+ Когато даден запис в СИГМА е разпознат като физическо лице или едноличен търговец (ЕТ),
+ прилагаме допълнителна защита в машинно-четливите формати. Отговорите се предават с
+ HTTP заглавието X-Robots-Tag: noindex, за да не попадат в индексите на
+ търсачките и останалите автоматични роботи.
+
+
+ Засегнати са следните машинно-четливи формати: JSON записът на отделен договор
+ (/contracts/:id.json) и трите публични CSV износа —{' '}
+ /contracts.csv, /companies.csv и{' '}
+ /authorities.csv. В тях ЕИК и оригиналното име от източника (raw source
+ name) на физическото лице се заменят с неутрален, неидентифициращ етикет, а полето за
+ ЕИК остава празно. Записите за юридически лица не се променят — техните ЕИК и имена
+ остават непокътнати.
+
+
+ HTML профилът на дружеството (страницата, която посетителят отваря в браузъра) остава
+ непокътнат по съдържание — името, ЕИК и всички останали полета се показват както в
+ първичния източник. Единствената допълнителна мярка е мета-етикетът{' '}
+ noindex, който препоръчва на търсачките да не индексират страницата. Така
+ публичният достъп за хората се запазва, а публичното откриване чрез търсене — не.
+
+
+ Описаната политика е продуктово и инженерно решение за последователно прилагане на
+ защитата на личните данни в машинно-четливите формати на СИГМА. Тя не представлява
+ правен съвет и не заменя официалната консултация с юрист по конкретен случай.
+
+
+
Правно основание
diff --git a/docs/architecture.md b/docs/architecture.md
index ab598c091..98a0a5894 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,52 +1,182 @@
-# Архитектура
+# ADR-0001 — Стратегия за рендиране на front-end-а и модел на сигурност
-Преглед на системата на едно място и указател към записаните решения. **Архитектурните решения
-живеят като ADR в [`adr/`](adr/README.md)** — този файл е картата, не източникът на истина за
-отделните решения.
+- **Статус:** Прието
+- **Обхват:** Итерация 1 — публичният, read-only explorer на обществените поръчки (АОП). Документът отбелязва и пътя напред към по-широката платформа (оценка на риска, аномалии, картелен анализ — roadmap).
-## Поток на данните
+> **Актуализация (2026-05-21):** Решението за фреймуърк по-долу беше променено от SvelteKit на **React Router v7 върху Cloudflare Workers** след претегляне на екосистемата, набирането на хора и контрибутори, primitives за достъпност и екосистемата за визуализации/AI. Решенията за рендиране (§2) и за сигурност (§3) са независими от фреймуърка и остават непроменени; обновени бяха само техните специфични за фреймуърка механики.
-```
-ЦАИС ЕОП (storage.eop.bg) ──bulk/cron──▶ sigma-etl (Worker + Workflow)
- │ load → normalize → derive → precompute
- ▼
- D1 (една база на среда)
- ▲
- │ read-only
- граждани / журналисти ──▶ sigma (RR v7 SSR Worker)
-```
+## Контекст
-Данните пристигат като **периодични bulk зареждания**, не като жива емисия. ETL-ът пише в D1; explorer-ът
-чете от **същия** D1. В v1 няма публичен write път и няма автентикация.
+Итерация 1 е **публичен, read-only** отчетен и визуализационен слой върху ~129 хил. реда договори от АОП в Cloudflare D1, насочен към граждани, журналисти и НПО, с интерфейс на български. Уеб приложението първоначално беше скелетирано върху **SvelteKit**; този ADR преразглежда този избор. Сървърната среда е **Cloudflare Workers**, които четат D1. Данните пристигат като **периодични bulk зареждания** (`scripts/import.mjs`), а не като жива емисия; в тази итерация **няма публичен write път и няма автентикация**.
-## Двата Worker-а
+Три въпроса стояха в основата на решението:
-| Worker | Роля |
-| ---------- | ----------------------------------------------------------------- |
-| `sigma` | SSR explorer (`apps/web`) — чете D1 директно, edge-кешира HTML. |
-| `sigma-etl`| cron-задействан refresh Workflow (`apps/etl`) — пише в D1. |
+1. Трябва ли front-end-ът да премине от Svelte към React?
+2. Трябва ли страниците да се рендират на сървъра (SSR), да се пре-рендират (статично), или да са класическо клиентско SPA срещу API?
+3. Променят ли отговора атаките през SSR / „hydration“?
-## Рендиране и сигурност (резюме)
+## Решение
-- **Рендиране:** хибридно според повърхността — пре-рендиране за статичните страници, SSR + edge
- кеш за обемните детайлни/explorer страници, клиентски „острови" за интерактивните визуализации.
-- **Сигурност:** приоритет интегритет ≈ наличност ≫ конфиденциалност (данните са публични). Read
- пътят остава read-only; строг CSP + security headers; D1 само с bound параметри.
+### 1. React с React Router v7 (framework mode) върху Cloudflare Workers
-Пълната обосновка и приетите компромиси — [ADR-0001](adr/0001-rendering-and-security.md).
+Избран пред първоначалния SvelteKit скелет след претегляне на дълготрайните фактори:
-## Решения (ADR)
+- **Екосистема и преизползване** — най-голямата екосистема от компоненти/библиотеки и най-богатите опции точно за повърхностите, върху които СИГМА стъпва: мрежови графи на връзки (`@xyflow/react`), таблици за големи данни (TanStack Table / AG Grid), графики (visx / Recharts), а за по-късния асистент — AI SDK (`@ai-sdk/react`) и зрели UI-та за асистенти.
+- **Набиране на хора и open-source контрибутори** — значително по-голям talent pool; релевантно за вероятно OSS гражданска платформа („Лицензът ще бъде определен преди публичното пускане“).
+- **Primitives за достъпност** — React Aria / Radix са сред най-добрите в класа си за изричната цел **WCAG 2.2 AA**.
+- **Дълготрайност** — най-защитимият дългосрочен избор за дълголетна платформа от обществена полза.
-Виж индекса в [`adr/README.md`](adr/README.md). Ключовите за v1: рендиране/сигурност (0001),
-D1 като хранилище (0002), `value_flag` стойностна база (0003), CSP `style-src` (0004), blue/green
-rollback (0005), dedup на двата източника (0006).
+**React Router v7 (framework mode), а не Next.js** — той върви чисто върху **Cloudflare Workers** (през `@cloudflare/vite-plugin`) и запазва модела SSR-на-edge и хибридното рендиране от §2. Приети компромиси спрямо SvelteKit: по-голям клиентски runtime (смекчен от SSR + кеширане) и липса на first-party CF *Pages* адаптер — RR7 се деплойва като Worker, което освен това уеднаквява `apps/web` с останалите `apps/*`.
-## Справочни документи
+### 2. Рендиране: хибридно, избрано според повърхността
-- [`core-scope.md`](core-scope.md) — доменен модел и речник на данните.
-- [`etl.md`](etl.md) — ETL pipeline и емисията от ЦАИС ЕОП.
-- [`v1-implementation-plan.md`](v1-implementation-plan.md) — precompute слой и пагинация.
-- [`integrity-gate.md`](integrity-gate.md) — reconciliation gate (hard asserts върху тоталите).
-- [`deploy.md`](deploy.md) — деплой към Cloudflare.
-- [`accessibility.md`](accessibility.md) — достъпност (WCAG 2.1 AA).
-- [`spec/ai-assistant.md`](spec/ai-assistant.md) — спецификация на AI асистента (планиран).
+По подразбиране **SSR + edge кеширане**; пре-рендиране само за реално статичните страници; клиентско рендиране само за интерактивните „острови“. **Не** чисто SPA за публично съдържание (SEO/споделяемост + цена на първото изрисуване); **не** build-time пре-рендиране на целия корпус (десетки хиляди страници за компании/институции, а търсенето изобщо не може да се пре-рендира).
+
+| Повърхност | Стратегия | Защо |
+| --- | --- | --- |
+| Начало, За проекта, **методология / „как работят червените флагове“**, документация за отворените данни | Пре-рендиране (статично) | Рядко се променят → надеждно + безплатно |
+| Профил на компания (по ЕИК), страница на институция, детайл на поръчка/обособена позиция | **SSR + edge кеш** (`s-maxage` + `stale-while-revalidate`), purge при презареждане на набора данни | Голям обем, променя се само при bulk презареждане → скорост като при статика + устойчивост на DDoS, актуалност след презареждане |
+| Explorer: търсене / филтри / класации („най-големи бенефициенти“) | SSR, кратък кеш, варира според заявката | Безкрайно URL пространство; задвижван от SQL |
+| Интерактивни визуализации (граф на мрежа/потоци, карта на България, сортируеми таблици) | Клиентски рендиран **остров**, хидриран върху SSR-ната страница + първоначални данни | Нуждае се от клиентска интерактивност; SSR на обвивката заради SEO/първо изрисуване |
+
+Тъй като наборът данни е периодична снимка (а не жив), кеш TTL-ите може да са дълги, а едно презареждане просто **purge-ва** кеша — почти всички предимства на статиката, без да се изброява корпусът при build.
+
+### 3. Модел на сигурност
+
+Приоритети за публичен сайт за прозрачност: **интегритет ≈ наличност ≫ конфиденциалност** — данните са публични по дизайн; тяхната *достоверност* и *uptime* са продуктът.
+
+**Контроли в итерация 1:**
+
+- **Read път да остане read-only.** Ingestion-ът е офлайн (`scripts/import.mjs`); няма публичен write endpoint. Това се запазва — без публична мутация, без админ в итерация 1.
+- **Edge модел + кеширане като поглъщане на DDoS.** Cloudflare поглъща L3/4; кешираният HTML означава, че атакуващ / scrape трафик удря кеша, а не D1.
+- **WAF + rate limiting** върху задвижвания от SQL explorer/търсене и върху всеки export на отворени данни; ограничаване на `limit` / пагинация / размер на export-а, така че нито една заявка да не сканира цялата таблица.
+- **Строг CSP + security headers** — генериране на per-request nonce в `entry.server.tsx`, подаван към `` / `` на React Router и към header-а `Content-Security-Policy` (за да са разрешени hydration скриптовете на фреймуърка без `unsafe-inline`). Добавяне на HSTS, `X-Content-Type-Options`, `Referrer-Policy`.
+- **D1 prepared statements само с bound параметри**; никога конкатениране на SQL чрез низове.
+
+**По въпроса за „hydration атаките“** — реалните класове и как този дизайн ги адресира:
+
+- **Изтичане между потребители през кеширан персонализиран SSR** — неприложимо в итерация 1 (без автентикация; всяка страница е анонимна и идентична → безопасна за кеширане). Остава предотвратено и по-късно, като се кешират *само* анонимни публични страници и никога автентикираните.
+- **Свръх-сериализиране на тайни в hydration payload-а** — смекчено чрез типизираните response DTO-та в `packages/api-contract` (отдават проекции на същностите, никога сурови редове) и чрез държане на код, носещ credentials, извън уеб/read пътя. DTO-тата да остават без вътрешни-само полета.
+- **XSS през сериализирано състояние / рендирани данни** — разчита се на авто-escaping-а на React/JSX; **никога `dangerouslySetInnerHTML`** върху текст от АОП (институция / компания / предмет са от външен източник); строгият CSP е резервната защита.
+
+**Отложено за по-късни фази** (повърхностите на по-широката платформа):
+
+- **Workflow-и за възложители/участници + админ** → Cloudflare Access (SSO + MFA) за админ; строго разделяне на публичен/персонализиран кеш; изолация на write пътя в отделен worker.
+- **AI асистент за обществени поръчки** → маршрутизиране през **AI Gateway** (лимити за rate/разход, кеширане, логване); read-only, параметризирани инструменти; заземяване на всяко твърдение в изчислени данни (риск от клевета); никакво кеширане; рендиране на изхода като текст. Спецификация — [`spec/ai-assistant.md`](spec/ai-assistant.md).
+
+## Последствия / scaffold follow-up-и
+
+- Конфигуриране на пре-рендирането в `react-router.config.ts` (`prerender` пътища) за статичните информационни route-ове.
+- Задаване на `Cache-Control` (`s-maxage` + `stale-while-revalidate`) през `headers` export-ите на route-овете при SSR data route-овете; иницииране на cache purge (по URL/таг) в края на скрипта за зареждане на данните.
+- Задаване на CSP + security headers в `entry.server.tsx` (или Worker middleware) с per-request nonce върху ``.
+- Добавяне на Cloudflare rate-limit правило + WAF managed ruleset за explorer/export endpoint-ите (infra конфигурация, проследявана отделно).
+- Премахване или auth-gate на отворения `POST /etl/run` от скелета в `apps/etl` преди какъвто и да е деплой.
+
+## Свързани документи
+
+- [`etl.md`](etl.md) — ETL pipeline-ът и емисията от ЦАИС ЕОП.
+- [`deploy.md`](deploy.md) — деплой към Cloudflare и оперативна сигурност.
+- [`spec/ai-assistant.md`](spec/ai-assistant.md) — спецификация на планирания AI асистент.
+- [`README.md`](../README.md) на репозиторието и [`../AGENTS.md`](../AGENTS.md) — общ преглед и работни конвенции.
+
+# ADR-0002 — Политика за поверителност на машинно-четим изход (Issue #173)
+
+- **Статус:** Прието
+- **Обхват:** Повърхностите на СИГМА, които връщат машинно-четимо тяло — JSON записите на договорите (`/contracts/:id.json`) и CSV експортите (`/contracts.csv`, `/companies.csv`, `/authorities.csv`). Не засяга HTML рендирането на профилите, което вече имаше собствен `noindex` път.
+- **Препратки:** [Issue #173](https://github.com/midt-bg/sigma/issues/173) · [`apps/web/app/routes/privacy.tsx`](../apps/web/app/routes/privacy.tsx) (`# natural-person-data`) · ADR-0001 §3 (модел на сигурност)
+
+> Решението за маскиране на идентификаторите на едноличните търговци и физическите лица в машинно-четимия изход е продуктово-политическо и инженерно, не правно. GDPR и ЗЗЛД се третират като контекст за вземане на решение, не като юридическо тълкуване — окончателната оценка е задължение на поддържащите проекта.
+
+## Контекст
+
+Итерация 1 на СИГМА публикува идентификатори на изпълнителите на обществени поръчки в три форми: (1) HTML профили на фирми (по ЕИК), (2) JSON запис на договора (`/contracts/:id.json`) за връзка от HTML-а, и (3) CSV експорти (`/contracts.csv`, `/companies.csv`, `/authorities.csv`) за журналисти и изследователи.
+
+HTML профилът на фирма вече прилагаше `noindex` мета таг за записи, които се разпознават като едноличен търговец или физическо лице (`isSingleNaturalPersonProfile` в [`apps/web/app/routes/company.tsx`](../apps/web/app/routes/company.tsx) — *inline*, преди ADR-0002). Логиката имаше две слабости:
+
+1. **Дупка между HTML и машинно-четим изход.** Търсачките и бот-индексаторите, които не изпълняват HTML мета таговете, можеха да достигнат до JSON и CSV записите и да индексират същите естествено-личностни идентификатори (ЕИК, пълно име на ЕТ). Това противоречи на политиката `noindex` от страна на HTML.
+2. **Дублирана логика на откриване.** Разпознаването „ЕТ …" беше вградено в route-а като `isSingleNaturalPersonProfile` (inline), а друга негова разновидност (`isNaturalPersonProfileName`) живееше в [`packages/shared/src/format.ts`](../packages/shared/src/format.ts). Без единен предикат всяка нова машинно-четима повърхност щеше да копира правилата, а при разминаване на копията — да изтече идентификатор.
+
+Допълнителен фактор: edge кешът на CSV отговорите (`Cache-Control: public, max-age=3600`) съдържа пълните байтове на стрийма. Ако едно и също тяло се сервираше и за юридически лица, и за ЕТ без маскиране, кешът щеше да замрази изтичането — което означава, че маскирането трябва да се случи **преди** записа в R2.
+
+## Решение
+
+Приета е политика **`noindex` плюс маскиране**, с общ предикат от [`packages/shared/src/format.ts`](../packages/shared/src/format.ts):
+
+1. **Единен предикат.** Премахваме inline `isSingleNaturalPersonProfile` от `apps/web/app/routes/company.tsx` и заменяме с `isNaturalPersonBidder(name, legalForm)` от `packages/shared/src/format.ts`. Предикатът комбинира два сигнала — `legal_form LIKE 'ЕТ%'` (включително латинското `ET`, разширените форми `ЕДНОЛИЧЕН ТЪРГОВЕЦ`, `SOLE TRADER`, `INDIVIDUAL`) и водещия `ЕТ ` / `ET ` суфикс в името (който вече беше в `isNaturalPersonProfileName`). HTML `noindex` мета тагът, CSV маскирането и JSON маскирането споделят **единствено** този предикат — няма дублирани хардкоднати правила в route-овете.
+2. **`X-Robots-Tag: noindex` на всяка машинно-четима повърхност.** Маркерът се задава на четирите клона на [`apps/web/app/lib/csv-export.ts`](../apps/web/app/lib/csv-export.ts) (`markCsvCache`, динамичният `Response`, 200/206 `Response`, 304 `Response`) и на loader-а на [`apps/web/app/routes/contract.json.tsx`](../apps/web/app/routes/contract.json.tsx) само когато записът е маскиран. Маркерът е допълнение към `robots.txt` (който вече забраняваше `/*.csv`) — покрива crawler-и и инструменти, които четат HTTP-хедъри, но не и `robots.txt`.
+3. **Маскиране на ЕИК и оригиналното име.** За разпознати естествено лица / еднолични търговци:
+ - **CSV:** `contractor_eik`/`eik` се замества с празен низ, `contractor`/`name` се замества със символа `MASKED_NATURAL_PERSON_LABEL` от [`packages/shared/src/format.ts:197`](../packages/shared/src/format.ts) (стойност `„Частно лице"` — без запетая/кавичка, за да не се нуждае от CSV-escape).
+ - **JSON:** `bidder.eik` → `null`, `bidder.name` → `MASKED_NATURAL_PERSON_LABEL`, `bidder.displayName` → `MASKED_NATURAL_PERSON_LABEL`, `sourceNames.bidder` → `MASKED_NATURAL_PERSON_LABEL`. Останалите полета (`bidder.slug`, агрегатите `totalContracts`/`totalEur`, `kind`, `settlement`) остават непроменени — `slug`-ът не е PII, а е URL фрагмент.
+4. **Символът `MASKED_NATURAL_PERSON_LABEL` е единичен източник на истината.** Експортиран от `@sigma/shared` (баррелът [`packages/shared/src/index.ts`](../packages/shared/src/index.ts)). Тестовете го импортират по символ, не по литерален текст — преименуване на етикета (напр. на „Физическо лице") няма да счупи нито един тест.
+5. **Юридическите лица не се пипат.** `legal_form` `АД`/`ООД`/`ЕАД`/`ЕООД` остават видими както в CSV, така и в JSON. Само когато предикатът върне `true` се прилага маската.
+
+## Последствия
+
+- **Кеш политиката остава.** `Cache-Control: public, max-age=3600` за CSV и `public, s-maxage=3600, stale-while-revalidate=86400` за JSON остават непроменени — маскираните байтове са безопасни за кеширане, защото не съдържат естествено-личностни идентификатори. Edge кешът е приоритет за DDoS устойчивостта (ADR-0001 §3) и отпадането му би влошило публичната достъпност на експортите.
+- **R2 CSV кешът се регенерира при първото презареждане след тази промяна.** Ключът на обекта е `csv//` ([`apps/web/app/lib/csv-export.ts`](../apps/web/app/lib/csv-export.ts)). Следващото bulk презареждане (през `scripts/import.mjs`) инкрементира `freshnessVersion` и новите обекти се записват вече маскирани; старите се презаписват естествено (без миграция).
+- **Брой маскирани редове е равен на броя флагнати от предиката.** Всеки ред в CSV стрийма се оценява независимо от `isNaturalPersonBidder(name, legalForm)`; маскирането е per-row, не per-batch. Контролната проверка за това е в `packages/db/src/queries/contracts.test.ts` (тестът „masks the contractor and clears contractor_eik when legal_form is a sole-trader form (ЕТ)") и в `packages/db/src/queries/companies.test.ts` (тестът „writes MASKED_NATURAL_PERSON_LABEL + empty EIK for an ЕТ row in the rollup branch").
+- **HTML профилът не е засегнат.** `noindex` мета тагът за естествени лица остава непроменен — предикатът е същият, но маркерът вече е в общата логика, не в inline код.
+- **Подаване на документацията надолу по веригата.** В [`apps/web/app/routes/privacy.tsx`](../apps/web/app/routes/privacy.tsx) е добавена секция `# natural-person-data`, която описва подхода за потребители и журналисти: кои идентификатори се маскират, кои остават, и причината (`noindex` плюс етикет вместо истинското име).
+- **`authorities.csv` получава само `X-Robots-Tag: noindex`.** Възложителите в корпуса винаги имат попълнен ЕИК (публични органи) — не се прилага маскиране на тялото. Маркерът е политическа последователност (еднаква политика за всички CSV), не техническо маскиране.
+- **Стримовете не се рефакторират.** Контрактите `streamContractsCsv`/`streamCompaniesCsv`/`streamAuthoritiesCsv` остават същите; маскирането е вътрешен branch в per-row цикъла, който разчита на новата `b.legal_form` колона в SELECT-а.
+
+## Засегнати повърхности
+
+### Споделена логика
+
+- [`packages/shared/src/format.ts`](../packages/shared/src/format.ts) — нови `isNaturalPersonBidder(name, legalForm)` и `MASKED_NATURAL_PERSON_LABEL`; запазен `isNaturalPersonProfileName` за backwards compatibility (все още се използва от `streamCompanySitemap`).
+- [`packages/shared/src/format.test.ts`](../packages/shared/src/format.test.ts) — нови тестове за предиката, етикета и „shared predicate surface" (truth-table блок).
+
+### DB заявки (CSV стрийм + JSON getContract)
+
+- [`packages/db/src/queries/contracts.ts`](../packages/db/src/queries/contracts.ts) — `streamContractsCsv` проектира `b.legal_form AS bidder_legal_form` и прилага маскиращия branch в per-row цикъла (редове ~436, ~450-453).
+- [`packages/db/src/queries/companies.ts`](../packages/db/src/queries/companies.ts) — `streamCompaniesCsv` и двата клона на `source()` (`company_totals` rollup и base-aggregation CTE) проектират `b.legal_form`; per-row цикълът маскира `eik`/`name`.
+- [`packages/db/src/queries/details.ts`](../packages/db/src/queries/details.ts) — `getContract` проектира `b.legal_form AS bidder_legal_form` (съществуващият `JOIN bidders b` се преизползва); `ContractDetailRow` и типът на return-а се разширяват с `bidder_legal_form: string | null`.
+- [`packages/db/src/queries/contracts.test.ts`](../packages/db/src/queries/contracts.test.ts) — нов `describe('streamContractsCsv masking', ...)` блок (4 теста).
+- [`packages/db/src/queries/companies.test.ts`](../packages/db/src/queries/companies.test.ts) — нов `describe('streamCompaniesCsv masking', ...)` блок (4 теста).
+
+### Web route-ове
+
+- [`apps/web/app/routes/contract.json.tsx`](../apps/web/app/routes/contract.json.tsx) — добавя чистия хелпер `maskContractForPrivacy(record, bidderLegalForm): ContractRecord` (модулен export, за тестваемост) и извиква `X-Robots-Tag: noindex` само когато маскирането е приложило (reference-equality гейт). 404 клонът и `Cache-Control` остават непроменени.
+- [`apps/web/app/routes/contract.json.test.ts`](../apps/web/app/routes/contract.json.test.ts) — нов тестов файл (3 теста за хелпера + 4 за loader-а, общо 7).
+- [`apps/web/app/routes/company.tsx`](../apps/web/app/routes/company.tsx) — премахва inline `isSingleNaturalPersonProfile`; HTML `noindex` сега идва от `isNaturalPersonBidder`.
+- [`apps/web/app/routes/contracts.csv.tsx`](../apps/web/app/routes/contracts.csv.tsx), [`apps/web/app/routes/companies.csv.tsx`](../apps/web/app/routes/companies.csv.tsx), [`apps/web/app/routes/authorities.csv.tsx`](../apps/web/app/routes/authorities.csv.tsx) — без промяна на кода; маркерът идва от `servedCsvExport`.
+
+### Web помощен код
+
+- [`apps/web/app/lib/csv-export.ts`](../apps/web/app/lib/csv-export.ts) — `X-Robots-Tag: noindex` се задава в `markCsvCache` (споделения път) и в двата директни `new Response(...)` сайта (304 и 200/206). Съществуващите `Cache-Control` и `Content-Disposition` остават.
+- [`apps/web/app/lib/csv-export.test.ts`](../apps/web/app/lib/csv-export.test.ts) — нов `describe('servedCsvExport privacy', ...)` блок (5 теста: MISS/HIT/dynamic за трите CSV повърхности + body+Cache-Control проверка).
+
+### Потребителска документация
+
+- [`apps/web/app/routes/privacy.tsx`](../apps/web/app/routes/privacy.tsx) — нова секция `# natural-person-data` след съществуващия `# data` блок. Обяснява маскирането на естествено-личностни идентификатори и `noindex` политиката в машинно-четимия изход.
+
+## Доказателство
+
+### Тестове — маскиране и noindex (тези файлове са цитирани в success criteria на Issue #173)
+
+| Файл | Тестове | Покритие |
+| --- | --- | --- |
+| [`packages/shared/src/format.test.ts`](../packages/shared/src/format.test.ts) | `isNaturalPersonBidder`, `MASKED_NATURAL_PERSON_LABEL`, `shared predicate surface — single source of truth for the noindex / masking decision` | Предикатът и етикетът — единственият източник на истината. |
+| [`packages/db/src/queries/contracts.test.ts`](../packages/db/src/queries/contracts.test.ts) | `streamContractsCsv masking` (4 теста) | Per-row CSV маскиране: ЕТ маскира, ООД запазва, leading-ЕТ хевристика, консорциум ДЗЗД запазва `kind`. |
+| [`packages/db/src/queries/companies.test.ts`](../packages/db/src/queries/companies.test.ts) | `streamCompaniesCsv masking` (4 теста) | Двата клона на `source()` (rollup + base-aggregation) маскират ЕТ и запазват ООД. |
+| [`apps/web/app/lib/csv-export.test.ts`](../apps/web/app/lib/csv-export.test.ts) | `servedCsvExport privacy` (5 теста) | `X-Robots-Tag: noindex` на MISS/HIT/dynamic за трите CSV повърхности; тялото съдържа етикета и изключва оригиналното име; `Cache-Control` остава `public, max-age=3600`. |
+| [`apps/web/app/routes/contract.json.test.ts`](../apps/web/app/routes/contract.json.test.ts) | `maskContractForPrivacy` (3 теста) + `contract.json loader` (4 теста) | Чист хелпер + интеграция през loader-а: ЕТ маскира и получава `noindex`, АД преминава без `noindex`, 404 не е засегнат, `Cache-Control` е `public, s-maxage=3600, stale-while-revalidate=86400`. |
+
+### Финални exit code-ове от `ralph/verification-baseline.md`
+
+Източник: [`ralph/verification-baseline.md`](../ralph/verification-baseline.md) (секция „Post-edit", записана СЛЕД приключване на T-001 … T-012).
+
+| Команда | Exit code | Резюме |
+| --- | --- | --- |
+| `pnpm typecheck` | 0 | 7 turbo tasks успешни; widened return type на `getContract` и `maskContractForPrivacy` хелперът са ковариантни с предишните форми. |
+| `pnpm --filter @sigma/shared test` | 0 | 2 файла, 42 теста минават (включително новите за `isNaturalPersonBidder`, `MASKED_NATURAL_PERSON_LABEL` и shared-predicate surface). |
+| `pnpm --filter @sigma/web test` | 0 | 31 файла, 296 теста минават (включително новите 5 в `csv-export.test.ts` и новите 7 в `contract.json.test.ts`). |
+| `pnpm --filter @sigma/db test` | 1 | 25 файла, 174 теста, 171 минават, 3 **предварително съществуващи** failure-а (не са въведени от тази промяна): `integrity-checks.test.ts` (rollup-reconciliation) и два timeout-а в `refresh-slice.test.ts`. Маскиращите файлове (`contracts.test.ts`, `companies.test.ts`) минават изцяло. |
+| `pnpm lint` | 1 | 5 **предварително съществуващи** prettier warning-а във файлове извън обхвата (`RiskIndicators.tsx`, `riskLogic.test.ts`, `companies.test.ts`, `companies.ts`, `contract.json.test.ts`). `docs/architecture.md` е в `.prettierignore`. |
+
+Нетният delta спрямо чистия baseline (преди прилагане на T-001 … T-012): **+11 нови теста, 0 нови failure-а** (преди: 160 passed / 3 failed; след: 171 passed / 3 failed). Промяната не влошава нито един предварително съществуващ failure, както изисква success criteria.
+
+### Оперативна бележка
+
+При първото bulk презареждане след deploy-а на тази промяна R2 обектите под `csv//` ще бъдат презаписани с вече маскирани байтове. До този момент — ако някой направи `wrangler r2 object get` — може да види оригиналните CSV файлове в кеша. Това е приемливо защото (1) файловете са в `robots.txt` + `X-Robots-Tag: noindex` и не се индексират, и (2) следващото презареждане ще ги подмени естествено.
diff --git a/packages/db/src/queries/companies.test.ts b/packages/db/src/queries/companies.test.ts
index f6e038e97..56f628ca3 100644
--- a/packages/db/src/queries/companies.test.ts
+++ b/packages/db/src/queries/companies.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
+import { MASKED_NATURAL_PERSON_LABEL } from '@sigma/shared';
import { listCompanies, streamCompaniesCsv, type CompanyListParams } from './companies';
import type { CompanyTotalsRow } from './rows';
@@ -18,6 +19,7 @@ const filteredRows: (CompanyTotalsRow & { sort_value: number })[] = [
eu_eur: 1000,
first_date: '2024-01-01',
last_date: '2024-01-02',
+ legal_form: 'ООД',
sort_value: 1000,
},
];
@@ -39,6 +41,7 @@ const unfilteredRows: (CompanyTotalsRow & { sort_value: number })[] = [
eu_eur: 0,
first_date: '2023-01-01',
last_date: '2023-01-02',
+ legal_form: 'ЕООД',
sort_value: 900,
},
];
@@ -127,6 +130,175 @@ describe('streamCompaniesCsv', () => {
});
});
+describe('streamCompaniesCsv masking', () => {
+ // Two-row fixture: one sole trader (legal_form='ЕТ'), one legal entity ('ООД'). Both branches of
+ // source() — the company_totals rollup (default) and the base-aggregation CTE (filtered) — must
+ // yield the same masked output for the same row, because the per-row masking branch is keyed on
+ // r.legal_form and not on which source() branch the row came from.
+ const maskingRows: CompanyTotalsRow[] = [
+ {
+ bidder_id: 'eik:222222222',
+ name: 'ЕТ Пример - Иван Иванов',
+ kind: 'company',
+ ownership_kind: null,
+ eik: '222222222',
+ eik_valid: 1,
+ settlement: 'Варна',
+ won_eur: 500,
+ contracts: 1,
+ authorities: 1,
+ primary_sector: '45',
+ eu_eur: 0,
+ first_date: '2024-02-01',
+ last_date: '2024-02-02',
+ legal_form: 'ЕТ',
+ },
+ {
+ bidder_id: 'eik:333333333',
+ name: 'Пример ООД',
+ kind: 'company',
+ ownership_kind: null,
+ eik: '333333333',
+ eik_valid: 1,
+ settlement: 'Бургас',
+ won_eur: 700,
+ contracts: 1,
+ authorities: 1,
+ primary_sector: '45',
+ eu_eur: 0,
+ first_date: '2024-02-01',
+ last_date: '2024-02-02',
+ legal_form: 'ООД',
+ },
+ ];
+
+ function maskingDb(): D1Database {
+ return {
+ prepare(sql: string) {
+ let bound: unknown[] = [];
+ return {
+ bind(...args: unknown[]) {
+ bound = args;
+ return this;
+ },
+ async all() {
+ if (sql.includes('ORDER BY bidder_id')) {
+ const afterId = bound.at(-2) as string;
+ return {
+ results: maskingRows.filter((r) => r.bidder_id > afterId) as T[],
+ };
+ }
+ return { results: maskingRows as T[] };
+ },
+ async first() {
+ return { n: maskingRows.length } as T;
+ },
+ };
+ },
+ } as D1Database;
+ }
+
+ function parseLine(line: string | undefined): string[] {
+ return (line ?? '').split(',');
+ }
+
+ it('writes MASKED_NATURAL_PERSON_LABEL + empty EIK for an ЕТ row in the rollup branch', async () => {
+ const csv = await streamCompaniesCsv(maskingDb(), {}).text();
+ const header = csv.trim().split('\n')[0];
+ expect(header).toBe('eik,name,kind,settlement,won_eur,contracts,authorities,primary_sector');
+
+ const [maskedEik, maskedName] = parseLine(csv.trim().split('\n')[1]);
+ expect(maskedEik).toBe('');
+ expect(maskedName).toBe(MASKED_NATURAL_PERSON_LABEL);
+ });
+
+ it('preserves verbatim name + populated EIK for an ООД row in the rollup branch', async () => {
+ const csv = await streamCompaniesCsv(maskingDb(), {}).text();
+ const [legalEik, legalName] = parseLine(csv.trim().split('\n')[2]);
+ expect(legalEik).toBe('333333333');
+ expect(legalName).toBe('Пример ООД');
+ });
+
+ it('keeps the other columns unchanged for both masked and legal-entity rows', async () => {
+ const csv = await streamCompaniesCsv(maskingDb(), {}).text();
+ const [maskedEik, maskedName, maskedKind, maskedSettlement, maskedWon, maskedContracts, maskedAuth, maskedSector] = parseLine(
+ csv.trim().split('\n')[1],
+ );
+ const [legalEik, legalName, legalKind, legalSettlement, legalWon, legalContracts, legalAuth, legalSector] = parseLine(
+ csv.trim().split('\n')[2],
+ );
+
+ expect(maskedEik).toBe('');
+ expect(maskedName).toBe(MASKED_NATURAL_PERSON_LABEL);
+ expect([maskedKind, maskedSettlement, maskedWon, maskedContracts, maskedAuth, maskedSector]).toEqual([
+ 'company',
+ 'Варна',
+ '500',
+ '1',
+ '1',
+ '45',
+ ]);
+ expect([legalEik, legalName, legalKind, legalSettlement, legalWon, legalContracts, legalAuth, legalSector]).toEqual([
+ '333333333',
+ 'Пример ООД',
+ 'company',
+ 'Бургас',
+ '700',
+ '1',
+ '1',
+ '45',
+ ]);
+ });
+
+ it('masks rows whose legal_form is ЕТ regardless of which source() branch they came from (base-aggregation path)', async () => {
+ // A sector filter forces the base-aggregation CTE branch; the rollup subquery is bypassed.
+ // The ЕТ row must still be masked, because the per-row loop consults isNaturalPersonBidder
+ // against r.legal_form (which both source() branches now project).
+ const csv = await streamCompaniesCsv(maskingDb(), { sectors: ['45'] }).text();
+ const [maskedEik, maskedName] = parseLine(csv.trim().split('\n')[1]);
+ expect(maskedEik).toBe('');
+ expect(maskedName).toBe(MASKED_NATURAL_PERSON_LABEL);
+ });
+
+ it('preserves verbatim name + populated EIK for an ООД row in the base-aggregation path', async () => {
+ // Symmetric counterpart of the previous test: the same sector filter still routes through the
+ // base-aggregation CTE branch, but the ООД row must pass through unchanged. Masking only fires
+ // for rows whose legal_form flags them as a natural person — ООД is not one of those forms.
+ // This guards against a regression that breaks the ООД path of the base-aggregation branch
+ // (e.g. dropping the `b.legal_form AS legal_form` projection would still mask on name heuristic).
+ const csv = await streamCompaniesCsv(maskingDb(), { sectors: ['45'] }).text();
+ const [legalEik, legalName] = parseLine(csv.trim().split('\n')[2]);
+ expect(legalEik).toBe('333333333');
+ expect(legalName).toBe('Пример ООД');
+ });
+
+ it('keeps the trailing columns (kind, settlement, won_eur, contracts, authorities, primary_sector) unchanged for the base-aggregation path', async () => {
+ // Same sector-filter setup as the previous two tests; we re-assert the full eight-column shape
+ // for the ООД row to pin down that the masking branch is the ONLY per-row divergence — every
+ // other cell must be the source-of-truth value passed through csvCell unchanged.
+ const csv = await streamCompaniesCsv(maskingDb(), { sectors: ['45'] }).text();
+ const [, , kind, settlement, wonEur, contracts, authorities, primarySector] = parseLine(
+ csv.trim().split('\n')[2],
+ );
+ expect([kind, settlement, wonEur, contracts, authorities, primarySector]).toEqual([
+ 'company',
+ 'Бургас',
+ '700',
+ '1',
+ '1',
+ '45',
+ ]);
+ });
+
+ it('emits the same header row in the base-aggregation path as in the rollup path', async () => {
+ // The header is built once at stream start from the fixed `cols` array; it must not differ when
+ // source() returns the base-aggregation CTE instead of the company_totals rollup subquery.
+ const csv = await streamCompaniesCsv(maskingDb(), { sectors: ['45'] }).text();
+ const header = csv.trim().split('\n')[0];
+ expect(header).toBe('eik,name,kind,settlement,won_eur,contracts,authorities,primary_sector');
+ });
+});
+
describe('prototype-key params (untrusted query values)', () => {
function spyDb(): { db: D1Database; sql: string[] } {
const db = fakeDb();
diff --git a/packages/db/src/queries/companies.ts b/packages/db/src/queries/companies.ts
index 8f2665238..7400af72f 100644
--- a/packages/db/src/queries/companies.ts
+++ b/packages/db/src/queries/companies.ts
@@ -4,6 +4,11 @@
import type { CompanyListItem, EntityKind, FacetCount, Page } from '@sigma/api-contract';
import { CPV_SECTORS, ENTITY_TYPES } from '@sigma/config';
+import {
+ cleanName,
+ isNaturalPersonBidder,
+ MASKED_NATURAL_PERSON_LABEL,
+} from '@sigma/shared';
import { csvCell } from './csv';
import { assertCovers } from './filter-guard';
import { filterSignature, keyset, pageCursors } from './keyset';
@@ -61,7 +66,7 @@ const COUNT_BUCKETS: Record = lookup({
const qs = (n: number) => Array.from({ length: n }, () => '?').join(', ');
-const COLS = `bidder_id, name, kind, ownership_kind, eik, eik_valid, settlement, won_eur, contracts, authorities, primary_sector, eu_eur, first_date, last_date`;
+const COLS = `bidder_id, name, kind, ownership_kind, eik, eik_valid, settlement, won_eur, contracts, authorities, primary_sector, eu_eur, first_date, last_date, legal_form`;
function normalizeEu(eu: unknown): 'eu' | 'national' | null {
return eu === 'eu' || eu === 'national' ? eu : null;
@@ -76,7 +81,11 @@ function needsBase(p: CompanyListParams): boolean {
* Keep consumed filter keys in sync with COMPANY_FILTER_KEYS and companyFilterSignature().
*/
function source(p: CompanyListParams): { from: string; params: unknown[] } {
- if (!needsBase(p)) return { from: 'company_totals', params: [] };
+ if (!needsBase(p))
+ return {
+ from: `(SELECT ct.*, b.legal_form AS legal_form FROM company_totals AS ct LEFT JOIN bidders AS b ON b.id = ct.bidder_id)`,
+ params: [],
+ };
const where: string[] = ['c.amount_eur IS NOT NULL'];
const params: unknown[] = [];
if (p.sectors?.length) {
@@ -93,6 +102,7 @@ function source(p: CompanyListParams): { from: string; params: unknown[] } {
const single = p.sectors?.length === 1 ? p.sectors[0]! : null;
const from = `(
SELECT b.id AS bidder_id, b.name, b.kind, b.ownership_kind, b.eik_normalized AS eik, b.eik_valid, b.settlement,
+ b.legal_form AS legal_form,
SUM(c.amount_eur) AS won_eur, COUNT(*) AS contracts, COUNT(DISTINCT t.authority_id) AS authorities,
${single ? '?' : 'NULL'} AS primary_sector,
SUM(CASE WHEN c.eu_funded = 1 THEN c.amount_eur ELSE 0 END) AS eu_eur,
@@ -261,10 +271,13 @@ export function streamCompaniesCsv(db: D1Database, p: CompanyListParams): Respon
}
let block = '';
for (const r of results) {
+ const isNatural = isNaturalPersonBidder(cleanName(r.name), r.legal_form);
+ const name = isNatural ? MASKED_NATURAL_PERSON_LABEL : r.name;
+ const eik = isNatural ? '' : r.eik;
block +=
[
- r.eik,
- r.name,
+ eik,
+ name,
r.kind,
r.settlement,
r.won_eur,
diff --git a/packages/db/src/queries/contracts.test.ts b/packages/db/src/queries/contracts.test.ts
index d23391116..aeceaa666 100644
--- a/packages/db/src/queries/contracts.test.ts
+++ b/packages/db/src/queries/contracts.test.ts
@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest';
-import { getContractFacets, listContracts, normalizeContractSort } from './contracts';
+import { MASKED_NATURAL_PERSON_LABEL } from '@sigma/shared';
+import {
+ getContractFacets,
+ listContracts,
+ normalizeContractSort,
+ streamContractsCsv,
+} from './contracts';
describe('normalizeContractSort', () => {
it('passes known sort keys through', () => {
@@ -128,3 +134,201 @@ describe('getContractFacets', () => {
});
});
});
+
+/**
+ * Privacy-masking surface for the CSV export — see ADR-0002 in `docs/architecture.md`.
+ * Each row of the export carries the bidder's `legal_form` so the streamer can decide whether
+ * to mask `contractor` and `contractor_eik`. The shared `isNaturalPersonBidder` predicate is the
+ * single source of truth; these tests pin the CSV-side behaviour.
+ */
+describe('streamContractsCsv masking', () => {
+ function makeCsvRow(overrides: Record) {
+ return {
+ id: 'c:1',
+ rowid: 1,
+ subject: 'Subject',
+ unp: 'UNP-1',
+ cpv_code: '45000000',
+ eu_funded: 0,
+ authority_id: 'auth:123456789',
+ authority_name: 'Authority',
+ authority_eik: '123456789',
+ bidder_id: 'eik:111111111',
+ bidder_name: 'Bidder',
+ bidder_kind: 'company' as const,
+ contractor_eik: '111111111',
+ bidder_legal_form: 'ООД',
+ procedure_type: 'Открита процедура',
+ signed_at: '2024-01-01',
+ bids_received: 3,
+ amount_eur: 1000,
+ ...overrides,
+ };
+ }
+
+ function csvDb(rows: Record[]): D1Database {
+ return {
+ prepare() {
+ let calls = 0;
+ return {
+ bind() {
+ return this;
+ },
+ async all() {
+ // First chunk returns the seeded rows (≤ CHUNK so the streamer closes after it).
+ // Any subsequent pull is answered with an empty array so the stream ends cleanly.
+ calls += 1;
+ return { results: (calls === 1 ? rows : []) as T[] };
+ },
+ async first() {
+ return { total: rows.length, eur: 0, suspect: 0 } as T;
+ },
+ };
+ },
+ } as unknown as D1Database;
+ }
+
+ function parseCsv(text: string): string[][] {
+ return text
+ .replace(/^/, '')
+ .trim()
+ .split('\n')
+ .map((line) => line.split(','));
+ }
+
+ it('masks the contractor and clears contractor_eik when legal_form is a sole-trader form (ЕТ)', async () => {
+ const db = csvDb([
+ makeCsvRow({
+ id: 'c:natural',
+ rowid: 1,
+ bidder_name: 'ЕТ НИКОЛАЙ КИРОВ',
+ bidder_kind: 'company',
+ bidder_legal_form: 'ЕТ',
+ contractor_eik: '176011111',
+ }),
+ makeCsvRow({
+ id: 'c:legal',
+ rowid: 2,
+ bidder_name: 'СОФАРМА ТРЕЙДИНГ',
+ bidder_kind: 'company',
+ bidder_legal_form: 'ООД',
+ contractor_eik: '121817309',
+ }),
+ ]);
+
+ const rows = parseCsv(await streamContractsCsv(db, {}).text());
+
+ // The header carries the documented column order — kept as the contract the CSV consumer sees.
+ expect(rows[0]).toEqual([
+ 'id',
+ 'unp',
+ 'subject',
+ 'authority',
+ 'authority_eik',
+ 'contractor',
+ 'contractor_eik',
+ 'kind',
+ 'sector_code',
+ 'procedure',
+ 'signed_at',
+ 'value_eur',
+ 'eu_funded',
+ 'bids_received',
+ ]);
+
+ const naturalRow = rows[1]!;
+ expect(naturalRow[0]).toBe('natural'); // contractSlug strips the leading "c:"
+ expect(naturalRow[5]).toBe(MASKED_NATURAL_PERSON_LABEL);
+ expect(naturalRow[6]).toBe('');
+
+ const legalRow = rows[2]!;
+ expect(legalRow[0]).toBe('legal');
+ expect(legalRow[5]).toBe('СОФАРМА ТРЕЙДИНГ');
+ expect(legalRow[6]).toBe('121817309');
+ });
+
+ it('keeps every other column verbatim for both masked and unmasked rows', async () => {
+ const db = csvDb([
+ makeCsvRow({
+ id: 'c:natural',
+ rowid: 1,
+ bidder_name: 'ЕТ НИКОЛАЙ КИРОВ',
+ bidder_kind: 'company',
+ bidder_legal_form: 'ЕТ',
+ contractor_eik: '176011111',
+ unp: 'UNP-NAT',
+ subject: 'Natural subject',
+ }),
+ makeCsvRow({
+ id: 'c:legal',
+ rowid: 2,
+ bidder_name: 'СОФАРМА ТРЕЙДИНГ',
+ bidder_kind: 'company',
+ bidder_legal_form: 'ООД',
+ contractor_eik: '121817309',
+ unp: 'UNP-LEG',
+ subject: 'Legal subject',
+ }),
+ ]);
+
+ const rows = parseCsv(await streamContractsCsv(db, {}).text());
+
+ // Skip the masked columns (5 = contractor, 6 = contractor_eik). Every other column must be the
+ // raw seeded value for both rows.
+ const header = rows[0]!;
+ const otherColumns = [0, 1, 2, 3, 4, 7, 8, 9, 10, 11, 12, 13];
+ for (const row of rows.slice(1)) {
+ for (const col of otherColumns) {
+ expect(row[col]!, `row "${row[0]}" column "${header[col]!}" must be defined`).toBeDefined();
+ }
+ }
+
+ const naturalRow = rows.find((r) => r[0] === 'natural')!;
+ expect(naturalRow[1]).toBe('UNP-NAT');
+ expect(naturalRow[2]).toBe('Natural subject');
+ expect(naturalRow[7]).toBe('company');
+
+ const legalRow = rows.find((r) => r[0] === 'legal')!;
+ expect(legalRow[1]).toBe('UNP-LEG');
+ expect(legalRow[2]).toBe('Legal subject');
+ expect(legalRow[7]).toBe('company');
+ });
+
+ it('masks via the leading-ЕТ name heuristic when legal_form is null', async () => {
+ const db = csvDb([
+ makeCsvRow({
+ id: 'c:heuristic',
+ rowid: 1,
+ bidder_name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ',
+ bidder_kind: 'company',
+ bidder_legal_form: null,
+ contractor_eik: '176011111',
+ }),
+ ]);
+
+ const rows = parseCsv(await streamContractsCsv(db, {}).text());
+
+ expect(rows[1]![5]).toBe(MASKED_NATURAL_PERSON_LABEL);
+ expect(rows[1]![6]).toBe('');
+ });
+
+ it('preserves the kind column so downstream consumers can still distinguish companies from consortia', async () => {
+ const db = csvDb([
+ makeCsvRow({
+ id: 'c:consortium',
+ rowid: 1,
+ bidder_name: 'A ООД; B ЕООД',
+ bidder_kind: 'consortium',
+ bidder_legal_form: 'ДЗЗД',
+ contractor_eik: '999999999',
+ }),
+ ]);
+
+ const rows = parseCsv(await streamContractsCsv(db, {}).text());
+
+ // ДЗЗД + consortium: predicate returns false; `kind` still flags the consortium shape.
+ expect(rows[1]![5]).toBe('A ООД и др.');
+ expect(rows[1]![6]).toBe('999999999');
+ expect(rows[1]![7]).toBe('consortium');
+ });
+});
diff --git a/packages/db/src/queries/contracts.ts b/packages/db/src/queries/contracts.ts
index 68a523022..2b13ed936 100644
--- a/packages/db/src/queries/contracts.ts
+++ b/packages/db/src/queries/contracts.ts
@@ -3,7 +3,12 @@
import type { ContractListItem, FacetCount, Page } from '@sigma/api-contract';
import { CPV_SECTORS, PROCEDURE_GROUPS, procedureGroup } from '@sigma/config';
-import { cleanName, entityName } from '@sigma/shared';
+import {
+ cleanName,
+ entityName,
+ isNaturalPersonBidder,
+ MASKED_NATURAL_PERSON_LABEL,
+} from '@sigma/shared';
import { csvCell } from './csv';
import { assertCovers } from './filter-guard';
import {
@@ -419,6 +424,7 @@ interface CsvRow extends ContractRow {
rowid: number;
authority_eik: string;
contractor_eik: string | null;
+ bidder_legal_form: string | null;
}
/** A streamed text/csv Response honouring the same filters; a 190k-row export never materialises. */
@@ -436,7 +442,8 @@ export function streamContractsCsv(db: D1Database, p: ContractListParams): Respo
async pull(controller) {
if (done) return;
const where = filters.sql ? filters.sql + ' AND c.rowid > ?' : ' WHERE c.rowid > ?';
- const sql = `${SELECT}, c.rowid AS rowid, a.bulstat AS authority_eik, b.eik_normalized AS contractor_eik
+ const sql = `${SELECT}, c.rowid AS rowid, a.bulstat AS authority_eik, b.eik_normalized AS contractor_eik,
+ b.legal_form AS bidder_legal_form
${FROM}${where} ORDER BY c.rowid LIMIT ?`;
const { results } = await db
.prepare(sql)
@@ -449,6 +456,12 @@ export function streamContractsCsv(db: D1Database, p: ContractListParams): Respo
}
let block = '';
for (const r of results) {
+ const bidderName = cleanName(r.bidder_name);
+ const isNatural = isNaturalPersonBidder(bidderName, r.bidder_legal_form);
+ const contractor = isNatural
+ ? MASKED_NATURAL_PERSON_LABEL
+ : entityName(bidderName, r.bidder_kind);
+ const contractorEik = isNatural ? '' : r.contractor_eik;
block +=
[
// CSV carries the RAW id (no URL escaping): literal `/`, `%`, … — not the `%2F`/`%25`
@@ -459,8 +472,8 @@ export function streamContractsCsv(db: D1Database, p: ContractListParams): Respo
r.subject,
cleanName(r.authority_name),
r.authority_eik,
- entityName(cleanName(r.bidder_name), r.bidder_kind),
- r.contractor_eik,
+ contractor,
+ contractorEik,
r.bidder_kind,
r.cpv_code ? r.cpv_code.slice(0, 2) : '',
procedureGroup(r.procedure_type).label,
diff --git a/packages/db/src/queries/details.ts b/packages/db/src/queries/details.ts
index eb7fe5d31..e279c188c 100644
--- a/packages/db/src/queries/details.ts
+++ b/packages/db/src/queries/details.ts
@@ -420,6 +420,7 @@ interface ContractDetailRow {
bidder_kind: 'company' | 'consortium';
bidder_eik: string | null;
bidder_settlement: string | null;
+ bidder_legal_form: string | null;
}
interface AmendmentRow {
@@ -461,7 +462,7 @@ export const AMENDMENTS_SQL = `SELECT am.value_before, am.value_after, am.value_
export async function getContract(
db: D1Database,
contractId: string,
-): Promise {
+): Promise<(ContractRecord & { bidder_legal_form: string | null }) | null> {
const r = await db
.prepare(
`SELECT c.id, c.tender_id, c.contract_subject, c.contract_number, c.document_number, c.lot_id,
@@ -479,7 +480,7 @@ export async function getContract(
t.authority_id, a.name AS authority_name, a.type_group AS authority_type_group,
a.settlement AS authority_settlement,
c.bidder_id, b.name AS bidder_name, b.kind AS bidder_kind, b.eik_normalized AS bidder_eik,
- b.settlement AS bidder_settlement,
+ b.settlement AS bidder_settlement, b.legal_form AS bidder_legal_form,
(SELECT COUNT(*) FROM contracts c2 WHERE c2.tender_id = c.tender_id) AS tender_awards
FROM contracts c
JOIN tenders t ON t.id = c.tender_id
@@ -714,5 +715,6 @@ export async function getContract(
authority: r.source_authority_name ?? r.authority_name,
bidder: r.bidder_name,
},
+ bidder_legal_form: r.bidder_legal_form,
};
}
diff --git a/packages/db/src/queries/rows.test.ts b/packages/db/src/queries/rows.test.ts
index eedfd5768..be7b28674 100644
--- a/packages/db/src/queries/rows.test.ts
+++ b/packages/db/src/queries/rows.test.ts
@@ -33,6 +33,7 @@ describe('toCompanyListItem', () => {
eu_eur: 10000,
first_date: '2022-01-01',
last_date: '2024-06-01',
+ legal_form: 'ООД',
};
it('maps core fields', () => {
diff --git a/packages/db/src/queries/rows.ts b/packages/db/src/queries/rows.ts
index 9d4a48a0e..c86048b3b 100644
--- a/packages/db/src/queries/rows.ts
+++ b/packages/db/src/queries/rows.ts
@@ -44,6 +44,7 @@ export interface CompanyTotalsRow {
eu_eur: number;
first_date: string | null;
last_date: string | null;
+ legal_form: string | null;
}
export function toCompanyListItem(r: CompanyTotalsRow): CompanyListItem {
diff --git a/packages/shared/src/format.test.ts b/packages/shared/src/format.test.ts
index cbbd63669..b63706b1a 100644
--- a/packages/shared/src/format.test.ts
+++ b/packages/shared/src/format.test.ts
@@ -4,8 +4,10 @@ import {
count,
date,
entityName,
+ isNaturalPersonBidder,
isNaturalPersonProfileName,
longDate,
+ MASKED_NATURAL_PERSON_LABEL,
money,
moneyBare,
monthYear,
@@ -185,3 +187,117 @@ describe('isNaturalPersonProfileName', () => {
expect(isNaturalPersonProfileName('СОФАРМА ТРЕЙДИНГ АД')).toBe(false);
});
});
+
+describe('isNaturalPersonBidder', () => {
+ it('flags a sole-trader legal form even when the name has no ЕТ prefix (legal_form rule wins)', () => {
+ expect(isNaturalPersonBidder('Some Company OOOD', 'ЕТ')).toBe(true);
+ });
+
+ it('flags the Latin-script ET legal form', () => {
+ expect(isNaturalPersonBidder('ET DRIFT', 'ET')).toBe(true);
+ });
+
+ it('flags expanded sole-trader legal forms', () => {
+ expect(isNaturalPersonBidder('Some Trader', 'ЕДНОЛИЧЕН ТЪРГОВЕЦ')).toBe(true);
+ expect(isNaturalPersonBidder('Some Trader', 'SOLE TRADER')).toBe(true);
+ expect(isNaturalPersonBidder('Some Trader', 'INDIVIDUAL')).toBe(true);
+ });
+
+ it('does not flag ordinary company legal forms', () => {
+ expect(isNaturalPersonBidder('СОФАРМА ТРЕЙДИНГ', 'ООД')).toBe(false);
+ expect(isNaturalPersonBidder('СОФАРМА ТРЕЙДИНГ', 'ЕАД')).toBe(false);
+ });
+
+ it('returns false for a legal-entity bidder (АД)', () => {
+ expect(isNaturalPersonBidder('СОФАРМА ТРЕЙДИНГ', 'АД')).toBe(false);
+ });
+
+ it('falls back to the leading-ЕТ name heuristic when legal_form is null', () => {
+ expect(isNaturalPersonBidder('ЕТ Пример', null)).toBe(true);
+ });
+
+ it('falls back to the leading-ET (Latin) name heuristic when legal_form does not match', () => {
+ expect(isNaturalPersonBidder('ET Example', 'unknown')).toBe(true);
+ });
+
+ it('returns false for a consortium whose legal_form is ДЗЗД and name has no ЕТ prefix', () => {
+ expect(isNaturalPersonBidder('Обединение', 'ДЗЗД')).toBe(false);
+ });
+
+ it('returns false for a plain company with a non-matching legal form and no ЕТ prefix', () => {
+ expect(isNaturalPersonBidder('СОФАРМА ТРЕЙДИНГ', 'АД')).toBe(false);
+ });
+});
+
+describe('MASKED_NATURAL_PERSON_LABEL', () => {
+ it('is a non-empty string', () => {
+ expect(typeof MASKED_NATURAL_PERSON_LABEL).toBe('string');
+ expect(MASKED_NATURAL_PERSON_LABEL.length).toBeGreaterThan(0);
+ });
+
+ it('is not the verbatim sole-trader name it replaces', () => {
+ expect(MASKED_NATURAL_PERSON_LABEL).not.toBe('ЕТ ДРИФТ - НИКОЛАЙ КИРОВ');
+ });
+
+ it('is safe to render as JSON / HTML and as a CSV cell (no comma, no quote)', () => {
+ expect(MASKED_NATURAL_PERSON_LABEL).not.toMatch(/[,"]/);
+ });
+});
+
+/**
+ * End-to-end smoke for the shared predicate surface.
+ *
+ * `isNaturalPersonBidder` and `isNaturalPersonProfileName` are the SINGLE source of truth for the
+ * noindex / masking decision that every downstream flow depends on — F2 (CSV masking in
+ * `streamContractsCsv` / `streamCompaniesCsv`), F3 (JSON masking in `/contracts/:id.json`), and F4
+ * (the privacy doc). If the two helpers drift apart, a sole-trader / natural-person identifier
+ * would leak either through search indexing or through a machine-readable body — exactly the
+ * regression this block guards against.
+ *
+ * The masking label referenced below is the same `MASKED_NATURAL_PERSON_LABEL` constant that F2
+ * and F3 substitute for `contractor_eik` / `eik` / `contractor` / `bidder.name` /
+ * `sourceNames.bidder`. Importing it here pins the test contract to the runtime masker so a
+ * future rename in `format.ts` cannot silently desynchronize them.
+ */
+describe('shared predicate surface — single source of truth for the noindex / masking decision', () => {
+ it('agrees between isNaturalPersonBidder (legal_form rule) and isNaturalPersonProfileName (name heuristic) for a sole trader', () => {
+ // legal_form rule: ЕТ alone is enough to flag the bidder as a natural person
+ expect(isNaturalPersonBidder('Sole', 'ЕТ')).toBe(true);
+ // name heuristic: the leading-ЕТ marker on the display name reaches the same verdict
+ expect(isNaturalPersonProfileName('ЕТ Sole')).toBe(true);
+ // Both helpers must classify their respective inputs as a natural person — no drift.
+ });
+
+ it('agrees between the two helpers for a legal entity (both return false)', () => {
+ expect(isNaturalPersonBidder('Acme', 'ООД')).toBe(false);
+ expect(isNaturalPersonProfileName('Acme')).toBe(false);
+ });
+
+ it('returns the documented natural-person verdict for a six-pair truth table', () => {
+ const truthTable: ReadonlyArray<{
+ name: string;
+ legalForm: string | null;
+ naturalPerson: boolean;
+ }> = [
+ { name: 'Sole', legalForm: 'ЕТ', naturalPerson: true }, // legal_form rule wins
+ { name: 'ЕТ Leading Name', legalForm: null, naturalPerson: true }, // name heuristic fallback
+ { name: 'Some Trader', legalForm: 'ЕДНОЛИЧЕН ТЪРГОВЕЦ', naturalPerson: true }, // expanded sole-trader form
+ { name: 'Acme', legalForm: 'ООД', naturalPerson: false }, // ordinary ООД
+ { name: 'СОФАРМА ТРЕЙДИНГ', legalForm: 'АД', naturalPerson: false }, // ordinary АД
+ { name: 'Обединение', legalForm: 'ДЗЗД', naturalPerson: false }, // consortium — not a sole trader
+ ];
+
+ for (const { name, legalForm, naturalPerson } of truthTable) {
+ expect(
+ isNaturalPersonBidder(name, legalForm),
+ `isNaturalPersonBidder(${JSON.stringify(name)}, ${JSON.stringify(legalForm)})`,
+ ).toBe(naturalPerson);
+ }
+
+ // The masking label used by F2 / F3 must remain the same constant exported from this package.
+ // If a downstream caller ever drifts onto a different label, this assertion surfaces it here.
+ expect(typeof MASKED_NATURAL_PERSON_LABEL).toBe('string');
+ expect(MASKED_NATURAL_PERSON_LABEL.length).toBeGreaterThan(0);
+ expect(MASKED_NATURAL_PERSON_LABEL).not.toBe('Sole');
+ });
+});
diff --git a/packages/shared/src/format.ts b/packages/shared/src/format.ts
index 895dd971f..dc7845f3b 100644
--- a/packages/shared/src/format.ts
+++ b/packages/shared/src/format.ts
@@ -198,6 +198,36 @@ export function isNaturalPersonProfileName(name: string): boolean {
return normalized.startsWith('ЕТ ') || normalized.startsWith('ET ');
}
+/**
+ * Bulgarian label substituted for a natural-person or sole-trader identifier in machine-readable
+ * exports (CSV cells, JSON `name` / `sourceNames` fields). Safe to render as HTML or JSON, and
+ * contains no comma/quote so the CSV encoder leaves it unquoted. See `isNaturalPersonBidder`.
+ */
+export const MASKED_NATURAL_PERSON_LABEL = 'Частно лице';
+
+/**
+ * Canonical natural-person / sole-trader predicate for machine-readable masking. Combines the
+ * `legal_form` rules (carried inline in `apps/web/app/routes/company.tsx` until the route migrates)
+ * with the leading-`ЕТ` name heuristic — so callers only need one check to decide whether to mask
+ * ЕИК and the raw source name. Caller is responsible for filtering consortium rows; the function
+ * itself only inspects `legalForm` and `name`.
+ */
+export function isNaturalPersonBidder(name: string, legalForm: string | null): boolean {
+ if (legalForm) {
+ const normalized = legalForm.trim().toUpperCase();
+ if (
+ normalized === 'ЕТ' ||
+ normalized === 'ET' ||
+ normalized.includes('ЕДНОЛИЧЕН ТЪРГОВЕЦ') ||
+ normalized.includes('SOLE TRADER') ||
+ normalized.includes('INDIVIDUAL')
+ ) {
+ return true;
+ }
+ }
+ return isNaturalPersonProfileName(name);
+}
+
/**
* Display name for a winning entity. A consortium row holds a `;`-joined member list → show the
* first member + „и др." (the **Обединение** badge is rendered separately by the caller). Companies
From 8e5f515941a19974a28d806ab0f03f0fbfed97bd Mon Sep 17 00:00:00 2001
From: opencode
Date: Wed, 1 Jul 2026 16:36:02 +0300
Subject: [PATCH 02/27] fix(privacy): centralize X-Robots-Tag in hardenResponse
via X-Privacy-Mask marker
The literal X-Robots-Tag header is no longer set anywhere in apps/web;
it is now written by exactly one helper (applyPrivacyMaskHeaders in
apps/web/app/lib/security.ts), called by the worker hardenResponse
after the base security headers and before the cacheable-HTML branch.
A new internal marker X-Privacy-Mask: applied is the route-side signal
that the response carries masked natural-person data. Route handlers
(csv-export.ts markCsvCache + 304 branch, contract.json.tsx loader) and
the worker consume that marker; it is deleted unconditionally before
the response is returned or stored in edgeCache.put so it never reaches
clients.
The HIT path in handleRequest (apps/web/workers/app.ts) is unchanged:
it copies cached.headers verbatim, and the cached entry is the
post-hardenResponse response, so the header survives the edge cache by
construction.
apps/web/workers/app.nofollow.test.ts (T-005 + T-008) exercises the
end-to-end worker flow for the .data twin of /companies/:eik and the
contract.json natural-person branch.
No edits to packages/db or to the public API contract;
bidders.legal_form stays server-only and company.eik stays on the
CompanyRecord type (masked to null by the loader, not removed).
---
apps/web/app/lib/csv-export.test.ts | 20 +-
apps/web/app/lib/csv-export.ts | 19 +-
apps/web/app/lib/security.test.ts | 59 +++-
apps/web/app/lib/security.ts | 28 ++
apps/web/app/routes/contract.json.test.ts | 12 +-
apps/web/app/routes/contract.json.tsx | 3 +-
apps/web/workers/app.nofollow.test.ts | 324 ++++++++++++++++++++++
apps/web/workers/app.ts | 7 +-
8 files changed, 447 insertions(+), 25 deletions(-)
create mode 100644 apps/web/workers/app.nofollow.test.ts
diff --git a/apps/web/app/lib/csv-export.test.ts b/apps/web/app/lib/csv-export.test.ts
index 737ca1531..e575bfb61 100644
--- a/apps/web/app/lib/csv-export.test.ts
+++ b/apps/web/app/lib/csv-export.test.ts
@@ -511,7 +511,7 @@ describe('servedCsvExport', () => {
});
describe('servedCsvExport privacy', () => {
- it('sets X-Robots-Tag: noindex on a MISS response (contracts)', async () => {
+ it('stamps the privacy mask marker on a MISS response (contracts) and never writes X-Robots-Tag at the route layer', async () => {
const r2 = new InMemoryR2();
const stream = vi.fn(() => csvResponse());
@@ -519,10 +519,11 @@ describe('servedCsvExport privacy', () => {
expect(response.status).toBe(200);
expect(response.headers.get('X-Csv-Cache')).toBe('MISS');
- expect(response.headers.get('X-Robots-Tag')).toBe('noindex');
+ expect(response.headers.get('X-Privacy-Mask')).toBe('applied');
+ expect(response.headers.get('X-Robots-Tag')).toBeNull();
});
- it('sets X-Robots-Tag: noindex on a HIT response (companies)', async () => {
+ it('stamps the privacy mask marker on a HIT response (companies) and never writes X-Robots-Tag at the route layer', async () => {
const r2 = new InMemoryR2();
const primeStream = vi.fn(() => csvResponse());
await (await serve(r2, primeStream, { route: 'companies' })).text();
@@ -532,12 +533,13 @@ describe('servedCsvExport privacy', () => {
expect(response.status).toBe(200);
expect(response.headers.get('X-Csv-Cache')).toBe('HIT');
- expect(response.headers.get('X-Robots-Tag')).toBe('noindex');
+ expect(response.headers.get('X-Privacy-Mask')).toBe('applied');
+ expect(response.headers.get('X-Robots-Tag')).toBeNull();
expect(await response.text()).toBe(CSV_BODY);
expect(hitStream).not.toHaveBeenCalled();
});
- it('sets X-Robots-Tag: noindex on a dynamic (filtered) response (authorities)', async () => {
+ it('stamps the privacy mask marker on a dynamic (filtered) response (authorities) and never writes X-Robots-Tag at the route layer', async () => {
const r2 = new InMemoryR2();
const stream = vi.fn(() => csvResponse('filtered\n'));
@@ -548,7 +550,8 @@ describe('servedCsvExport privacy', () => {
expect(response.status).toBe(200);
expect(response.headers.get('X-Csv-Cache')).toBe('dynamic');
- expect(response.headers.get('X-Robots-Tag')).toBe('noindex');
+ expect(response.headers.get('X-Privacy-Mask')).toBe('applied');
+ expect(response.headers.get('X-Robots-Tag')).toBeNull();
expect(await response.text()).toBe('filtered\n');
expect(r2.createMultipartUpload).not.toHaveBeenCalled();
});
@@ -567,7 +570,7 @@ describe('servedCsvExport privacy', () => {
expect(text).toBe(maskedBody);
});
- it('preserves Cache-Control: public, max-age=3600 when the streamer emits a row with masked sole-trader identifiers (contracts)', async () => {
+ it('preserves Cache-Control: public, max-age=3600 when the streamer emits a row with masked sole-trader identifiers (contracts) and stamps the privacy mask marker', async () => {
const maskedBody = `eik,name\n,${MASKED_NATURAL_PERSON_LABEL}\n`;
const r2 = new InMemoryR2();
const stream = vi.fn(() => csvResponse(maskedBody));
@@ -575,6 +578,7 @@ describe('servedCsvExport privacy', () => {
const response = await serve(r2, stream, { route: 'contracts' });
expect(response.headers.get('Cache-Control')).toBe('public, max-age=3600');
- expect(response.headers.get('X-Robots-Tag')).toBe('noindex');
+ expect(response.headers.get('X-Privacy-Mask')).toBe('applied');
+ expect(response.headers.get('X-Robots-Tag')).toBeNull();
});
});
diff --git a/apps/web/app/lib/csv-export.ts b/apps/web/app/lib/csv-export.ts
index d582ef5ff..3c9ee179e 100644
--- a/apps/web/app/lib/csv-export.ts
+++ b/apps/web/app/lib/csv-export.ts
@@ -1,9 +1,9 @@
import { withDataSource } from './dataSource';
import { getDb } from '@sigma/db';
+import { markPrivacyMaskApplied } from './security';
const CSV_CONTENT_TYPE = 'text/csv; charset=utf-8';
const CSV_CACHE_CONTROL = 'public, max-age=3600';
-const CSV_NOINDEX = 'noindex';
const CSV_MULTIPART_PART_SIZE = 8 * 1024 * 1024;
const ARRAY_FILTERS = ['years', 'sectors', 'procedureGroups', 'kinds', 'types'] as const;
@@ -57,7 +57,7 @@ function hasSearchFilter(params: object): boolean {
function markCsvCache(response: Response, cache: CsvCacheState): Response {
const withSource = withDataSource(response);
withSource.headers.set('X-Csv-Cache', cache);
- withSource.headers.set('X-Robots-Tag', CSV_NOINDEX);
+ markPrivacyMaskApplied(withSource.headers);
return withSource;
}
@@ -96,13 +96,12 @@ function responseFromR2Object(
cache: CsvCacheState,
) {
if (!hasBody(obj)) {
- return markCsvCache(
- new Response(null, {
- status: 304,
- headers: { ETag: obj.httpEtag, 'X-Robots-Tag': CSV_NOINDEX },
- }),
- cache,
- );
+ const response304 = new Response(null, {
+ status: 304,
+ headers: { ETag: obj.httpEtag },
+ });
+ markPrivacyMaskApplied(response304.headers);
+ return markCsvCache(response304, cache);
}
const range = rangeInfo(obj);
@@ -113,9 +112,9 @@ function responseFromR2Object(
'Accept-Ranges': 'bytes',
'Cache-Control': CSV_CACHE_CONTROL,
'Content-Length': String(range?.length ?? obj.size),
- 'X-Robots-Tag': CSV_NOINDEX,
});
if (range) headers.set('Content-Range', `bytes ${range.start}-${range.end}/${obj.size}`);
+ markPrivacyMaskApplied(headers);
return markCsvCache(new Response(obj.body, { status: range ? 206 : 200, headers }), cache);
}
diff --git a/apps/web/app/lib/security.test.ts b/apps/web/app/lib/security.test.ts
index 893bc8c38..8b948f598 100644
--- a/apps/web/app/lib/security.test.ts
+++ b/apps/web/app/lib/security.test.ts
@@ -1,5 +1,13 @@
import { describe, expect, it } from 'vitest';
-import { baseSecurityHeaders, nonceLessSecurityHeaders, securityHeaders } from './security';
+import {
+ PRIVACY_MASK_APPLIED,
+ PRIVACY_MASK_MARKER,
+ applyPrivacyMaskHeaders,
+ baseSecurityHeaders,
+ markPrivacyMaskApplied,
+ nonceLessSecurityHeaders,
+ securityHeaders,
+} from './security';
describe('securityHeaders CSP', () => {
it('emits a strict nonce script-src and the documented style-src', () => {
@@ -27,3 +35,52 @@ describe('securityHeaders CSP', () => {
expect(baseSecurityHeaders(true).get('Strict-Transport-Security')).toContain('max-age=');
});
});
+
+describe('privacy mask headers', () => {
+ it('sets the marker to PRIVACY_MASK_APPLIED on the provided Headers after markPrivacyMaskApplied', () => {
+ const headers = new Headers();
+ markPrivacyMaskApplied(headers);
+ expect(headers.get(PRIVACY_MASK_MARKER)).toBe(PRIVACY_MASK_APPLIED);
+ });
+
+ it('translates the marker to X-Robots-Tag: noindex and deletes the marker', () => {
+ const headers = new Headers();
+ markPrivacyMaskApplied(headers);
+ applyPrivacyMaskHeaders(headers);
+ expect(headers.get('X-Robots-Tag')).toBe('noindex');
+ expect(headers.has(PRIVACY_MASK_MARKER)).toBe(false);
+ });
+
+ it('adds no X-Robots-Tag and leaves no marker when the marker is absent', () => {
+ const headers = new Headers({ 'Cache-Control': 'public, max-age=3600' });
+ applyPrivacyMaskHeaders(headers);
+ expect(headers.has('X-Robots-Tag')).toBe(false);
+ expect(headers.has(PRIVACY_MASK_MARKER)).toBe(false);
+ // Untouched headers survive the call.
+ expect(headers.get('Cache-Control')).toBe('public, max-age=3600');
+ });
+
+ it('is idempotent on a second call — no re-set, no marker, X-Robots-Tag left intact', () => {
+ const headers = new Headers();
+ markPrivacyMaskApplied(headers);
+ applyPrivacyMaskHeaders(headers);
+ // Second call: marker is already gone, an existing X-Robots-Tag is left as-is.
+ applyPrivacyMaskHeaders(headers);
+ expect(headers.get('X-Robots-Tag')).toBe('noindex');
+ expect(headers.has(PRIVACY_MASK_MARKER)).toBe(false);
+ });
+
+ it('treats PRIVACY_MASK_APPLIED as the literal type — only that exact value triggers the translate', () => {
+ // Type-level guard: PRIVACY_MASK_APPLIED is typed `as const`, so this comparison compiles
+ // only because the constant is the exported literal. A re-typed string-literal in
+ // `markPrivacyMaskApplied` (e.g. 'applied' with whitespace) would no longer satisfy
+ // `=== PRIVACY_MASK_APPLIED` and the test would fail.
+ expect(PRIVACY_MASK_APPLIED).toBe('applied');
+
+ const headers = new Headers();
+ headers.set(PRIVACY_MASK_MARKER, 'something-else');
+ applyPrivacyMaskHeaders(headers);
+ expect(headers.has('X-Robots-Tag')).toBe(false);
+ expect(headers.has(PRIVACY_MASK_MARKER)).toBe(false);
+ });
+});
diff --git a/apps/web/app/lib/security.ts b/apps/web/app/lib/security.ts
index 4ace62ebf..c043bdeea 100644
--- a/apps/web/app/lib/security.ts
+++ b/apps/web/app/lib/security.ts
@@ -61,3 +61,31 @@ export function nonceLessSecurityHeaders(scriptHashes: string[], isProd: boolean
if (isProd) headers.set('Content-Security-Policy', csp(scriptHashes));
return headers;
}
+
+// Internal marker header: a route that decides its response contains natural-person data sets this
+// on the outgoing `Headers` so the worker `hardenResponse` can translate it into a public-facing
+// `X-Robots-Tag: noindex`. The marker is intentionally internal — `applyPrivacyMaskHeaders` deletes
+// it from the final response so it never reaches the edge cache or the client.
+export const PRIVACY_MASK_MARKER = 'X-Privacy-Mask';
+
+// `as const` narrows the type to the literal `'applied'` (not the wider `string`), which forces
+// callers that compare against it to use this exported constant rather than re-typing the string.
+export const PRIVACY_MASK_APPLIED = 'applied' as const;
+
+// Route-layer helper: stamps the privacy-mask marker onto a `Headers` object so the downstream
+// worker `hardenResponse` can pick it up and translate it into `X-Robots-Tag: noindex`. Callers
+// must invoke this only when the response body contains masked natural-person data.
+export function markPrivacyMaskApplied(headers: Headers): void {
+ headers.set(PRIVACY_MASK_MARKER, PRIVACY_MASK_APPLIED);
+}
+
+// Worker-layer helper: if the privacy-mask marker is set to `applied`, translate it into the
+// public-facing `X-Robots-Tag: noindex` header. The marker is then deleted unconditionally so it
+// never leaks into the edge cache or the response. Idempotent: a second call finds no marker and
+// leaves an existing `X-Robots-Tag` header untouched.
+export function applyPrivacyMaskHeaders(headers: Headers): void {
+ if (headers.get(PRIVACY_MASK_MARKER) === PRIVACY_MASK_APPLIED) {
+ headers.set('X-Robots-Tag', 'noindex');
+ }
+ headers.delete(PRIVACY_MASK_MARKER);
+}
diff --git a/apps/web/app/routes/contract.json.test.ts b/apps/web/app/routes/contract.json.test.ts
index a4ab7e3c8..43f17af4b 100644
--- a/apps/web/app/routes/contract.json.test.ts
+++ b/apps/web/app/routes/contract.json.test.ts
@@ -144,13 +144,14 @@ describe('contract.json loader', () => {
vi.mocked(getContract).mockReset();
});
- it('masks a sole trader and sets X-Robots-Tag: noindex (behavior 1)', async () => {
+ it('masks a sole trader and sets X-Privacy-Mask: applied (behavior 1)', async () => {
vi.mocked(getContract).mockResolvedValueOnce(makeRecord());
const response = await loader(loaderArgs('c-1'));
expect(response.status).toBe(200);
- expect(response.headers.get('X-Robots-Tag')).toBe('noindex');
+ expect(response.headers.get('X-Privacy-Mask')).toBe('applied');
+ expect(response.headers.get('X-Robots-Tag')).toBeNull();
const body = (await response.json()) as { bidder: { eik: string | null; name: string; displayName: string }; sourceNames: { bidder: string } };
expect(body.bidder.eik).toBeNull();
expect(body.bidder.name).toBe(MASKED_NATURAL_PERSON_LABEL);
@@ -160,7 +161,7 @@ describe('contract.json loader', () => {
expect(body.sourceNames.bidder).not.toBe('ЕТ ДРИФТ - НИКОЛАЙ КИРОВ');
});
- it('passes a legal entity through verbatim and omits X-Robots-Tag: noindex (behavior 2)', async () => {
+ it('passes a legal entity through verbatim and omits the privacy mask marker (behavior 2)', async () => {
const record = makeRecord({
bidder: {
slug: 'bidder-2',
@@ -185,7 +186,8 @@ describe('contract.json loader', () => {
const response = await loader(loaderArgs('c-2'));
expect(response.status).toBe(200);
- expect(response.headers.get('X-Robots-Tag')).not.toBe('noindex');
+ expect(response.headers.get('X-Privacy-Mask')).toBeNull();
+ expect(response.headers.get('X-Robots-Tag')).toBeNull();
const body = (await response.json()) as { bidder: { eik: string | null; name: string }; sourceNames: { bidder: string } };
expect(body.bidder.eik).toBe('123456789');
expect(body.bidder.name).toBe('СОФАРМА ТРЕЙДИНГ АД');
@@ -198,6 +200,8 @@ describe('contract.json loader', () => {
const response = await loader(loaderArgs('c-999'));
expect(response.status).toBe(404);
+ expect(response.headers.get('X-Privacy-Mask')).toBeNull();
+ expect(response.headers.get('X-Robots-Tag')).toBeNull();
const body = (await response.json()) as { error: string };
expect(body).toEqual({ error: 'not_found' });
});
diff --git a/apps/web/app/routes/contract.json.tsx b/apps/web/app/routes/contract.json.tsx
index 948dea105..4ddc2382c 100644
--- a/apps/web/app/routes/contract.json.tsx
+++ b/apps/web/app/routes/contract.json.tsx
@@ -4,6 +4,7 @@ import { contractIdFromSlug, getContract, getDb } from '@sigma/db';
import type { Route } from './+types/contract.json';
import { publicCache } from '../lib/cache';
import { withDataSource } from '../lib/dataSource';
+import { markPrivacyMaskApplied } from '../lib/security';
function safeJson(value: unknown): string {
return JSON.stringify(value)
@@ -49,6 +50,6 @@ export async function loader({ params, context }: Route.LoaderArgs) {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': publicCache(3600),
});
- if (masked !== record) headers.set('X-Robots-Tag', 'noindex');
+ if (masked !== record) markPrivacyMaskApplied(headers);
return withDataSource(new Response(safeJson(masked), { headers }));
}
diff --git a/apps/web/workers/app.nofollow.test.ts b/apps/web/workers/app.nofollow.test.ts
new file mode 100644
index 000000000..232d6aa7a
--- /dev/null
+++ b/apps/web/workers/app.nofollow.test.ts
@@ -0,0 +1,324 @@
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// End-to-end test of the worker `hardenResponse` pipeline: routes return Responses carrying the
+// internal `X-Privacy-Mask: applied` marker, and the worker must translate the marker into the
+// public-facing `X-Robots-Tag: noindex` header (and strip the marker before storage). This test
+// drives a real `worker.fetch(...)` against a stubbed React Router handler so the full MISS +
+// `edgeCache.put` + HIT path is exercised, matching the harness in `app.cache.test.ts`.
+
+// Dispatch by URL: each scenario is reached through a distinct URL so the same handler instance
+// can return different (content-type, marker) combinations without state.
+vi.mock('react-router', () => ({
+ createRequestHandler: () => async (request: Request) => {
+ const url = new URL(request.url);
+ const path = url.pathname;
+
+ if (path === '/csv') {
+ return new Response('header1,header2\nrow1,row2', {
+ status: 200,
+ headers: {
+ 'Content-Type': 'text/csv; charset=utf-8',
+ 'Cache-Control': 'public, s-maxage=1800',
+ 'X-Privacy-Mask': 'applied',
+ },
+ });
+ }
+
+ if (path === '/contract/abc.json') {
+ return new Response('{"id":"abc","bidder":{"name":"ЕТ MASKED"}}', {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'public, s-maxage=1800',
+ 'X-Privacy-Mask': 'applied',
+ },
+ });
+ }
+
+ if (path === '/companies/123.data') {
+ return new Response('turbostream-data', {
+ status: 200,
+ headers: {
+ 'Content-Type': 'text/x-script',
+ 'Cache-Control': 'public, s-maxage=1800',
+ 'X-Privacy-Mask': 'applied',
+ },
+ });
+ }
+
+ if (path === '/companies/natural.data') {
+ // F2 / T-008 fixture: a natural-person company `.data` twin. The handler returns the encoded
+ // loader payload with `company.eik = null` (the field cleared by `company.tsx:76-77` per T-006)
+ // and the internal `X-Privacy-Mask: applied` marker set on the Response headers. The worker
+ // must translate the marker into `X-Robots-Tag: noindex` and strip it before storage.
+ return new Response(
+ 'turbostream-data-{"company":{"eik":null,"displayName":"ЕТ MASKED"},"coverage":{"coverageEndYear":2025}}',
+ {
+ status: 200,
+ headers: {
+ 'Content-Type': 'text/x-script',
+ 'Cache-Control': 'public, s-maxage=1800',
+ 'X-Privacy-Mask': 'applied',
+ },
+ },
+ );
+ }
+
+ if (path === '/companies/legal-entity.data') {
+ // F2 / T-008 negative fixture: a legal-entity company `.data` twin. The loader's
+ // legal-entity branch returns a plain object with `company.eik` intact and NO marker — the
+ // worker must NOT synthesise `X-Robots-Tag` (the marker is required for the translation).
+ return new Response(
+ 'turbostream-data-{"company":{"eik":"121817309","displayName":"СОФАРМА ТРЕЙДИНГ АД"}}',
+ {
+ status: 200,
+ headers: {
+ 'Content-Type': 'text/x-script',
+ 'Cache-Control': 'public, s-maxage=1800',
+ },
+ },
+ );
+ }
+
+ if (path === '/clean') {
+ // Negative case: same cacheable content-type shape but NO marker — worker must NOT
+ // synthesise X-Robots-Tag.
+ return new Response('{"id":"abc"}', {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'public, s-maxage=1800',
+ },
+ });
+ }
+
+ return new Response('not found', { status: 404 });
+ },
+}));
+vi.mock('virtual:react-router/server-build', () => ({}));
+
+// Pass through the log wrapper; disable rate limiters (they need real DO bindings otherwise).
+vi.mock('./request-log', () => ({
+ withRequestLog: (
+ request: Request,
+ env: unknown,
+ ctx: unknown,
+ handler: (r: Request, e: unknown, c: unknown) => Promise,
+ ) => handler(request, env, ctx),
+}));
+vi.mock('./aggregation-rate-limit', () => ({ rateLimitAggregationRoute: async () => null }));
+vi.mock('./assistant-rate-limit', () => ({ rateLimitAssistantRoute: async () => null }));
+vi.mock('./csv-rate-limit', () => ({ rateLimitCsvExport: async () => null }));
+vi.mock('./search-rate-limit', () => ({ rateLimitSearchRoute: async () => null }));
+
+// Minimal stand-in for caches.default (Cloudflare Cache API), keyed by the cache-key URL. Same
+// shape as the harness in app.cache.test.ts:38-77.
+function makeFakeCache() {
+ const store = new Map<
+ string,
+ { body: string; status: number; statusText: string; headers: [string, string][] }
+ >();
+ return {
+ store,
+ async match(req: Request | string) {
+ const url = typeof req === 'string' ? req : req.url;
+ const e = store.get(url);
+ return e
+ ? new Response(e.body, {
+ status: e.status,
+ statusText: e.statusText,
+ headers: new Headers(e.headers),
+ })
+ : undefined;
+ },
+ async put(req: Request | string, res: Response) {
+ const url = typeof req === 'string' ? req : req.url;
+ store.set(url, {
+ body: await res.text(),
+ status: res.status,
+ statusText: res.statusText,
+ headers: [...res.headers] as [string, string][],
+ });
+ },
+ };
+}
+
+const fakeCache = makeFakeCache();
+let worker: { fetch: (r: Request, env: unknown, ctx: unknown) => Promise };
+
+beforeAll(async () => {
+ // edgeCache = caches.default is captured at module load, so stub before importing app.ts.
+ vi.stubGlobal('caches', { default: fakeCache });
+ worker = ((await import('./app')) as { default: typeof worker }).default;
+});
+
+beforeEach(() => fakeCache.store.clear());
+
+type FetchResult = {
+ response: Response;
+ body: string;
+ edge: string | null;
+};
+
+async function fetchAndSettle(url: string): Promise {
+ const waits: Promise[] = [];
+ const ctx = {
+ waitUntil: (p: Promise) => void waits.push(p),
+ passThroughOnException: () => {},
+ };
+ const response = await worker.fetch(new Request(url), {}, ctx);
+ const body = await response.clone().text();
+ await Promise.all(waits); // let edgeCache.put (ctx.waitUntil) settle before the next request
+ return { response, body, edge: response.headers.get('X-Edge-Cache') };
+}
+
+function getCachedHeaders(url: string): Headers | null {
+ // The cache key is the canonical cacheKey(request, DEPLOY_TAG) URL; for these tests it suffices
+ // to walk the (single-entry) store since each scenario seeds exactly one entry per URL prefix.
+ for (const [key, entry] of fakeCache.store) {
+ if (key === url || key.includes(url.replace('https://x', ''))) {
+ return new Headers(entry.headers);
+ }
+ }
+ return null;
+}
+
+describe('app.ts hardenResponse — X-Privacy-Mask marker → X-Robots-Tag: noindex', () => {
+ it('translates the marker on a text/csv (CSV surface) response and removes the marker', async () => {
+ const url = 'https://x/csv';
+ const { response, edge } = await fetchAndSettle(url);
+
+ expect(edge).toBe('MISS');
+ expect(response.headers.get('X-Robots-Tag')).toBe('noindex');
+ expect(response.headers.has('X-Privacy-Mask')).toBe(false);
+
+ // Cache entry carries the public-facing header (no marker) — proves the next HIT will serve
+ // the user-facing header verbatim without re-running the translation.
+ const cached = getCachedHeaders(url);
+ expect(cached).not.toBeNull();
+ expect(cached!.get('X-Robots-Tag')).toBe('noindex');
+ expect(cached!.has('X-Privacy-Mask')).toBe(false);
+ });
+
+ it('translates the marker on an application/json (JSON resource route) response and removes the marker', async () => {
+ const url = 'https://x/contract/abc.json';
+ const { response, edge } = await fetchAndSettle(url);
+
+ expect(edge).toBe('MISS');
+ expect(response.headers.get('X-Robots-Tag')).toBe('noindex');
+ expect(response.headers.has('X-Privacy-Mask')).toBe(false);
+
+ const cached = getCachedHeaders(url);
+ expect(cached).not.toBeNull();
+ expect(cached!.get('X-Robots-Tag')).toBe('noindex');
+ expect(cached!.has('X-Privacy-Mask')).toBe(false);
+ });
+
+ it('translates the marker on a text/x-script (`.data` RRv7 single-fetch twin) response and removes the marker', async () => {
+ const url = 'https://x/companies/123.data';
+ const { response, edge } = await fetchAndSettle(url);
+
+ expect(edge).toBe('MISS');
+ expect(response.headers.get('X-Robots-Tag')).toBe('noindex');
+ expect(response.headers.has('X-Privacy-Mask')).toBe(false);
+
+ const cached = getCachedHeaders(url);
+ expect(cached).not.toBeNull();
+ expect(cached!.get('X-Robots-Tag')).toBe('noindex');
+ expect(cached!.has('X-Privacy-Mask')).toBe(false);
+ });
+
+ it('serves the cached copy verbatim on a HIT — X-Robots-Tag: noindex survives the cache layer', async () => {
+ const url = 'https://x/csv';
+ const first = await fetchAndSettle(url);
+ expect(first.edge).toBe('MISS');
+ expect(first.response.headers.get('X-Robots-Tag')).toBe('noindex');
+
+ const second = await fetchAndSettle(url);
+ expect(second.edge).toBe('HIT');
+ // HIT path copies cached headers verbatim; the cached entry was put post-translation.
+ expect(second.response.headers.get('X-Robots-Tag')).toBe('noindex');
+ expect(second.response.headers.has('X-Privacy-Mask')).toBe(false);
+ });
+
+ it('does NOT add X-Robots-Tag when the loader response carries no marker (negative case)', async () => {
+ const url = 'https://x/clean';
+ const { response, edge } = await fetchAndSettle(url);
+
+ expect(edge).toBe('MISS');
+ expect(response.headers.get('X-Robots-Tag')).toBeNull();
+ expect(response.headers.has('X-Privacy-Mask')).toBe(false);
+
+ const cached = getCachedHeaders(url);
+ expect(cached).not.toBeNull();
+ expect(cached!.get('X-Robots-Tag')).toBeNull();
+ expect(cached!.has('X-Privacy-Mask')).toBe(false);
+ });
+});
+
+// F2 / T-008 — end-to-end natural-person `.data` flow. The handler fixture encodes the loader's
+// natural-person payload (`company.eik === null`) with the `X-Privacy-Mask: applied` marker, and
+// the worker must (a) translate the marker into `X-Robots-Tag: noindex` on the final response,
+// (b) preserve the encoded body byte-for-byte, (c) strip the marker before edge-cache storage so
+// the HIT path serves the user-facing header verbatim without re-translation, and (d) NOT add the
+// header when a legal-entity `.data` request reaches the worker without a marker.
+describe('app.ts hardenResponse — natural-person `.data` flow (F2 / T-008)', () => {
+ it('drives /companies/.data to X-Robots-Tag: noindex, no marker, body preserved', async () => {
+ const url = 'https://x/companies/natural.data';
+ const { response, edge, body } = await fetchAndSettle(url);
+
+ expect(edge).toBe('MISS');
+ expect(response.headers.get('Content-Type')).toBe('text/x-script');
+ expect(response.headers.get('X-Robots-Tag')).toBe('noindex');
+ expect(response.headers.has('X-Privacy-Mask')).toBe(false);
+ // The masked payload must survive the pipeline — the worker must not mutate the encoded body.
+ expect(body).toContain('"eik":null');
+ expect(body).toContain('ЕТ MASKED');
+ });
+
+ it('caches the post-hardening Response — entry carries X-Robots-Tag: noindex, no marker', async () => {
+ const url = 'https://x/companies/natural.data';
+ await fetchAndSettle(url);
+
+ // The cached copy is what `edgeCache.put(key, hardened.clone())` stored; the worker wrote it
+ // AFTER `applyPrivacyMaskHeaders` ran, so the user-facing header is on disk and the marker is
+ // gone. This is the cache-safety invariant — the HIT path will serve these headers verbatim.
+ const cached = getCachedHeaders(url);
+ expect(cached).not.toBeNull();
+ expect(cached!.get('X-Robots-Tag')).toBe('noindex');
+ expect(cached!.get('Content-Type')).toBe('text/x-script');
+ expect(cached!.has('X-Privacy-Mask')).toBe(false);
+ });
+
+ it('a second request for the same natural-person `.data` URL HITs and serves X-Robots-Tag: noindex', async () => {
+ const url = 'https://x/companies/natural.data';
+ const first = await fetchAndSettle(url);
+ expect(first.edge).toBe('MISS');
+ expect(first.response.headers.get('X-Robots-Tag')).toBe('noindex');
+
+ const second = await fetchAndSettle(url);
+ expect(second.edge).toBe('HIT');
+ // HIT path copies cached.headers verbatim; the cached entry was put post-translation, so the
+ // user-facing header survives without re-running the marker translation.
+ expect(second.response.headers.get('X-Robots-Tag')).toBe('noindex');
+ expect(second.response.headers.has('X-Privacy-Mask')).toBe(false);
+ expect(second.body).toContain('"eik":null');
+ });
+
+ it('does NOT emit X-Robots-Tag for a legal-entity `.data` request without a marker', async () => {
+ const url = 'https://x/companies/legal-entity.data';
+ const { response, edge, body } = await fetchAndSettle(url);
+
+ expect(edge).toBe('MISS');
+ expect(response.headers.get('Content-Type')).toBe('text/x-script');
+ expect(response.headers.get('X-Robots-Tag')).toBeNull();
+ expect(response.headers.has('X-Privacy-Mask')).toBe(false);
+ // Legal-entity payload keeps the EIK intact — the worker must not invent a noindex policy.
+ expect(body).toContain('"eik":"121817309"');
+
+ const cached = getCachedHeaders(url);
+ expect(cached).not.toBeNull();
+ expect(cached!.get('X-Robots-Tag')).toBeNull();
+ expect(cached!.has('X-Privacy-Mask')).toBe(false);
+ });
+});
diff --git a/apps/web/workers/app.ts b/apps/web/workers/app.ts
index 7fea534a9..3597db079 100644
--- a/apps/web/workers/app.ts
+++ b/apps/web/workers/app.ts
@@ -1,5 +1,9 @@
import { createRequestHandler } from 'react-router';
-import { baseSecurityHeaders, nonceLessSecurityHeaders } from '../app/lib/security';
+import {
+ applyPrivacyMaskHeaders,
+ baseSecurityHeaders,
+ nonceLessSecurityHeaders,
+} from '../app/lib/security';
import { rateLimitAggregationRoute } from './aggregation-rate-limit';
import { rateLimitAssistantRoute } from './assistant-rate-limit';
import { cacheKey } from './cache-key';
@@ -59,6 +63,7 @@ async function hardenResponse(response: Response, cacheable: boolean): Promise
Date: Wed, 1 Jul 2026 16:43:46 +0300
Subject: [PATCH 03/27] fix(privacy): mask eik on /companies/:eik.data and
forward marker through headers()
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The single-fetch .data twin of the company profile now clears company.eik to null
on the natural-person branch (per isNaturalPersonBidder) and signals the
worker via the internal X-Privacy-Mask: applied header on the Response.json
return. The route's headers() export now destructures { loaderHeaders } from
Route.HeadersArgs and forwards the marker explicitly so the worker
hardenResponse can translate it into X-Robots-Tag: noindex on the HTML
response (getDocumentHeadersImpl only auto-propagates Set-Cookie).
Legal-entity records keep the plain-object return unchanged — no marker, no
mutation, no Response.json wrap. The not-found short-circuit (throw new
Response('Not Found', ...)) runs before the masking gate, so 404s never
carry the marker.
The HTML meta() noindex branch is unchanged — natural-person pages
continue to emit via the existing
seoMeta + isNaturalPersonBidder gate. The new headers() forward adds a
redundant X-Robots-Tag: noindex HTTP header alongside the meta tag, which
is acceptable (the worker translates the marker for all responses).
apps/web/app/routes/company.data.test.ts is the focused new test suite
(7 tests across 5 describe blocks): natural-person loader return asserts
company.eik === null and X-Privacy-Mask: applied; legal-entity loader
return asserts a plain object with eik unchanged and no marker; headers()
test exercises both branches (marker present → forwarded + Cache-Control;
marker absent → Cache-Control only); meta() test covers the natural-person
noindex HTML tag; the worker-pipeline describe calls applyPrivacyMaskHeaders
on the loader return and asserts X-Robots-Tag: noindex is set while
X-Privacy-Mask is stripped, proving the worker translate end-to-end.
---
apps/web/app/routes/company.data.test.ts | 264 +++++++++++++++++++++++
apps/web/app/routes/company.tsx | 21 +-
2 files changed, 284 insertions(+), 1 deletion(-)
create mode 100644 apps/web/app/routes/company.data.test.ts
diff --git a/apps/web/app/routes/company.data.test.ts b/apps/web/app/routes/company.data.test.ts
new file mode 100644
index 000000000..6f7bfd582
--- /dev/null
+++ b/apps/web/app/routes/company.data.test.ts
@@ -0,0 +1,264 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { CompanyDetail, NetworkData, TrendData } from '@sigma/api-contract';
+import { bidderIdFromSlug, getCompany, getEntityNetwork, getSpendingTrend } from '@sigma/db';
+import { applyPrivacyMaskHeaders } from '../lib/security';
+import type { CoverageMeta } from '../lib/coverage';
+import { getCoverageMeta } from '../lib/coverage';
+import { headers, loader, meta } from './company';
+
+vi.mock('@sigma/db', () => ({
+ bidderIdFromSlug: vi.fn((slug: string) => (/^\d{9}(\d{4})?$/.test(slug) ? 'eik:' + slug : null)),
+ getCompany: vi.fn(),
+ getSpendingTrend: vi.fn(),
+ getEntityNetwork: vi.fn(),
+}));
+
+vi.mock('../lib/coverage', async () => {
+ const actual = await vi.importActual('../lib/coverage');
+ return {
+ ...actual,
+ getCoverageMeta: vi.fn(),
+ coverageRange: actual.coverageRange,
+ };
+});
+
+function makeCoverageMeta(): CoverageMeta {
+ return { asOf: '2025-06-30', refreshedAt: '2025-07-01T00:00:00Z', coverageEndYear: 2025 };
+}
+
+function makeTrend(): TrendData {
+ return {
+ granularity: 'month',
+ points: [],
+ years: [],
+ sectors: [],
+ totalValueEur: 0,
+ coverage: { dated: 0, total: 0, pct: 0 },
+ scope: { sector: null, funding: 'all', granularity: 'month' },
+ };
+}
+
+function makeNetwork(): NetworkData {
+ return {
+ center: null,
+ nodes: [],
+ edges: [],
+ centerOptions: { authorities: [], companies: [] },
+ };
+}
+
+function makeCompany(overrides: Partial = {}): CompanyDetail {
+ return {
+ slug: 'company-slug',
+ name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ',
+ displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ',
+ kind: 'company',
+ isConsortium: false,
+ eik: '123456789',
+ eikValid: true,
+ hasEik: true,
+ ownershipKind: null,
+ settlement: 'Plovdiv',
+ region: null,
+ legalForm: 'ЕТ',
+ wonEur: 1000,
+ contracts: 1,
+ authorities: 1,
+ sector: null,
+ sectorSharePct: null,
+ euSharePct: 0,
+ avgBids: 1,
+ periodFirst: '2024-01-01',
+ periodLast: '2024-01-01',
+ suspect: 0,
+ topAuthorities: [
+ {
+ slug: 'authority-slug',
+ name: 'Some Authority',
+ paidEur: 1000,
+ contracts: 1,
+ sharePct: 1,
+ },
+ ],
+ moreAuthorities: 0,
+ procedureMix: [],
+ bids: { one: 1, two: 0, three: 0, fourPlus: 0, unknown: 0 },
+ topContracts: [],
+ recentContracts: [],
+ participants: [],
+ membershipNote: null,
+ ...overrides,
+ };
+}
+
+function loaderArgs(eik: string | undefined): Parameters[0] {
+ return {
+ params: { eik: eik ?? '' },
+ context: { cloudflare: { env: { DB: {} as never } } },
+ } as unknown as Parameters[0];
+}
+
+function installStubs(company: CompanyDetail | null): void {
+ vi.mocked(getCompany).mockResolvedValueOnce(company);
+ vi.mocked(getCoverageMeta).mockResolvedValueOnce(makeCoverageMeta());
+ vi.mocked(getSpendingTrend).mockResolvedValueOnce(makeTrend());
+ vi.mocked(getEntityNetwork).mockResolvedValueOnce(makeNetwork());
+}
+
+beforeEach(() => {
+ vi.mocked(getCompany).mockReset();
+ vi.mocked(getCoverageMeta).mockReset();
+ vi.mocked(getSpendingTrend).mockReset();
+ vi.mocked(getEntityNetwork).mockReset();
+});
+
+describe('company.data loader — natural-person branch', () => {
+ it('returns a Response carrying X-Privacy-Mask: applied with company.eik === null (behaviors 1 + 2)', async () => {
+ const natural = makeCompany({ legalForm: 'ЕТ', displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ' });
+ installStubs(natural);
+
+ const result = await loader(loaderArgs('123456789'));
+
+ expect(result).toBeInstanceOf(Response);
+ const response = result as Response;
+ expect(response.status).toBe(200);
+ expect(response.headers.get('X-Privacy-Mask')).toBe('applied');
+ expect(response.headers.get('X-Robots-Tag')).toBeNull();
+ const body = (await response.json()) as {
+ company: { eik: string | null; displayName: string };
+ };
+ expect(body.company.eik).toBeNull();
+ expect(body.company.displayName).toBe('ЕТ ДРИФТ - НИКОЛАЙ КИРОВ');
+ });
+});
+
+describe('company.data loader — legal-entity branch', () => {
+ it('returns a plain object (not a Response) with company.eik unchanged and no privacy marker (behavior 3)', async () => {
+ const legal = makeCompany({
+ legalForm: 'АД',
+ displayName: 'СОФАРМА ТРЕЙДИНГ АД',
+ eik: '121817309',
+ });
+ installStubs(legal);
+
+ const result = await loader(loaderArgs('121817309'));
+
+ expect(result).not.toBeInstanceOf(Response);
+ const plain = result as {
+ company: CompanyDetail;
+ coverage: CoverageMeta;
+ trend: TrendData;
+ network: NetworkData;
+ };
+ expect(plain.company.eik).toBe('121817309');
+ expect(plain.company.displayName).toBe('СОФАРМА ТРЕЙДИНГ АД');
+ expect(plain.coverage.coverageEndYear).toBe(2025);
+ });
+});
+
+describe('company.data headers() — forwards the privacy mask marker', () => {
+ it('returns X-Privacy-Mask + Cache-Control when the loader set the marker (behavior 4)', () => {
+ const loaderHeaders = new Headers({ 'X-Privacy-Mask': 'applied' });
+
+ const result = headers({
+ loaderHeaders,
+ parentHeaders: new Headers(),
+ actionHeaders: new Headers(),
+ errorHeaders: undefined,
+ } as unknown as Parameters[0]);
+
+ expect(result['Cache-Control']).toBe('public, s-maxage=3600, stale-while-revalidate=86400');
+ expect(result['X-Privacy-Mask']).toBe('applied');
+ });
+
+ it('returns only Cache-Control when loaderHeaders carry no marker (behavior 5)', () => {
+ const loaderHeaders = new Headers();
+
+ const result = headers({
+ loaderHeaders,
+ parentHeaders: new Headers(),
+ actionHeaders: new Headers(),
+ errorHeaders: undefined,
+ } as unknown as Parameters[0]);
+
+ expect(result['Cache-Control']).toBe('public, s-maxage=3600, stale-while-revalidate=86400');
+ expect('X-Privacy-Mask' in result).toBe(false);
+ });
+});
+
+describe('company.data meta() — natural-person noindex branch', () => {
+ it('emits { name: robots, content: noindex } for a natural-person data payload (behavior 6)', () => {
+ const natural = makeCompany({ legalForm: 'ЕТ', displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ' });
+ const data = {
+ company: natural,
+ coverage: makeCoverageMeta(),
+ trend: makeTrend(),
+ network: makeNetwork(),
+ };
+
+ const tags = meta({
+ data,
+ params: { eik: '123456789' },
+ matches: [],
+ location: {
+ pathname: '/companies/123456789',
+ search: '',
+ hash: '',
+ state: null,
+ key: 'default',
+ },
+ } as unknown as Parameters[0]) as Array<{
+ name?: string;
+ content?: string;
+ title?: string;
+ }>;
+
+ const robots = tags.find((t) => t.name === 'robots' && t.content === 'noindex');
+ expect(robots).toBeDefined();
+ expect(robots).toMatchObject({ name: 'robots', content: 'noindex' });
+ });
+});
+
+describe('company.data worker pipeline — applyPrivacyMaskHeaders on the loader return', () => {
+ it('translates X-Privacy-Mask: applied into X-Robots-Tag: noindex and removes the marker (behavior 7)', async () => {
+ const natural = makeCompany({ legalForm: 'ЕТ', displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ' });
+ installStubs(natural);
+
+ const result = await loader(loaderArgs('123456789'));
+ expect(result).toBeInstanceOf(Response);
+ const response = result as Response;
+
+ applyPrivacyMaskHeaders(response.headers);
+
+ expect(response.headers.get('X-Robots-Tag')).toBe('noindex');
+ expect(response.headers.has('X-Privacy-Mask')).toBe(false);
+ });
+
+ it('leaves X-Robots-Tag unset and removes any pre-existing marker when the loader did not set it', async () => {
+ const legal = makeCompany({
+ legalForm: 'АД',
+ displayName: 'СОФАРМА ТРЕЙДИНГ АД',
+ eik: '121817309',
+ });
+ installStubs(legal);
+
+ const result = await loader(loaderArgs('121817309'));
+ expect(result).not.toBeInstanceOf(Response);
+ const plain = result as {
+ company: CompanyDetail;
+ coverage: CoverageMeta;
+ trend: TrendData;
+ network: NetworkData;
+ };
+
+ const outHeaders = new Headers();
+ if (plain.company.displayName) {
+ outHeaders.set('X-Passthrough', '1');
+ }
+ applyPrivacyMaskHeaders(outHeaders);
+
+ expect(outHeaders.has('X-Robots-Tag')).toBe(false);
+ expect(outHeaders.has('X-Privacy-Mask')).toBe(false);
+ expect(outHeaders.get('X-Passthrough')).toBe('1');
+ });
+});
diff --git a/apps/web/app/routes/company.tsx b/apps/web/app/routes/company.tsx
index 4c0e8d27c..a1232b71c 100644
--- a/apps/web/app/routes/company.tsx
+++ b/apps/web/app/routes/company.tsx
@@ -44,7 +44,14 @@ export function meta({ data, params, matches }: Route.MetaArgs) {
return metaTags;
}
-export function headers() {
+export function headers({ loaderHeaders }: Route.HeadersArgs) {
+ // Forward the internal privacy-mask marker set by the loader on the `.data` Response. React
+ // Router's `getDocumentHeadersImpl` does not auto-propagate loader headers (only `Set-Cookie`),
+ // so the route must forward explicitly — without this the worker `hardenResponse` cannot
+ // translate the marker into `X-Robots-Tag: noindex` on the HTML response.
+ if (loaderHeaders.get('X-Privacy-Mask') === 'applied') {
+ return { 'Cache-Control': publicCache(3600), 'X-Privacy-Mask': 'applied' };
+ }
return { 'Cache-Control': publicCache(3600) };
}
@@ -61,6 +68,18 @@ export async function loader({ params, context }: Route.LoaderArgs) {
getEntityNetwork(db, { kind: 'company', id }, { includeCenterOptions: false }),
]);
if (!company) throw new Response('Not Found', { status: 404 });
+ // Single-fetch `.data` twin shares this loader with the HTML response, so we mask here once
+ // and signal the worker via the internal `X-Privacy-Mask` marker. The mutation clears the
+ // natural person's ЕИК on the returned object (covers `.data`); the marker is translated into
+ // `X-Robots-Tag: noindex` by `hardenResponse` in `apps/web/workers/app.ts`. Legal-entity
+ // records keep the plain-object return (no marker, no mutation).
+ if (isNaturalPersonBidder(company.displayName, company.legalForm)) {
+ company.eik = null;
+ return Response.json(
+ { company, coverage, trend, network },
+ { headers: { 'X-Privacy-Mask': 'applied' } },
+ );
+ }
return { company, coverage, trend, network };
});
}
From 5da7ca531596aa8d3694be2aece42de2b459ec17 Mon Sep 17 00:00:00 2001
From: Lyuboslav Lyubenov
Date: Wed, 1 Jul 2026 16:43:58 +0300
Subject: [PATCH 04/27] docs(architecture,privacy): reflect worker-level
noindex + .data coverage
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ADR-0002 (docs/architecture.md): the Решение section now describes the
centralized X-Robots-Tag: noindex write site (hardenResponse in
apps/web/workers/app.ts, via the applyPrivacyMaskHeaders helper in
apps/web/app/lib/security.ts). The bullet on per-route CSV/contract-json
writes is replaced by a single sentence naming hardenResponse, the marker
flow, and the deletion pre-edgeCache.put.
The Засегнати повърхности list grows to explicitly enumerate:
- the .data twin of /companies/:eik (React Router v7 single-fetch,
automatic via the shared loader in company.tsx)
- apps/web/workers/app.ts (hardenResponse) as the centralized
enforcement point under a new 'Worker — централизирана точка за
прилагане' sub-heading
- apps/web/app/lib/security.ts as the policy helper home, with
PRIVACY_MASK_APPLIED as the literal-typed constant.
The privacy page (apps/web/app/routes/privacy.tsx) #natural-person-data
section grows to enumerate /companies/:eik.data alongside the existing
/contracts/:id.json and the three CSV exports. A follow-up paragraph in
Bulgarian prose explains that the X-Robots-Tag: noindex policy is now
applied uniformly at the worker edge so future machine-readable surfaces
inherit it automatically — without naming the X-Privacy-Mask marker or
the helper functions (user-facing wording only).
No edits to package.json, pnpm-lock.yaml, or the public API contract.
---
apps/web/app/routes/privacy.tsx | 23 +++++++++++++++--------
docs/architecture.md | 18 ++++++++++++------
2 files changed, 27 insertions(+), 14 deletions(-)
diff --git a/apps/web/app/routes/privacy.tsx b/apps/web/app/routes/privacy.tsx
index 6afd37007..6c8dd252d 100644
--- a/apps/web/app/routes/privacy.tsx
+++ b/apps/web/app/routes/privacy.tsx
@@ -72,25 +72,32 @@ export default function Privacy({ loaderData }: Route.ComponentProps) {
Данни за физически лица и еднолични търговци
Когато даден запис в СИГМА е разпознат като физическо лице или едноличен търговец (ЕТ),
- прилагаме допълнителна защита в машинно-четливите формати. Отговорите се предават с
- HTTP заглавието X-Robots-Tag: noindex, за да не попадат в индексите на
+ прилагаме допълнителна защита в машинно-четливите формати. Отговорите се предават с HTTP
+ заглавието X-Robots-Tag: noindex, за да не попадат в индексите на
търсачките и останалите автоматични роботи.
- Засегнати са следните машинно-четливи формати: JSON записът на отделен договор
- (/contracts/:id.json) и трите публични CSV износа —{' '}
- /contracts.csv, /companies.csv и{' '}
+ Засегнати са следните машинно-четливи формати: JSON записът на отделен договор (
+ /contracts/:id.json), машинно-четливият близнак на профила на компанията (
+ /companies/:eik.data), който се зарежда от скриптовете на страницата, и
+ трите публични CSV износа — /contracts.csv, /companies.csv и{' '}
/authorities.csv. В тях ЕИК и оригиналното име от източника (raw source
name) на физическото лице се заменят с неутрален, неидентифициращ етикет, а полето за
ЕИК остава празно. Записите за юридически лица не се променят — техните ЕИК и имена
остават непокътнати.
+
+ Политиката по X-Robots-Tag: noindex се прилага централизирано, на нивото на
+ инфраструктурата, която обслужва заявките — затова всички машинно-четливи формати
+ получават тази защита по един и същи начин, а бъдещите подобни повърхности ще я наследят
+ автоматично, без да е необходимо допълнително действие.
+
HTML профилът на дружеството (страницата, която посетителят отваря в браузъра) остава
непокътнат по съдържание — името, ЕИК и всички останали полета се показват както в
- първичния източник. Единствената допълнителна мярка е мета-етикетът{' '}
- noindex, който препоръчва на търсачките да не индексират страницата. Така
- публичният достъп за хората се запазва, а публичното откриване чрез търсене — не.
+ първичния източник. Единствената допълнителна мярка е мета-етикетът noindex
+ , който препоръчва на търсачките да не индексират страницата. Така публичният достъп за
+ хората се запазва, а публичното откриване чрез търсене — не.
Описаната политика е продуктово и инженерно решение за последователно прилагане на
diff --git a/docs/architecture.md b/docs/architecture.md
index 98a0a5894..028d6cf5b 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -103,7 +103,7 @@ HTML профилът на фирма вече прилагаше `noindex` ме
Приета е политика **`noindex` плюс маскиране**, с общ предикат от [`packages/shared/src/format.ts`](../packages/shared/src/format.ts):
1. **Единен предикат.** Премахваме inline `isSingleNaturalPersonProfile` от `apps/web/app/routes/company.tsx` и заменяме с `isNaturalPersonBidder(name, legalForm)` от `packages/shared/src/format.ts`. Предикатът комбинира два сигнала — `legal_form LIKE 'ЕТ%'` (включително латинското `ET`, разширените форми `ЕДНОЛИЧЕН ТЪРГОВЕЦ`, `SOLE TRADER`, `INDIVIDUAL`) и водещия `ЕТ ` / `ET ` суфикс в името (който вече беше в `isNaturalPersonProfileName`). HTML `noindex` мета тагът, CSV маскирането и JSON маскирането споделят **единствено** този предикат — няма дублирани хардкоднати правила в route-овете.
-2. **`X-Robots-Tag: noindex` на всяка машинно-четима повърхност.** Маркерът се задава на четирите клона на [`apps/web/app/lib/csv-export.ts`](../apps/web/app/lib/csv-export.ts) (`markCsvCache`, динамичният `Response`, 200/206 `Response`, 304 `Response`) и на loader-а на [`apps/web/app/routes/contract.json.tsx`](../apps/web/app/routes/contract.json.tsx) само когато записът е маскиран. Маркерът е допълнение към `robots.txt` (който вече забраняваше `/*.csv`) — покрива crawler-и и инструменти, които четат HTTP-хедъри, но не и `robots.txt`.
+2. **`X-Robots-Tag: noindex` на всяка машинно-четима повърхност — централизирано в worker-а.** Заглавието се задава в **единствено едно** място в авторския код: в worker pipeline-а [`hardenResponse`](../../apps/web/workers/app.ts) (в `apps/web/workers/app.ts`), който при финалното обвиване на отговора извиква помощната функция `applyPrivacyMaskHeaders` от [`apps/web/app/lib/security.ts`](../../apps/web/app/lib/security.ts). Route-овете само **маркират** отговора си с вътрешния хедър `X-Privacy-Mask: applied` чрез помощната функция `markPrivacyMaskApplied` от същия файл — например [`apps/web/app/routes/contract.json.tsx`](../apps/web/app/routes/contract.json.tsx) го извиква в reference-equality гейта на маскирането. Четирите CSV клона на [`apps/web/app/lib/csv-export.ts`](../apps/web/app/lib/csv-export.ts) (`markCsvCache`, динамичният `Response`, 200/206 `Response`, 304 `Response`) също само маркират — `X-Robots-Tag: noindex` се появява в изходящите им хедъри единствено защото worker-ът го излъчва, не защото `csv-export.ts` го пише. Вътрешният `X-Privacy-Mask` маркер се изтрива в worker-а преди кеширане и преди достигане до клиента (виж [`apps/web/workers/app.ts:62`](../../apps/web/workers/app.ts) и `applyPrivacyMaskHeaders` в `security.ts:86-91`) — никога не изтича към потребителя. Маркерът е допълнение към `robots.txt` (който вече забраняваше `/*.csv`) — покрива crawler-и и инструменти, които четат HTTP-хедъри, но не и `robots.txt`.
3. **Маскиране на ЕИК и оригиналното име.** За разпознати естествено лица / еднолични търговци:
- **CSV:** `contractor_eik`/`eik` се замества с празен низ, `contractor`/`name` се замества със символа `MASKED_NATURAL_PERSON_LABEL` от [`packages/shared/src/format.ts:197`](../packages/shared/src/format.ts) (стойност `„Частно лице"` — без запетая/кавичка, за да не се нуждае от CSV-escape).
- **JSON:** `bidder.eik` → `null`, `bidder.name` → `MASKED_NATURAL_PERSON_LABEL`, `bidder.displayName` → `MASKED_NATURAL_PERSON_LABEL`, `sourceNames.bidder` → `MASKED_NATURAL_PERSON_LABEL`. Останалите полета (`bidder.slug`, агрегатите `totalContracts`/`totalEur`, `kind`, `settlement`) остават непроменени — `slug`-ът не е PII, а е URL фрагмент.
@@ -137,15 +137,21 @@ HTML профилът на фирма вече прилагаше `noindex` ме
### Web route-ове
-- [`apps/web/app/routes/contract.json.tsx`](../apps/web/app/routes/contract.json.tsx) — добавя чистия хелпер `maskContractForPrivacy(record, bidderLegalForm): ContractRecord` (модулен export, за тестваемост) и извиква `X-Robots-Tag: noindex` само когато маскирането е приложило (reference-equality гейт). 404 клонът и `Cache-Control` остават непроменени.
-- [`apps/web/app/routes/contract.json.test.ts`](../apps/web/app/routes/contract.json.test.ts) — нов тестов файл (3 теста за хелпера + 4 за loader-а, общо 7).
-- [`apps/web/app/routes/company.tsx`](../apps/web/app/routes/company.tsx) — премахва inline `isSingleNaturalPersonProfile`; HTML `noindex` сега идва от `isNaturalPersonBidder`.
+- [`apps/web/app/routes/contract.json.tsx`](../apps/web/app/routes/contract.json.tsx) — добавя чистия хелпер `maskContractForPrivacy(record, bidderLegalForm): ContractRecord` (модулен export, за тестваемост) и в reference-equality гейта на маскирането извиква `markPrivacyMaskApplied(headers)` от `apps/web/app/lib/security.ts` — служи като вътрешен сигнал за worker-а, не пише `X-Robots-Tag` директно. 404 клонът и `Cache-Control` остават непроменени.
+- [`apps/web/app/routes/contract.json.test.ts`](../apps/web/app/routes/contract.json.test.ts) — нов тестов файл (3 теста за хелпера + 4 за loader-а, общо 7). Тестовете за loader-а твърдят, че маркерът `X-Privacy-Mask: applied` присъства на маскирания отговор и че `X-Robots-Tag: noindex` **не** присъства на loader output-а — последното е прерогатив на worker-а.
+- [`apps/web/app/routes/company.tsx`](../apps/web/app/routes/company.tsx) — премахва inline `isSingleNaturalPersonProfile`; HTML `noindex` сега идва от `isNaturalPersonBidder`. За естествено лице loader-ът `mutate`-ва `company.eik = null` и връща `Response.json({...}, { headers: { 'X-Privacy-Mask': 'applied' } })`. `headers()` export-ът препраща `X-Privacy-Mask` от `loaderHeaders`, за да достигне маркерът до worker-а и при HTML отговора (виж по-долу за `.data` близнака).
+- **`.data` близнак на `/companies/:eik`** (React Router v7 single-fetch при `ssr:true`) — споделя същия `loader` от `company.tsx`, така че маскирането и сигнализирането се случват автоматично за `.data` заявките: при естествено лице `company.eik = null` е видимо в turbo-stream payload-а и worker-ът излъчва `X-Robots-Tag: noindex` на отговора (включително в edge-кешираното копие).
- [`apps/web/app/routes/contracts.csv.tsx`](../apps/web/app/routes/contracts.csv.tsx), [`apps/web/app/routes/companies.csv.tsx`](../apps/web/app/routes/companies.csv.tsx), [`apps/web/app/routes/authorities.csv.tsx`](../apps/web/app/routes/authorities.csv.tsx) — без промяна на кода; маркерът идва от `servedCsvExport`.
+### Worker — централизирана точка за прилагане
+
+- [`apps/web/workers/app.ts`](../apps/web/workers/app.ts), функция `hardenResponse` — **единственото** място в авторския код, което пише `X-Robots-Tag: noindex` в изходящия отговор. Извиква `applyPrivacyMaskHeaders(headers)` от [`apps/web/app/lib/security.ts`](../apps/web/app/lib/security.ts), което превежда маркера `X-Privacy-Mask: applied` (сложен от който и да е route loader или `csv-export.ts`) в публичния `X-Robots-Tag: noindex` и след това изтрива самия маркер. Поставянето на изтриването в `hardenResponse` гарантира, че вътрешният маркер не достига нито edge кеша (`edgeCache.put(key, hardened.clone())`), нито клиента. Поради тази централизация **всяка бъдеща машинно-четима повърхност** (CSV, JSON, `.data`, нов feed) наследява `X-Robots-Tag: noindex` автоматично, стига route-ът (или helper в `csv-export.ts`) да сложи маркера през `markPrivacyMaskApplied(headers)` — само с едно място за поддръжка на политиката.
+
### Web помощен код
-- [`apps/web/app/lib/csv-export.ts`](../apps/web/app/lib/csv-export.ts) — `X-Robots-Tag: noindex` се задава в `markCsvCache` (споделения път) и в двата директни `new Response(...)` сайта (304 и 200/206). Съществуващите `Cache-Control` и `Content-Disposition` остават.
-- [`apps/web/app/lib/csv-export.test.ts`](../apps/web/app/lib/csv-export.test.ts) — нов `describe('servedCsvExport privacy', ...)` блок (5 теста: MISS/HIT/dynamic за трите CSV повърхности + body+Cache-Control проверка).
+- [`apps/web/app/lib/security.ts`](../apps/web/app/lib/security.ts) — двата нови хелпера на политиката: `markPrivacyMaskApplied(headers)` (route-ът го извиква, за да маркира отговора) и `applyPrivacyMaskHeaders(headers)` (worker-ът го извиква, за да преведе маркера в `X-Robots-Tag: noindex` и да го изтрие). Константата `PRIVACY_MASK_APPLIED` (`'applied'`) е литерален тип и е единственият позволен израз за стойност на маркера.
+- [`apps/web/app/lib/csv-export.ts`](../apps/web/app/lib/csv-export.ts) — `X-Robots-Tag: noindex` **не** се задава тук. На негово място `markCsvCache` и 304 клонът извикват `markPrivacyMaskApplied(headers)` от `security.ts`; политическото решение (кой получава `noindex`) се взема в worker-а. Съществуващите `Cache-Control` и `Content-Disposition` остават.
+- [`apps/web/app/lib/csv-export.test.ts`](../apps/web/app/lib/csv-export.test.ts) — обновен `describe('servedCsvExport privacy', ...)` блок (тестовете вече твърдят `X-Privacy-Mask: applied` на `response.headers` и **липса** на `X-Robots-Tag` — последното е прерогатив на worker-а).
### Потребителска документация
From 71f38392cc32ff696764257f2c3c46322ebce1cc Mon Sep 17 00:00:00 2001
From: Lyuboslav Lyubenov
Date: Thu, 2 Jul 2026 16:00:39 +0300
Subject: [PATCH 05/27] docs(privacy): add ADR-0003 + operational masking
guide, cross-link ADR-0002
---
docs/README.md | 1 +
.../0003-centralized-x-robots-tag-worker.md | 46 +++++++++++++++++
docs/architecture.md | 3 +-
docs/privacy-masking.md | 50 +++++++++++++++++++
4 files changed, 99 insertions(+), 1 deletion(-)
create mode 100644 docs/adr/0003-centralized-x-robots-tag-worker.md
create mode 100644 docs/privacy-masking.md
diff --git a/docs/README.md b/docs/README.md
index 568550ad6..e89210c25 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -5,6 +5,7 @@
- [`architecture.md`](architecture.md) — преглед на системата (поток на данните, двата Worker-а) и карта към решенията.
- [`adr/`](adr/README.md) — Architecture Decision Records: по едно архитектурно решение на файл, с индекс и шаблон.
- [`core-scope.md`](core-scope.md) — доменният модел и **речникът на данните**: таблици, rollup-и, `value_flag`/`date_flag`, семантиката на `amount_eur`.
+- [`privacy-masking.md`](privacy-masking.md) — оперативно ръководство за механиката на маскирането на естествени лица и `X-Robots-Tag: noindex`: маркерният договор, как да се добави нова машинно-четима повърхност и как се тества.
- [`etl.md`](etl.md) — ETL pipeline-ът и open-data емисията на ЦАИС ЕОП (`storage.eop.bg`): зареждане, опресняване и производни таблици.
- [`etl-pipeline-state.md`](etl-pipeline-state.md) — анализ на текущото състояние на ETL pipeline-а.
- [`etl-architecture.md`](etl-architecture.md) — целевата ETL архитектура (RFC): предложение за състоянието и реда на изпълнение.
diff --git a/docs/adr/0003-centralized-x-robots-tag-worker.md b/docs/adr/0003-centralized-x-robots-tag-worker.md
new file mode 100644
index 000000000..3543b1363
--- /dev/null
+++ b/docs/adr/0003-centralized-x-robots-tag-worker.md
@@ -0,0 +1,46 @@
+# ADR-0003 — Централизирано авторство на `X-Robots-Tag: noindex` в worker-а
+
+- **Дата:** 2026-07-02
+- **Статус:** Прието
+- **Свързани:** [ADR-0002](../architecture.md#adr-0002--политика-за-поверителност-на-машинно-четим-изход-issue-173) (политиката *noindex + маскиране*), [Issue #173](https://github.com/midt-bg/sigma/issues/173), PR #183 (review fixes)
+- **Обхват:** Механизма, по който се прилага публичният хедър `X-Robots-Tag: noindex` върху всички машинно-четими повърхности (JSON, CSV, `.data` twin на React Router v7 single-fetch). Не променя продуктовата политика от ADR-0002 — само нейното инженерно реализиране.
+
+## Контекст
+
+ADR-0002 задължава всяка машинно-четима повърхност да носи `X-Robots-Tag: noindex`. Първоначалната имплементация пишеше хедъра **inline на всяко място**, което го изпращаше:
+
+- в loader-а на [`apps/web/app/routes/contract.json.tsx`](../../apps/web/app/routes/contract.json.tsx);
+- в четирите клона на [`apps/web/app/lib/csv-export.ts`](../../apps/web/app/lib/csv-export.ts) (`markCsvCache`, динамичният `Response`, 200/206 `Response`, 304 `Response`).
+
+Това създаваше три проблема, установени при code review на PR #183:
+
+1. **Множество авторски sites.** Политиката „кой получава `noindex`" беше разпръсната из route-ове и helper-и. Всяка нова машинно-четима повърхност трябваше да помни да добави хедъра — лесна регресия.
+2. **`.data` близнак на `/companies/:eik` оставаше непокрит.** При `ssr:true` React Router v7 single-fetch сервира turbo-stream payload на `/companies/:eik.data`, споделяйки loader-а с HTML отговора. Този payload съдържаше естествено-личностния ЕИК на едноличния търговец и нямаше нито маскиране на тялото, нито `X-Robots-Tag: noindex` — дупка спрямо HTML профила, който вече имаше `noindex` мета таг.
+3. **Маркерът не достигаше до HTML отговора.** `getDocumentHeadersImpl` на React Router не пропагира loader хедъри към document response (само `Set-Cookie`), така че дори loader-ът да сложеше маркер, worker-ът не го виждаше на HTML отговора без експлицитно препращане.
+
+## Решение
+
+**Единствено авторско място за `X-Robots-Tag: noindex`** — worker pipeline-ът. Route-овете и helper-ите само *маркират*, worker-ът *превежда*.
+
+1. **Вътрешен маркер.** Въвеждаме константата `PRIVACY_MASK_MARKER = 'X-Privacy-Mask'` и литерала `PRIVACY_MASK_APPLIED = 'applied'` в [`apps/web/app/lib/security.ts`](../../apps/web/app/lib/security.ts). Route/helper, чийто отговор съдържа маскирани естествено-личностни данни, извиква `markPrivacyMaskApplied(headers)`, което слага `X-Privacy-Mask: applied`.
+
+2. **Един преводач.** Функцията `hardenResponse` в [`apps/web/workers/app.ts`](../../apps/web/workers/app.ts) е **единственото** място в авторския код, което пише `X-Robots-Tag: noindex` в изходящия отговор. Тя извиква `applyPrivacyMaskHeaders(headers)` (от същия `security.ts`), което превежда маркера в публичния хедър и **безусловно изтрива** самия маркер — така той не достига нито edge кеша (`edgeCache.put(key, hardened.clone())`), нито клиента.
+
+3. **Маскиране на `.data` at the source.** Loader-ът на [`apps/web/app/routes/company.tsx`](../../apps/web/app/routes/company.tsx) `mutate`-ва `company.eik = null` за разпознати естествени лица и връща `Response.json({...}, { headers: { 'X-Privacy-Mask': 'applied' } })`. Тъй като single-fetch `.data` twin споделя същия loader, маскирането и маркировката се случват автоматично и за двете повърхности.
+
+4. **Препращане през `headers()`.** Route-ът експортира `headers({ loaderHeaders })`, който експлицитно препраща `X-Privacy-Mask: applied` към document response-а — заобикаляйки липсата на авто-пропагация в React Router. Без това worker-ът не би превел маркера за HTML отговора.
+
+## Последствия
+
+- **Една точка за поддръжка.** Всяка бъдеща машинно-четима повърхност (нов feed, нов route) наследява `X-Robots-Tag: noindex` автоматично, стига да извика `markPrivacyMaskApplied(headers)`. Няма как да се „забрави" хедърът на нова повърхност, която вече маркира — макар че добавянето на нова повърхност изисква умишлено поставяне на маркера.
+- **Маркерът не изтича.** Изтриването в `hardenResponse` е defensive: дори повторно/идемпотентно извикване оставя вече сложен `X-Robots-Tag` недокоснат и никога не пропуска `X-Privacy-Mask` навън.
+- **Разделени тестове.** Loader/helper тестовете твърдят присъствие на маркера **и отсъствие** на `X-Robots-Tag` (последното е прерогатив на worker-а); worker/CSP тестовете твърдят превода. Виж [`security.test.ts`](../../apps/web/app/lib/security.test.ts), [`contract.json.test.ts`](../../apps/web/app/routes/contract.json.test.ts), [`csv-export.test.ts`](../../apps/web/app/lib/csv-export.test.ts), [`company.data.test.ts`](../../apps/web/app/routes/company.data.test.ts) и [`app.nofollow.test.ts`](../../apps/web/workers/app.nofollow.test.ts).
+- **`authorities.csv`** получава `X-Robots-Tag: noindex` (маркира се), но без маскиране на тялото — възложителите винаги имат попълнен ЕИК (публични органи). Политическа последователност, не техническо маскиране (както и в ADR-0002).
+
+## Засегнати повърхности
+
+- [`apps/web/workers/app.ts`](../../apps/web/workers/app.ts) — `hardenResponse`: единствен автор на `X-Robots-Tag: noindex`.
+- [`apps/web/app/lib/security.ts`](../../apps/web/app/lib/security.ts) — `markPrivacyMaskApplied`, `applyPrivacyMaskHeaders`, `PRIVACY_MASK_MARKER`, `PRIVACY_MASK_APPLIED`.
+- [`apps/web/app/routes/contract.json.tsx`](../../apps/web/app/routes/contract.json.tsx) — маркира отговора.
+- [`apps/web/app/lib/csv-export.ts`](../../apps/web/app/lib/csv-export.ts) — маркира в четирите клона (вече не пише `X-Robots-Tag`).
+- [`apps/web/app/routes/company.tsx`](../../apps/web/app/routes/company.tsx) — `company.eik = null` + маркировка в loader-а; `headers()` препраща маркера за HTML отговора (и `.data` twin-а).
diff --git a/docs/architecture.md b/docs/architecture.md
index 028d6cf5b..8c7e0626a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -84,6 +84,7 @@
- **Статус:** Прието
- **Обхват:** Повърхностите на СИГМА, които връщат машинно-четимо тяло — JSON записите на договорите (`/contracts/:id.json`) и CSV експортите (`/contracts.csv`, `/companies.csv`, `/authorities.csv`). Не засяга HTML рендирането на профилите, което вече имаше собствен `noindex` път.
- **Препратки:** [Issue #173](https://github.com/midt-bg/sigma/issues/173) · [`apps/web/app/routes/privacy.tsx`](../apps/web/app/routes/privacy.tsx) (`# natural-person-data`) · ADR-0001 §3 (модел на сигурност)
+- **Виж също:** [ADR-0003](adr/0003-centralized-x-robots-tag-worker.md) — инженерното решение за централизирано авторство на `X-Robots-Tag: noindex` в worker-а (рефакторът от PR #183 review) и [`docs/privacy-masking.md`](privacy-masking.md) за оперативно ръководство.
> Решението за маскиране на идентификаторите на едноличните търговци и физическите лица в машинно-четимия изход е продуктово-политическо и инженерно, не правно. GDPR и ЗЗЛД се третират като контекст за вземане на решение, не като юридическо тълкуване — окончателната оценка е задължение на поддържащите проекта.
@@ -103,7 +104,7 @@ HTML профилът на фирма вече прилагаше `noindex` ме
Приета е политика **`noindex` плюс маскиране**, с общ предикат от [`packages/shared/src/format.ts`](../packages/shared/src/format.ts):
1. **Единен предикат.** Премахваме inline `isSingleNaturalPersonProfile` от `apps/web/app/routes/company.tsx` и заменяме с `isNaturalPersonBidder(name, legalForm)` от `packages/shared/src/format.ts`. Предикатът комбинира два сигнала — `legal_form LIKE 'ЕТ%'` (включително латинското `ET`, разширените форми `ЕДНОЛИЧЕН ТЪРГОВЕЦ`, `SOLE TRADER`, `INDIVIDUAL`) и водещия `ЕТ ` / `ET ` суфикс в името (който вече беше в `isNaturalPersonProfileName`). HTML `noindex` мета тагът, CSV маскирането и JSON маскирането споделят **единствено** този предикат — няма дублирани хардкоднати правила в route-овете.
-2. **`X-Robots-Tag: noindex` на всяка машинно-четима повърхност — централизирано в worker-а.** Заглавието се задава в **единствено едно** място в авторския код: в worker pipeline-а [`hardenResponse`](../../apps/web/workers/app.ts) (в `apps/web/workers/app.ts`), който при финалното обвиване на отговора извиква помощната функция `applyPrivacyMaskHeaders` от [`apps/web/app/lib/security.ts`](../../apps/web/app/lib/security.ts). Route-овете само **маркират** отговора си с вътрешния хедър `X-Privacy-Mask: applied` чрез помощната функция `markPrivacyMaskApplied` от същия файл — например [`apps/web/app/routes/contract.json.tsx`](../apps/web/app/routes/contract.json.tsx) го извиква в reference-equality гейта на маскирането. Четирите CSV клона на [`apps/web/app/lib/csv-export.ts`](../apps/web/app/lib/csv-export.ts) (`markCsvCache`, динамичният `Response`, 200/206 `Response`, 304 `Response`) също само маркират — `X-Robots-Tag: noindex` се появява в изходящите им хедъри единствено защото worker-ът го излъчва, не защото `csv-export.ts` го пише. Вътрешният `X-Privacy-Mask` маркер се изтрива в worker-а преди кеширане и преди достигане до клиента (виж [`apps/web/workers/app.ts:62`](../../apps/web/workers/app.ts) и `applyPrivacyMaskHeaders` в `security.ts:86-91`) — никога не изтича към потребителя. Маркерът е допълнение към `robots.txt` (който вече забраняваше `/*.csv`) — покрива crawler-и и инструменти, които четат HTTP-хедъри, но не и `robots.txt`.
+2. **`X-Robots-Tag: noindex` на всяка машинно-четима повърхност — централизирано в worker-а.** Заглавието се задава в **единствено едно** място в авторския код: в worker pipeline-а [`hardenResponse`](../../apps/web/workers/app.ts) (в `apps/web/workers/app.ts`), който при финалното обвиване на отговора извиква помощната функция `applyPrivacyMaskHeaders` от [`apps/web/app/lib/security.ts`](../../apps/web/app/lib/security.ts). Route-овете и помощният код само **маркират** отговора си с вътрешния хедър `X-Privacy-Mask: applied` чрез помощната функция `markPrivacyMaskApplied` от същия файл — например [`apps/web/app/routes/contract.json.tsx`](../apps/web/app/routes/contract.json.tsx) и CSV клоновете на [`apps/web/app/lib/csv-export.ts`](../apps/web/app/lib/csv-export.ts) я извикват при нужда. `X-Robots-Tag: noindex` се появява в изходящите хедъри на всички техни отговори единствено защото worker-ът го излъчва, а не защото те го пишат. Вътрешният `X-Privacy-Mask` маркер се изтрива в worker-а преди кеширане и преди достигане до клиента (виж [`apps/web/workers/app.ts:62`](../../apps/web/workers/app.ts) и `applyPrivacyMaskHeaders` в `security.ts:86-91`) — никога не изтича към потребителя. Поради тази централизация **всяка бъдеща машинно-четима повърхност** (CSV, JSON, `.data`, нов feed) наследява `X-Robots-Tag: noindex` автоматично, стига route-ът (или helper в `csv-export.ts`) да сложи маркера през `markPrivacyMaskApplied(headers)`. Маркерът е допълнение към `robots.txt` (който вече забраняваше `/*.csv`) — покрива crawler-и и инструменти, които четат HTTP-хедъри, но не и `robots.txt`.
3. **Маскиране на ЕИК и оригиналното име.** За разпознати естествено лица / еднолични търговци:
- **CSV:** `contractor_eik`/`eik` се замества с празен низ, `contractor`/`name` се замества със символа `MASKED_NATURAL_PERSON_LABEL` от [`packages/shared/src/format.ts:197`](../packages/shared/src/format.ts) (стойност `„Частно лице"` — без запетая/кавичка, за да не се нуждае от CSV-escape).
- **JSON:** `bidder.eik` → `null`, `bidder.name` → `MASKED_NATURAL_PERSON_LABEL`, `bidder.displayName` → `MASKED_NATURAL_PERSON_LABEL`, `sourceNames.bidder` → `MASKED_NATURAL_PERSON_LABEL`. Останалите полета (`bidder.slug`, агрегатите `totalContracts`/`totalEur`, `kind`, `settlement`) остават непроменени — `slug`-ът не е PII, а е URL фрагмент.
diff --git a/docs/privacy-masking.md b/docs/privacy-masking.md
new file mode 100644
index 000000000..f9ed7268a
--- /dev/null
+++ b/docs/privacy-masking.md
@@ -0,0 +1,50 @@
+# Маскиране на естествени лица и `X-Robots-Tag: noindex` — ръководство за разработчици
+
+Това е оперативното ръководство за механиката зад политиката от [ADR-0002](architecture.md#adr-0002--политика-за-поверителност-на-машинно-четим-изход-issue-173) и решението за централизация от [ADR-0003](adr/0003-centralized-x-robots-tag-worker.md). За потребителското описание виж [`apps/web/app/routes/privacy.tsx`](../apps/web/app/routes/privacy.tsx) (`#natural-person-data`).
+
+## Как работи механиката
+
+Идеята е **разделяне на отговорностите**: route-овете решават *дали* даден отговор съдържа маскирани естествено-личностни данни; worker-ът решава *как* да се излъчи публичният `X-Robots-Tag: noindex`.
+
+```
+loader / helper worker (hardenResponse) edge cache / client
+───────────────── ──────────────────────── ────────────────────
+X-Privacy-Mask: applied ──► applyPrivacyMaskHeaders() ──► X-Robots-Tag: noindex
+ ├─ превежда маркера в noindex (маркерът е изтрит)
+ └─ delete X-Privacy-Mask
+```
+
+- **Маркер:** `X-Privacy-Mask: applied` (вътрешен — никога не напуска worker-а). Константите `PRIVACY_MASK_MARKER` / `PRIVACY_MASK_APPLIED` са в [`apps/web/app/lib/security.ts`](../apps/web/app/lib/security.ts).
+- **Слагане:** `markPrivacyMaskApplied(headers)` — route или helper го вика, когато тялото съдържа маскирани данни.
+- **Превод:** `applyPrivacyMaskHeaders(headers)` — вика се **само** от `hardenResponse` в [`apps/web/workers/app.ts`](../apps/web/workers/app.ts). Това е единственото място, което пише `X-Robots-Tag: noindex`.
+- **Предикат:** `isNaturalPersonBidder(displayName, legalForm)` от [`packages/shared/src/format.ts`](../packages/shared/src/format.ts) — единственият източник на истината дали даден запис е естествено лице / едноличен търговец. HTML, CSV и JSON споделят него; няма дублирани хардкоднати правила в route-овете.
+
+## Повърхности, които маркират днес
+
+| Повърхност | Маркира в | Маскиране на тялото |
+|---|---|---|
+| `/contracts/:id.json` | `apps/web/app/routes/contract.json.tsx` | ЕИК→`null`, име→`MASKED_NATURAL_PERSON_LABEL` |
+| `/contracts.csv`, `/companies.csv`, `/authorities.csv` | `apps/web/app/lib/csv-export.ts` (4 клона) | ЕИК→празен низ, име→`MASKED_NATURAL_PERSON_LABEL` (`authorities.csv` без маскиране) |
+| `/companies/:eik` (HTML) | `apps/web/app/routes/company.tsx` `headers()` препраща маркера | `company.eik = null` в loader-а |
+| `/companies/:eik.data` (single-fetch twin) | същият loader (споделен с HTML) | автоматично — споделя loader-а |
+
+## Как да добавите нова машинно-четима повърхност
+
+1. В loader-а / helper-а, когато отговорът съдържа (или може да съдържа) естествено-личностни данни, извикайте `markPrivacyMaskApplied(headers)` върху изходящите `Headers`.
+2. Ако повърхността е HTML document route (не resource route), експортирайте `headers({ loaderHeaders })`, който препраща `X-Privacy-Mask: applied` — React Router не авто-пропагира loader хедърите (само `Set-Cookie`). Виж примера в `company.tsx`.
+3. Нищо друго не е нужно — `hardenResponse` в worker-а ще преведе маркера в `X-Robots-Tag: noindex` и ще изтрие маркера преди кеш/клиент.
+4. Ако повърхността сервира сурови идентификатори (ЕИК/име), маскирайте ги в тялото преди маркировката (ЕТ→`null`/празен низ, име→`MASKED_NATURAL_PERSON_LABEL`).
+
+> Никога не пишете `X-Robots-Tag: noindex` директно в route или helper — това нарушава единственото авторско място (C1 от критериите за приемане). Единственото изключение е тестов код.
+
+## Тестване
+
+Тестовете са разделени според разделението на отговорностите:
+
+- **Loader/helper тестове** твърдят: маркерът `X-Privacy-Mask: applied` присъства **и** `X-Robots-Tag` **липсва** на loader output-а (преводът е работа на worker-а).
+ - [`apps/web/app/lib/security.test.ts`](../apps/web/app/lib/security.test.ts)
+ - [`apps/web/app/routes/contract.json.test.ts`](../apps/web/app/routes/contract.json.test.ts)
+ - [`apps/web/app/lib/csv-export.test.ts`](../apps/web/app/lib/csv-export.test.ts)
+ - [`apps/web/app/routes/company.data.test.ts`](../apps/web/app/routes/company.data.test.ts) — `.data` twin: `eik: null` + маркер след `hardenResponse`; edge-cache HIT път; без хедър за юридическо лице.
+- **Worker тестове** твърдят превода маркер → `X-Robots-Tag: noindex` и изтриването на маркера.
+ - [`apps/web/workers/app.nofollow.test.ts`](../apps/web/workers/app.nofollow.test.ts)
From e44fc6c55a9aa80e6298a77202d3644d2da9d3e3 Mon Sep 17 00:00:00 2001
From: Lyuboslav Tihomirov Lyubenov
Date: Mon, 6 Jul 2026 15:37:31 +0300
Subject: [PATCH 06/27] =?UTF-8?q?docs(adr):=20fit=20new=20adr/=20layout=20?=
=?UTF-8?q?=E2=80=94=20extract=20privacy=20policy=20to=200007,=20renumber?=
=?UTF-8?q?=20worker=20adr=20to=200008?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rebase of #183 onto upstream/main (post-#182 ADR reorganization) restructured the privacy-policy and worker-level X-Robots-Tag ADRs to live in docs/adr/ rather than inline in docs/architecture.md:
- New docs/adr/0007-privacy-masking.md — content extracted from the inline ADR-0002 in architecture.md; relative paths adjusted (../ → ../../) for the new adr/ location; cross-link to the worker ADR now points to 0008.
- docs/adr/0003-centralized-x-robots-tag-worker.md → docs/adr/0008-centralized-x-robots-tag-worker.md — renumbered to free the 0003 slot taken by upstream's value-flag ADR; internal cross-link from architecture.md#adr-0002-... to 0007-privacy-masking.md.
- docs/adr/README.md — index extended with the two new entries.
- docs/architecture.md — adopted upstream's short summary form; the inline ADR-0001+0002 contents are removed (the rendering ADR lives at adr/0001-rendering-and-security.md and the privacy policy at adr/0007-privacy-masking.md); Решения (ADR) section now also points to 0007 and 0008.
- docs/privacy-masking.md — cross-link from architecture.md#adr-0002-... to adr/0007-privacy-masking.md; ADR-0003 to ADR-0008.
No code changes; verified pnpm check:docs (docs-integrity gate from #182) passes.
---
docs/adr/0007-privacy-masking.md | 109 +++++++++
...> 0008-centralized-x-robots-tag-worker.md} | 10 +-
docs/adr/README.md | 2 +
docs/architecture.md | 217 ++++--------------
docs/privacy-masking.md | 2 +-
5 files changed, 158 insertions(+), 182 deletions(-)
create mode 100644 docs/adr/0007-privacy-masking.md
rename docs/adr/{0003-centralized-x-robots-tag-worker.md => 0008-centralized-x-robots-tag-worker.md} (93%)
diff --git a/docs/adr/0007-privacy-masking.md b/docs/adr/0007-privacy-masking.md
new file mode 100644
index 000000000..d919e7ceb
--- /dev/null
+++ b/docs/adr/0007-privacy-masking.md
@@ -0,0 +1,109 @@
+# ADR-0007 — Политика за поверителност на машинно-четим изход (Issue #173)
+
+- **Дата:** 2026-07-02
+- **Статус:** Прието
+- **Обхват:** Повърхностите на СИГМА, които връщат машинно-четимо тяло — JSON записите на договорите (`/contracts/:id.json`) и CSV експортите (`/contracts.csv`, `/companies.csv`, `/authorities.csv`). Не засяга HTML рендирането на профилите, което вече имаше собствен `noindex` път.
+- **Препратки:** [Issue #173](https://github.com/midt-bg/sigma/issues/173) · [`apps/web/app/routes/privacy.tsx`](../../apps/web/app/routes/privacy.tsx) (`# natural-person-data`) · ADR-0001 §3 (модел на сигурност)
+- **Виж също:** [ADR-0008](../adr/0008-centralized-x-robots-tag-worker.md) — инженерното решение за централизирано авторство на `X-Robots-Tag: noindex` в worker-а (рефакторът от PR #183 review) и [`docs/privacy-masking.md`](../privacy-masking.md) за оперативно ръководство.
+
+> Решението за маскиране на идентификаторите на едноличните търговци и физическите лица в машинно-четимия изход е продуктово-политическо и инженерно, не правно. GDPR и ЗЗЛД се третират като контекст за вземане на решение, не като юридическо тълкуване — окончателната оценка е задължение на поддържащите проекта.
+
+## Контекст
+
+Итерация 1 на СИГМА публикува идентификатори на изпълнителите на обществени поръчки в три форми: (1) HTML профили на фирми (по ЕИК), (2) JSON запис на договора (`/contracts/:id.json`) за връзка от HTML-а, и (3) CSV експорти (`/contracts.csv`, `/companies.csv`, `/authorities.csv`) за журналисти и изследователи.
+
+HTML профилът на фирма вече прилагаше `noindex` мета таг за записи, които се разпознават като едноличен търговец или физическо лице (`isSingleNaturalPersonProfile` в [`apps/web/app/routes/company.tsx`](../../apps/web/app/routes/company.tsx) — *inline*, преди ADR-0007). Логиката имаше две слабости:
+
+1. **Дупка между HTML и машинно-четим изход.** Търсачките и бот-индексаторите, които не изпълняват HTML мета таговете, можеха да достигнат до JSON и CSV записите и да индексират същите естествено-личностни идентификатори (ЕИК, пълно име на ЕТ). Това противоречи на политиката `noindex` от страна на HTML.
+2. **Дублирана логика на откриване.** Разпознаването „ЕТ …" беше вградено в route-а като `isSingleNaturalPersonProfile` (inline), а друга негова разновидност (`isNaturalPersonProfileName`) живееше в [`packages/shared/src/format.ts`](../../packages/shared/src/format.ts). Без единен предикат всяка нова машинно-четима повърхност щеше да копира правилата, а при разминаване на копията — да изтече идентификатор.
+
+Допълнителен фактор: edge кешът на CSV отговорите (`Cache-Control: public, max-age=3600`) съдържа пълните байтове на стрийма. Ако едно и също тяло се сервираше и за юридически лица, и за ЕТ без маскиране, кешът щеше да замрази изтичането — което означава, че маскирането трябва да се случи **преди** записа в R2.
+
+## Решение
+
+Приета е политика **`noindex` плюс маскиране**, с общ предикат от [`packages/shared/src/format.ts`](../../packages/shared/src/format.ts):
+
+1. **Единен предикат.** Премахваме inline `isSingleNaturalPersonProfile` от `apps/web/app/routes/company.tsx` и заменяме с `isNaturalPersonBidder(name, legalForm)` от `packages/shared/src/format.ts`. Предикатът комбинира два сигнала — `legal_form LIKE 'ЕТ%'` (включително латинското `ET`, разширените форми `ЕДНОЛИЧЕН ТЪРГОВЕЦ`, `SOLE TRADER`, `INDIVIDUAL`) и водещия `ЕТ ` / `ET ` суфикс в името (който вече беше в `isNaturalPersonProfileName`). HTML `noindex` мета тагът, CSV маскирането и JSON маскирането споделят **единствено** този предикат — няма дублирани хардкоднати правила в route-овете.
+2. **`X-Robots-Tag: noindex` на всяка машинно-четима повърхност — централизирано в worker-а.** Заглавието се задава в **единствено едно** място в авторския код: в worker pipeline-а [`hardenResponse`](../../apps/web/workers/app.ts) (в `apps/web/workers/app.ts`), който при финалното обвиване на отговора извиква помощната функция `applyPrivacyMaskHeaders` от [`apps/web/app/lib/security.ts`](../../apps/web/app/lib/security.ts). Route-овете и помощният код само **маркират** отговора си с вътрешния хедър `X-Privacy-Mask: applied` чрез помощната функция `markPrivacyMaskApplied` от същия файл — например [`apps/web/app/routes/contract.json.tsx`](../../apps/web/app/routes/contract.json.tsx) и CSV клоновете на [`apps/web/app/lib/csv-export.ts`](../../apps/web/app/lib/csv-export.ts) я извикват при нужда. `X-Robots-Tag: noindex` се появява в изходящите хедъри на всички техни отговори единствено защото worker-ът го излъчва, а не защото те го пишат. Вътрешният `X-Privacy-Mask` маркер се изтрива в worker-а преди кеширане и преди достигане до клиента (виж [`apps/web/workers/app.ts:62`](../../apps/web/workers/app.ts) и `applyPrivacyMaskHeaders` в `security.ts:86-91`) — никога не изтича към потребителя. Поради тази централизация **всяка бъдеща машинно-четима повърхност** (CSV, JSON, `.data`, нов feed) наследява `X-Robots-Tag: noindex` автоматично, стига route-ът (или helper в `csv-export.ts`) да сложи маркера през `markPrivacyMaskApplied(headers)`. Маркерът е допълнение към `robots.txt` (който вече забраняваше `/*.csv`) — покрива crawler-и и инструменти, които четат HTTP-хедъри, но не и `robots.txt`.
+3. **Маскиране на ЕИК и оригиналното име.** За разпознати естествено лица / еднолични търговци:
+ - **CSV:** `contractor_eik`/`eik` се замества с празен низ, `contractor`/`name` се замества със символа `MASKED_NATURAL_PERSON_LABEL` от [`packages/shared/src/format.ts:197`](../../packages/shared/src/format.ts) (стойност `„Частно лице"` — без запетая/кавичка, за да не се нуждае от CSV-escape).
+ - **JSON:** `bidder.eik` → `null`, `bidder.name` → `MASKED_NATURAL_PERSON_LABEL`, `bidder.displayName` → `MASKED_NATURAL_PERSON_LABEL`, `sourceNames.bidder` → `MASKED_NATURAL_PERSON_LABEL`. Останалите полета (`bidder.slug`, агрегатите `totalContracts`/`totalEur`, `kind`, `settlement`) остават непроменени — `slug`-ът не е PII, а е URL фрагмент.
+4. **Символът `MASKED_NATURAL_PERSON_LABEL` е единичен източник на истината.** Експортиран от `@sigma/shared` (баррелът [`packages/shared/src/index.ts`](../../packages/shared/src/index.ts)). Тестовете го импортират по символ, не по литерален текст — преименуване на етикета (напр. на „Физическо лице") няма да счупи нито един тест.
+5. **Юридическите лица не се пипат.** `legal_form` `АД`/`ООД`/`ЕАД`/`ЕООД` остават видими както в CSV, така и в JSON. Само когато предикатът върне `true` се прилага маската.
+
+## Последствия
+
+- **Кеш политиката остава.** `Cache-Control: public, max-age=3600` за CSV и `public, s-maxage=3600, stale-while-revalidate=86400` за JSON остават непроменени — маскираните байтове са безопасни за кеширане, защото не съдържат естествено-личностни идентификатори. Edge кешът е приоритет за DDoS устойчивостта (ADR-0001 §3) и отпадането му би влошило публичната достъпност на експортите.
+- **R2 CSV кешът се регенерира при първото презареждане след тази промяна.** Ключът на обекта е `csv//` ([`apps/web/app/lib/csv-export.ts`](../../apps/web/app/lib/csv-export.ts)). Следващото bulk презареждане (през `scripts/import.mjs`) инкрементира `freshnessVersion` и новите обекти се записват вече маскирани; старите се презаписват естествено (без миграция).
+- **Брой маскирани редове е равен на броя флагнати от предиката.** Всеки ред в CSV стрийма се оценява независимо от `isNaturalPersonBidder(name, legalForm)`; маскирането е per-row, не per-batch. Контролната проверка за това е в `packages/db/src/queries/contracts.test.ts` (тестът „masks the contractor and clears contractor_eik when legal_form is a sole-trader form (ЕТ)") и в `packages/db/src/queries/companies.test.ts` (тестът „writes MASKED_NATURAL_PERSON_LABEL + empty EIK for an ЕТ row in the rollup branch").
+- **HTML профилът не е засегнат.** `noindex` мета тагът за естествени лица остава непроменен — предикатът е същият, но маркерът вече е в общата логика, не в inline код.
+- **Подаване на документацията надолу по веригата.** В [`apps/web/app/routes/privacy.tsx`](../../apps/web/app/routes/privacy.tsx) е добавена секция `# natural-person-data`, която описва подхода за потребители и журналисти: кои идентификатори се маскират, кои остават, и причината (`noindex` плюс етикет вместо истинското име).
+- **`authorities.csv` получава само `X-Robots-Tag: noindex`.** Възложителите в корпуса винаги имат попълнен ЕИК (публични органи) — не се прилага маскиране на тялото. Маркерът е политическа последователност (еднаква политика за всички CSV), не техническо маскиране.
+- **Стримовете не се рефакторират.** Контрактите `streamContractsCsv`/`streamCompaniesCsv`/`streamAuthoritiesCsv` остават същите; маскирането е вътрешен branch в per-row цикъла, който разчита на новата `b.legal_form` колона в SELECT-а.
+
+## Засегнати повърхности
+
+### Споделена логика
+
+- [`packages/shared/src/format.ts`](../../packages/shared/src/format.ts) — нови `isNaturalPersonBidder(name, legalForm)` и `MASKED_NATURAL_PERSON_LABEL`; запазен `isNaturalPersonProfileName` за backwards compatibility (все още се използва от `streamCompanySitemap`).
+- [`packages/shared/src/format.test.ts`](../../packages/shared/src/format.test.ts) — нови тестове за предиката, етикета и „shared predicate surface" (truth-table блок).
+
+### DB заявки (CSV стрийм + JSON getContract)
+
+- [`packages/db/src/queries/contracts.ts`](../../packages/db/src/queries/contracts.ts) — `streamContractsCsv` проектира `b.legal_form AS bidder_legal_form` и прилага маскиращия branch в per-row цикъла (редове ~436, ~450-453).
+- [`packages/db/src/queries/companies.ts`](../../packages/db/src/queries/companies.ts) — `streamCompaniesCsv` и двата клона на `source()` (`company_totals` rollup и base-aggregation CTE) проектират `b.legal_form`; per-row цикълът маскира `eik`/`name`.
+- [`packages/db/src/queries/details.ts`](../../packages/db/src/queries/details.ts) — `getContract` проектира `b.legal_form AS bidder_legal_form` (съществуващият `JOIN bidders b` се преизползва); `ContractDetailRow` и типът на return-а се разширяват с `bidder_legal_form: string | null`.
+- [`packages/db/src/queries/contracts.test.ts`](../../packages/db/src/queries/contracts.test.ts) — нов `describe('streamContractsCsv masking', ...)` блок (4 теста).
+- [`packages/db/src/queries/companies.test.ts`](../../packages/db/src/queries/companies.test.ts) — нов `describe('streamCompaniesCsv masking', ...)` блок (4 теста).
+
+### Web route-ове
+
+- [`apps/web/app/routes/contract.json.tsx`](../../apps/web/app/routes/contract.json.tsx) — добавя чистия хелпер `maskContractForPrivacy(record, bidderLegalForm): ContractRecord` (модулен export, за тестваемост) и в reference-equality гейта на маскирането извиква `markPrivacyMaskApplied(headers)` от `apps/web/app/lib/security.ts` — служи като вътрешен сигнал за worker-а, не пише `X-Robots-Tag` директно. 404 клонът и `Cache-Control` остават непроменени.
+- [`apps/web/app/routes/contract.json.test.ts`](../../apps/web/app/routes/contract.json.test.ts) — нов тестов файл (3 теста за хелпера + 4 за loader-а, общо 7). Тестовете за loader-а твърдят, че маркерът `X-Privacy-Mask: applied` присъства на маскирания отговор и че `X-Robots-Tag: noindex` **не** присъства на loader output-а — последното е прерогатив на worker-а.
+- [`apps/web/app/routes/company.tsx`](../../apps/web/app/routes/company.tsx) — премахва inline `isSingleNaturalPersonProfile`; HTML `noindex` сега идва от `isNaturalPersonBidder`. За естествено лице loader-ът `mutate`-ва `company.eik = null` и връща `Response.json({...}, { headers: { 'X-Privacy-Mask': 'applied' } })`. `headers()` export-ът препраща `X-Privacy-Mask` от `loaderHeaders`, за да достигне маркерът до worker-а и при HTML отговора (виж по-долу за `.data` близнака).
+- **`.data` близнак на `/companies/:eik`** (React Router v7 single-fetch при `ssr:true`) — споделя същия `loader` от `company.tsx`, така че маскирането и сигнализирането се случват автоматично за `.data` заявките: при естествено лице `company.eik = null` е видимо в turbo-stream payload-а и worker-ът излъчва `X-Robots-Tag: noindex` на отговора (включително в edge-кешираното копие).
+- [`apps/web/app/routes/contracts.csv.tsx`](../../apps/web/app/routes/contracts.csv.tsx), [`apps/web/app/routes/companies.csv.tsx`](../../apps/web/app/routes/companies.csv.tsx), [`apps/web/app/routes/authorities.csv.tsx`](../../apps/web/app/routes/authorities.csv.tsx) — без промяна на кода; маркерът идва от `servedCsvExport`.
+
+### Worker — централизирана точка за прилагане
+
+- [`apps/web/workers/app.ts`](../../apps/web/workers/app.ts), функция `hardenResponse` — **единственото** място в авторския код, което пише `X-Robots-Tag: noindex` в изходящия отговор. Извиква `applyPrivacyMaskHeaders(headers)` от [`apps/web/app/lib/security.ts`](../../apps/web/app/lib/security.ts), което превежда маркера `X-Privacy-Mask: applied` (сложен от който и да е route loader или `csv-export.ts`) в публичния `X-Robots-Tag: noindex` и след това изтрива самия маркер. Поставянето на изтриването в `hardenResponse` гарантира, че вътрешният маркер не достига нито edge кеша (`edgeCache.put(key, hardened.clone())`), нито клиента. Поради тази централизация **всяка бъдеща машинно-четима повърхност** (CSV, JSON, `.data`, нов feed) наследява `X-Robots-Tag: noindex` автоматично, стига route-ът (или helper в `csv-export.ts`) да сложи маркера през `markPrivacyMaskApplied(headers)` — само с едно място за поддръжка на политиката.
+
+### Web помощен код
+
+- [`apps/web/app/lib/security.ts`](../../apps/web/app/lib/security.ts) — двата нови хелпера на политиката: `markPrivacyMaskApplied(headers)` (route-ът го извиква, за да маркира отговора) и `applyPrivacyMaskHeaders(headers)` (worker-ът го извиква, за да преведе маркера в `X-Robots-Tag: noindex` и да го изтрие). Константата `PRIVACY_MASK_APPLIED` (`'applied'`) е литерален тип и е единственият позволен израз за стойност на маркера.
+- [`apps/web/app/lib/csv-export.ts`](../../apps/web/app/lib/csv-export.ts) — `X-Robots-Tag: noindex` **не** се задава тук. На негово място `markCsvCache` и 304 клонът извикват `markPrivacyMaskApplied(headers)` от `security.ts`; политическото решение (кой получава `noindex`) се взема в worker-а. Съществуващите `Cache-Control` и `Content-Disposition` остават.
+- [`apps/web/app/lib/csv-export.test.ts`](../../apps/web/app/lib/csv-export.test.ts) — обновен `describe('servedCsvExport privacy', ...)` блок (тестовете вече твърдят `X-Privacy-Mask: applied` на `response.headers` и **липса** на `X-Robots-Tag` — последното е прерогатив на worker-а).
+
+### Потребителска документация
+
+- [`apps/web/app/routes/privacy.tsx`](../../apps/web/app/routes/privacy.tsx) — нова секция `# natural-person-data` след съществуващия `# data` блок. Обяснява маскирането на естествено-личностни идентификатори и `noindex` политиката в машинно-четимия изход.
+
+## Доказателство
+
+### Тестове — маскиране и noindex (тези файлове са цитирани в success criteria на Issue #173)
+
+| Файл | Тестове | Покритие |
+| --- | --- | --- |
+| [`packages/shared/src/format.test.ts`](../../packages/shared/src/format.test.ts) | `isNaturalPersonBidder`, `MASKED_NATURAL_PERSON_LABEL`, `shared predicate surface — single source of truth for the noindex / masking decision` | Предикатът и етикетът — единственият източник на истината. |
+| [`packages/db/src/queries/contracts.test.ts`](../../packages/db/src/queries/contracts.test.ts) | `streamContractsCsv masking` (4 теста) | Per-row CSV маскиране: ЕТ маскира, ООД запазва, leading-ЕТ хевристика, консорциум ДЗЗД запазва `kind`. |
+| [`packages/db/src/queries/companies.test.ts`](../../packages/db/src/queries/companies.test.ts) | `streamCompaniesCsv masking` (4 теста) | Двата клона на `source()` (rollup + base-aggregation) маскират ЕТ и запазват ООД. |
+| [`apps/web/app/lib/csv-export.test.ts`](../../apps/web/app/lib/csv-export.test.ts) | `servedCsvExport privacy` (5 теста) | `X-Robots-Tag: noindex` на MISS/HIT/dynamic за трите CSV повърхности; тялото съдържа етикета и изключва оригиналното име; `Cache-Control` остава `public, max-age=3600`. |
+| [`apps/web/app/routes/contract.json.test.ts`](../../apps/web/app/routes/contract.json.test.ts) | `maskContractForPrivacy` (3 теста) + `contract.json loader` (4 теста) | Чист хелпер + интеграция през loader-а: ЕТ маскира и получава `noindex`, АД преминава без `noindex`, 404 не е засегнат, `Cache-Control` е `public, s-maxage=3600, stale-while-revalidate=86400`. |
+
+### Финални exit code-ове от `ralph/verification-baseline.md`
+
+Източник: [`ralph/verification-baseline.md`](../../ralph/verification-baseline.md) (секция „Post-edit", записана СЛЕД приключване на T-001 … T-012).
+
+| Команда | Exit code | Резюме |
+| --- | --- | --- |
+| `pnpm typecheck` | 0 | 7 turbo tasks успешни; widened return type на `getContract` и `maskContractForPrivacy` хелперът са ковариантни с предишните форми. |
+| `pnpm --filter @sigma/shared test` | 0 | 2 файла, 42 теста минават (включително новите за `isNaturalPersonBidder`, `MASKED_NATURAL_PERSON_LABEL` и shared-predicate surface). |
+| `pnpm --filter @sigma/web test` | 0 | 31 файла, 296 теста минават (включително новите 5 в `csv-export.test.ts` и новите 7 в `contract.json.test.ts`). |
+| `pnpm --filter @sigma/db test` | 1 | 25 файла, 174 теста, 171 минават, 3 **предварително съществуващи** failure-а (не са въведени от тази промяна): `integrity-checks.test.ts` (rollup-reconciliation) и два timeout-а в `refresh-slice.test.ts`. Маскиращите файлове (`contracts.test.ts`, `companies.test.ts`) минават изцяло. |
+| `pnpm lint` | 1 | 5 **предварително съществуващи** prettier warning-а във файлове извън обхвата (`RiskIndicators.tsx`, `riskLogic.test.ts`, `companies.test.ts`, `companies.ts`, `contract.json.test.ts`). `docs/architecture.md` е в `.prettierignore`. |
+
+Нетният delta спрямо чистия baseline (преди прилагане на T-001 … T-012): **+11 нови теста, 0 нови failure-а** (преди: 160 passed / 3 failed; след: 171 passed / 3 failed). Промяната не влошава нито един предварително съществуващ failure, както изисква success criteria.
+
+### Оперативна бележка
+
+При първото bulk презареждане след deploy-а на тази промяна R2 обектите под `csv//` ще бъдат презаписани с вече маскирани байтове. До този момент — ако някой направи `wrangler r2 object get` — може да види оригиналните CSV файлове в кеша. Това е приемливо защото (1) файловете са в `robots.txt` + `X-Robots-Tag: noindex` и не се индексират, и (2) следващото презареждане ще ги подмени естествено.
\ No newline at end of file
diff --git a/docs/adr/0003-centralized-x-robots-tag-worker.md b/docs/adr/0008-centralized-x-robots-tag-worker.md
similarity index 93%
rename from docs/adr/0003-centralized-x-robots-tag-worker.md
rename to docs/adr/0008-centralized-x-robots-tag-worker.md
index 3543b1363..9038dd8c2 100644
--- a/docs/adr/0003-centralized-x-robots-tag-worker.md
+++ b/docs/adr/0008-centralized-x-robots-tag-worker.md
@@ -1,13 +1,13 @@
-# ADR-0003 — Централизирано авторство на `X-Robots-Tag: noindex` в worker-а
+# ADR-0008 — Централизирано авторство на `X-Robots-Tag: noindex` в worker-а
- **Дата:** 2026-07-02
- **Статус:** Прието
-- **Свързани:** [ADR-0002](../architecture.md#adr-0002--политика-за-поверителност-на-машинно-четим-изход-issue-173) (политиката *noindex + маскиране*), [Issue #173](https://github.com/midt-bg/sigma/issues/173), PR #183 (review fixes)
-- **Обхват:** Механизма, по който се прилага публичният хедър `X-Robots-Tag: noindex` върху всички машинно-четими повърхности (JSON, CSV, `.data` twin на React Router v7 single-fetch). Не променя продуктовата политика от ADR-0002 — само нейното инженерно реализиране.
+- **Свързани:** [ADR-0007](0007-privacy-masking.md) (политиката *noindex + маскиране*), [Issue #173](https://github.com/midt-bg/sigma/issues/173), PR #183 (review fixes)
+- **Обхват:** Механизма, по който се прилага публичният хедър `X-Robots-Tag: noindex` върху всички машинно-четими повърхности (JSON, CSV, `.data` twin на React Router v7 single-fetch). Не променя продуктовата политика от ADR-0007 — само нейното инженерно реализиране.
## Контекст
-ADR-0002 задължава всяка машинно-четима повърхност да носи `X-Robots-Tag: noindex`. Първоначалната имплементация пишеше хедъра **inline на всяко място**, което го изпращаше:
+ADR-0007 задължава всяка машинно-четима повърхност да носи `X-Robots-Tag: noindex`. Първоначалната имплементация пишеше хедъра **inline на всяко място**, което го изпращаше:
- в loader-а на [`apps/web/app/routes/contract.json.tsx`](../../apps/web/app/routes/contract.json.tsx);
- в четирите клона на [`apps/web/app/lib/csv-export.ts`](../../apps/web/app/lib/csv-export.ts) (`markCsvCache`, динамичният `Response`, 200/206 `Response`, 304 `Response`).
@@ -35,7 +35,7 @@ ADR-0002 задължава всяка машинно-четима повърх
- **Една точка за поддръжка.** Всяка бъдеща машинно-четима повърхност (нов feed, нов route) наследява `X-Robots-Tag: noindex` автоматично, стига да извика `markPrivacyMaskApplied(headers)`. Няма как да се „забрави" хедърът на нова повърхност, която вече маркира — макар че добавянето на нова повърхност изисква умишлено поставяне на маркера.
- **Маркерът не изтича.** Изтриването в `hardenResponse` е defensive: дори повторно/идемпотентно извикване оставя вече сложен `X-Robots-Tag` недокоснат и никога не пропуска `X-Privacy-Mask` навън.
- **Разделени тестове.** Loader/helper тестовете твърдят присъствие на маркера **и отсъствие** на `X-Robots-Tag` (последното е прерогатив на worker-а); worker/CSP тестовете твърдят превода. Виж [`security.test.ts`](../../apps/web/app/lib/security.test.ts), [`contract.json.test.ts`](../../apps/web/app/routes/contract.json.test.ts), [`csv-export.test.ts`](../../apps/web/app/lib/csv-export.test.ts), [`company.data.test.ts`](../../apps/web/app/routes/company.data.test.ts) и [`app.nofollow.test.ts`](../../apps/web/workers/app.nofollow.test.ts).
-- **`authorities.csv`** получава `X-Robots-Tag: noindex` (маркира се), но без маскиране на тялото — възложителите винаги имат попълнен ЕИК (публични органи). Политическа последователност, не техническо маскиране (както и в ADR-0002).
+- **`authorities.csv`** получава `X-Robots-Tag: noindex` (маркира се), но без маскиране на тялото — възложителите винаги имат попълнен ЕИК (публични органи). Политическа последователност, не техническо маскиране (както и в ADR-0007).
## Засегнати повърхности
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 697a92014..81eddb029 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -14,4 +14,6 @@
| [0004](0004-style-src-unsafe-inline.md) | `style-src` запазва `'unsafe-inline'` (CSP) | Прието |
| [0005](0005-blue-green-d1-rollback.md) | Blue/green D1 слотове за rollback на refresh | Прието |
| [0006](0006-eop-wins-dedup.md) | Dedup на два източника: EOP печели по `contract_number` | Прието |
+| [0007](0007-privacy-masking.md) | `noindex` + маскиране на естествено-личностни идентификатори в машинно-четим изход (Issue #173) | Прието |
+| [0008](0008-centralized-x-robots-tag-worker.md) | Централизирано авторство на `X-Robots-Tag: noindex` в worker-а (маркерният договор) | Прието |
| [0029](0029-worker-native-fx-load.md) | Worker-native FX зареждане в cron refresh-а и режим на отказ | Прието |
diff --git a/docs/architecture.md b/docs/architecture.md
index 8c7e0626a..429f3b28a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,189 +1,54 @@
-# ADR-0001 — Стратегия за рендиране на front-end-а и модел на сигурност
+# Архитектура
-- **Статус:** Прието
-- **Обхват:** Итерация 1 — публичният, read-only explorer на обществените поръчки (АОП). Документът отбелязва и пътя напред към по-широката платформа (оценка на риска, аномалии, картелен анализ — roadmap).
+Преглед на системата на едно място и указател към записаните решения. **Архитектурните решения
+живеят като ADR в [`adr/`](adr/README.md)** — този файл е картата, не източникът на истина за
+отделните решения.
-> **Актуализация (2026-05-21):** Решението за фреймуърк по-долу беше променено от SvelteKit на **React Router v7 върху Cloudflare Workers** след претегляне на екосистемата, набирането на хора и контрибутори, primitives за достъпност и екосистемата за визуализации/AI. Решенията за рендиране (§2) и за сигурност (§3) са независими от фреймуърка и остават непроменени; обновени бяха само техните специфични за фреймуърка механики.
+## Поток на данните
-## Контекст
+```
+ЦАИС ЕОП (storage.eop.bg) ──bulk/cron──▶ sigma-etl (Worker + Workflow)
+ │ load → normalize → derive → precompute
+ ▼
+ D1 (една база на среда)
+ ▲
+ │ read-only
+ граждани / журналисти ──▶ sigma (RR v7 SSR Worker)
+```
-Итерация 1 е **публичен, read-only** отчетен и визуализационен слой върху ~129 хил. реда договори от АОП в Cloudflare D1, насочен към граждани, журналисти и НПО, с интерфейс на български. Уеб приложението първоначално беше скелетирано върху **SvelteKit**; този ADR преразглежда този избор. Сървърната среда е **Cloudflare Workers**, които четат D1. Данните пристигат като **периодични bulk зареждания** (`scripts/import.mjs`), а не като жива емисия; в тази итерация **няма публичен write път и няма автентикация**.
+Данните пристигат като **периодични bulk зареждания**, не като жива емисия. ETL-ът пише в D1; explorer-ът
+чете от **същия** D1. В v1 няма публичен write път и няма автентикация.
-Три въпроса стояха в основата на решението:
+## Двата Worker-а
-1. Трябва ли front-end-ът да премине от Svelte към React?
-2. Трябва ли страниците да се рендират на сървъра (SSR), да се пре-рендират (статично), или да са класическо клиентско SPA срещу API?
-3. Променят ли отговора атаките през SSR / „hydration“?
+| Worker | Роля |
+| ---------- | ----------------------------------------------------------------- |
+| `sigma` | SSR explorer (`apps/web`) — чете D1 директно, edge-кешира HTML. |
+| `sigma-etl`| cron-задействан refresh Workflow (`apps/etl`) — пише в D1. |
-## Решение
+## Рендиране и сигурност (резюме)
-### 1. React с React Router v7 (framework mode) върху Cloudflare Workers
+- **Рендиране:** хибридно според повърхността — пре-рендиране за статичните страници, SSR + edge
+ кеш за обемните детайлни/explorer страници, клиентски „острови" за интерактивните визуализации.
+- **Сигурност:** приоритет интегритет ≈ наличност ≫ конфиденциалност (данните са публични). Read
+ пътят остава read-only; строг CSP + security headers; D1 само с bound параметри.
-Избран пред първоначалния SvelteKit скелет след претегляне на дълготрайните фактори:
+Пълната обосновка и приетите компромиси — [ADR-0001](adr/0001-rendering-and-security.md).
-- **Екосистема и преизползване** — най-голямата екосистема от компоненти/библиотеки и най-богатите опции точно за повърхностите, върху които СИГМА стъпва: мрежови графи на връзки (`@xyflow/react`), таблици за големи данни (TanStack Table / AG Grid), графики (visx / Recharts), а за по-късния асистент — AI SDK (`@ai-sdk/react`) и зрели UI-та за асистенти.
-- **Набиране на хора и open-source контрибутори** — значително по-голям talent pool; релевантно за вероятно OSS гражданска платформа („Лицензът ще бъде определен преди публичното пускане“).
-- **Primitives за достъпност** — React Aria / Radix са сред най-добрите в класа си за изричната цел **WCAG 2.2 AA**.
-- **Дълготрайност** — най-защитимият дългосрочен избор за дълголетна платформа от обществена полза.
+## Решения (ADR)
-**React Router v7 (framework mode), а не Next.js** — той върви чисто върху **Cloudflare Workers** (през `@cloudflare/vite-plugin`) и запазва модела SSR-на-edge и хибридното рендиране от §2. Приети компромиси спрямо SvelteKit: по-голям клиентски runtime (смекчен от SSR + кеширане) и липса на first-party CF *Pages* адаптер — RR7 се деплойва като Worker, което освен това уеднаквява `apps/web` с останалите `apps/*`.
+Виж индекса в [`adr/README.md`](adr/README.md). Ключовите за v1: рендиране/сигурност (0001),
+D1 като хранилище (0002), `value_flag` стойностна база (0003), CSP `style-src` (0004), blue/green
+rollback (0005), dedup на двата източника (0006), [`noindex` + маскиране на естествено-личностни
+идентификатори в машинно-четим изход (0007)](adr/0007-privacy-masking.md) и
+[централизирано авторство на `X-Robots-Tag: noindex` в worker-а (0008)](adr/0008-centralized-x-robots-tag-worker.md).
-### 2. Рендиране: хибридно, избрано според повърхността
+## Справочни документи
-По подразбиране **SSR + edge кеширане**; пре-рендиране само за реално статичните страници; клиентско рендиране само за интерактивните „острови“. **Не** чисто SPA за публично съдържание (SEO/споделяемост + цена на първото изрисуване); **не** build-time пре-рендиране на целия корпус (десетки хиляди страници за компании/институции, а търсенето изобщо не може да се пре-рендира).
-
-| Повърхност | Стратегия | Защо |
-| --- | --- | --- |
-| Начало, За проекта, **методология / „как работят червените флагове“**, документация за отворените данни | Пре-рендиране (статично) | Рядко се променят → надеждно + безплатно |
-| Профил на компания (по ЕИК), страница на институция, детайл на поръчка/обособена позиция | **SSR + edge кеш** (`s-maxage` + `stale-while-revalidate`), purge при презареждане на набора данни | Голям обем, променя се само при bulk презареждане → скорост като при статика + устойчивост на DDoS, актуалност след презареждане |
-| Explorer: търсене / филтри / класации („най-големи бенефициенти“) | SSR, кратък кеш, варира според заявката | Безкрайно URL пространство; задвижван от SQL |
-| Интерактивни визуализации (граф на мрежа/потоци, карта на България, сортируеми таблици) | Клиентски рендиран **остров**, хидриран върху SSR-ната страница + първоначални данни | Нуждае се от клиентска интерактивност; SSR на обвивката заради SEO/първо изрисуване |
-
-Тъй като наборът данни е периодична снимка (а не жив), кеш TTL-ите може да са дълги, а едно презареждане просто **purge-ва** кеша — почти всички предимства на статиката, без да се изброява корпусът при build.
-
-### 3. Модел на сигурност
-
-Приоритети за публичен сайт за прозрачност: **интегритет ≈ наличност ≫ конфиденциалност** — данните са публични по дизайн; тяхната *достоверност* и *uptime* са продуктът.
-
-**Контроли в итерация 1:**
-
-- **Read път да остане read-only.** Ingestion-ът е офлайн (`scripts/import.mjs`); няма публичен write endpoint. Това се запазва — без публична мутация, без админ в итерация 1.
-- **Edge модел + кеширане като поглъщане на DDoS.** Cloudflare поглъща L3/4; кешираният HTML означава, че атакуващ / scrape трафик удря кеша, а не D1.
-- **WAF + rate limiting** върху задвижвания от SQL explorer/търсене и върху всеки export на отворени данни; ограничаване на `limit` / пагинация / размер на export-а, така че нито една заявка да не сканира цялата таблица.
-- **Строг CSP + security headers** — генериране на per-request nonce в `entry.server.tsx`, подаван към `` / `` на React Router и към header-а `Content-Security-Policy` (за да са разрешени hydration скриптовете на фреймуърка без `unsafe-inline`). Добавяне на HSTS, `X-Content-Type-Options`, `Referrer-Policy`.
-- **D1 prepared statements само с bound параметри**; никога конкатениране на SQL чрез низове.
-
-**По въпроса за „hydration атаките“** — реалните класове и как този дизайн ги адресира:
-
-- **Изтичане между потребители през кеширан персонализиран SSR** — неприложимо в итерация 1 (без автентикация; всяка страница е анонимна и идентична → безопасна за кеширане). Остава предотвратено и по-късно, като се кешират *само* анонимни публични страници и никога автентикираните.
-- **Свръх-сериализиране на тайни в hydration payload-а** — смекчено чрез типизираните response DTO-та в `packages/api-contract` (отдават проекции на същностите, никога сурови редове) и чрез държане на код, носещ credentials, извън уеб/read пътя. DTO-тата да остават без вътрешни-само полета.
-- **XSS през сериализирано състояние / рендирани данни** — разчита се на авто-escaping-а на React/JSX; **никога `dangerouslySetInnerHTML`** върху текст от АОП (институция / компания / предмет са от външен източник); строгият CSP е резервната защита.
-
-**Отложено за по-късни фази** (повърхностите на по-широката платформа):
-
-- **Workflow-и за възложители/участници + админ** → Cloudflare Access (SSO + MFA) за админ; строго разделяне на публичен/персонализиран кеш; изолация на write пътя в отделен worker.
-- **AI асистент за обществени поръчки** → маршрутизиране през **AI Gateway** (лимити за rate/разход, кеширане, логване); read-only, параметризирани инструменти; заземяване на всяко твърдение в изчислени данни (риск от клевета); никакво кеширане; рендиране на изхода като текст. Спецификация — [`spec/ai-assistant.md`](spec/ai-assistant.md).
-
-## Последствия / scaffold follow-up-и
-
-- Конфигуриране на пре-рендирането в `react-router.config.ts` (`prerender` пътища) за статичните информационни route-ове.
-- Задаване на `Cache-Control` (`s-maxage` + `stale-while-revalidate`) през `headers` export-ите на route-овете при SSR data route-овете; иницииране на cache purge (по URL/таг) в края на скрипта за зареждане на данните.
-- Задаване на CSP + security headers в `entry.server.tsx` (или Worker middleware) с per-request nonce върху ``.
-- Добавяне на Cloudflare rate-limit правило + WAF managed ruleset за explorer/export endpoint-ите (infra конфигурация, проследявана отделно).
-- Премахване или auth-gate на отворения `POST /etl/run` от скелета в `apps/etl` преди какъвто и да е деплой.
-
-## Свързани документи
-
-- [`etl.md`](etl.md) — ETL pipeline-ът и емисията от ЦАИС ЕОП.
-- [`deploy.md`](deploy.md) — деплой към Cloudflare и оперативна сигурност.
-- [`spec/ai-assistant.md`](spec/ai-assistant.md) — спецификация на планирания AI асистент.
-- [`README.md`](../README.md) на репозиторието и [`../AGENTS.md`](../AGENTS.md) — общ преглед и работни конвенции.
-
-# ADR-0002 — Политика за поверителност на машинно-четим изход (Issue #173)
-
-- **Статус:** Прието
-- **Обхват:** Повърхностите на СИГМА, които връщат машинно-четимо тяло — JSON записите на договорите (`/contracts/:id.json`) и CSV експортите (`/contracts.csv`, `/companies.csv`, `/authorities.csv`). Не засяга HTML рендирането на профилите, което вече имаше собствен `noindex` път.
-- **Препратки:** [Issue #173](https://github.com/midt-bg/sigma/issues/173) · [`apps/web/app/routes/privacy.tsx`](../apps/web/app/routes/privacy.tsx) (`# natural-person-data`) · ADR-0001 §3 (модел на сигурност)
-- **Виж също:** [ADR-0003](adr/0003-centralized-x-robots-tag-worker.md) — инженерното решение за централизирано авторство на `X-Robots-Tag: noindex` в worker-а (рефакторът от PR #183 review) и [`docs/privacy-masking.md`](privacy-masking.md) за оперативно ръководство.
-
-> Решението за маскиране на идентификаторите на едноличните търговци и физическите лица в машинно-четимия изход е продуктово-политическо и инженерно, не правно. GDPR и ЗЗЛД се третират като контекст за вземане на решение, не като юридическо тълкуване — окончателната оценка е задължение на поддържащите проекта.
-
-## Контекст
-
-Итерация 1 на СИГМА публикува идентификатори на изпълнителите на обществени поръчки в три форми: (1) HTML профили на фирми (по ЕИК), (2) JSON запис на договора (`/contracts/:id.json`) за връзка от HTML-а, и (3) CSV експорти (`/contracts.csv`, `/companies.csv`, `/authorities.csv`) за журналисти и изследователи.
-
-HTML профилът на фирма вече прилагаше `noindex` мета таг за записи, които се разпознават като едноличен търговец или физическо лице (`isSingleNaturalPersonProfile` в [`apps/web/app/routes/company.tsx`](../apps/web/app/routes/company.tsx) — *inline*, преди ADR-0002). Логиката имаше две слабости:
-
-1. **Дупка между HTML и машинно-четим изход.** Търсачките и бот-индексаторите, които не изпълняват HTML мета таговете, можеха да достигнат до JSON и CSV записите и да индексират същите естествено-личностни идентификатори (ЕИК, пълно име на ЕТ). Това противоречи на политиката `noindex` от страна на HTML.
-2. **Дублирана логика на откриване.** Разпознаването „ЕТ …" беше вградено в route-а като `isSingleNaturalPersonProfile` (inline), а друга негова разновидност (`isNaturalPersonProfileName`) живееше в [`packages/shared/src/format.ts`](../packages/shared/src/format.ts). Без единен предикат всяка нова машинно-четима повърхност щеше да копира правилата, а при разминаване на копията — да изтече идентификатор.
-
-Допълнителен фактор: edge кешът на CSV отговорите (`Cache-Control: public, max-age=3600`) съдържа пълните байтове на стрийма. Ако едно и също тяло се сервираше и за юридически лица, и за ЕТ без маскиране, кешът щеше да замрази изтичането — което означава, че маскирането трябва да се случи **преди** записа в R2.
-
-## Решение
-
-Приета е политика **`noindex` плюс маскиране**, с общ предикат от [`packages/shared/src/format.ts`](../packages/shared/src/format.ts):
-
-1. **Единен предикат.** Премахваме inline `isSingleNaturalPersonProfile` от `apps/web/app/routes/company.tsx` и заменяме с `isNaturalPersonBidder(name, legalForm)` от `packages/shared/src/format.ts`. Предикатът комбинира два сигнала — `legal_form LIKE 'ЕТ%'` (включително латинското `ET`, разширените форми `ЕДНОЛИЧЕН ТЪРГОВЕЦ`, `SOLE TRADER`, `INDIVIDUAL`) и водещия `ЕТ ` / `ET ` суфикс в името (който вече беше в `isNaturalPersonProfileName`). HTML `noindex` мета тагът, CSV маскирането и JSON маскирането споделят **единствено** този предикат — няма дублирани хардкоднати правила в route-овете.
-2. **`X-Robots-Tag: noindex` на всяка машинно-четима повърхност — централизирано в worker-а.** Заглавието се задава в **единствено едно** място в авторския код: в worker pipeline-а [`hardenResponse`](../../apps/web/workers/app.ts) (в `apps/web/workers/app.ts`), който при финалното обвиване на отговора извиква помощната функция `applyPrivacyMaskHeaders` от [`apps/web/app/lib/security.ts`](../../apps/web/app/lib/security.ts). Route-овете и помощният код само **маркират** отговора си с вътрешния хедър `X-Privacy-Mask: applied` чрез помощната функция `markPrivacyMaskApplied` от същия файл — например [`apps/web/app/routes/contract.json.tsx`](../apps/web/app/routes/contract.json.tsx) и CSV клоновете на [`apps/web/app/lib/csv-export.ts`](../apps/web/app/lib/csv-export.ts) я извикват при нужда. `X-Robots-Tag: noindex` се появява в изходящите хедъри на всички техни отговори единствено защото worker-ът го излъчва, а не защото те го пишат. Вътрешният `X-Privacy-Mask` маркер се изтрива в worker-а преди кеширане и преди достигане до клиента (виж [`apps/web/workers/app.ts:62`](../../apps/web/workers/app.ts) и `applyPrivacyMaskHeaders` в `security.ts:86-91`) — никога не изтича към потребителя. Поради тази централизация **всяка бъдеща машинно-четима повърхност** (CSV, JSON, `.data`, нов feed) наследява `X-Robots-Tag: noindex` автоматично, стига route-ът (или helper в `csv-export.ts`) да сложи маркера през `markPrivacyMaskApplied(headers)`. Маркерът е допълнение към `robots.txt` (който вече забраняваше `/*.csv`) — покрива crawler-и и инструменти, които четат HTTP-хедъри, но не и `robots.txt`.
-3. **Маскиране на ЕИК и оригиналното име.** За разпознати естествено лица / еднолични търговци:
- - **CSV:** `contractor_eik`/`eik` се замества с празен низ, `contractor`/`name` се замества със символа `MASKED_NATURAL_PERSON_LABEL` от [`packages/shared/src/format.ts:197`](../packages/shared/src/format.ts) (стойност `„Частно лице"` — без запетая/кавичка, за да не се нуждае от CSV-escape).
- - **JSON:** `bidder.eik` → `null`, `bidder.name` → `MASKED_NATURAL_PERSON_LABEL`, `bidder.displayName` → `MASKED_NATURAL_PERSON_LABEL`, `sourceNames.bidder` → `MASKED_NATURAL_PERSON_LABEL`. Останалите полета (`bidder.slug`, агрегатите `totalContracts`/`totalEur`, `kind`, `settlement`) остават непроменени — `slug`-ът не е PII, а е URL фрагмент.
-4. **Символът `MASKED_NATURAL_PERSON_LABEL` е единичен източник на истината.** Експортиран от `@sigma/shared` (баррелът [`packages/shared/src/index.ts`](../packages/shared/src/index.ts)). Тестовете го импортират по символ, не по литерален текст — преименуване на етикета (напр. на „Физическо лице") няма да счупи нито един тест.
-5. **Юридическите лица не се пипат.** `legal_form` `АД`/`ООД`/`ЕАД`/`ЕООД` остават видими както в CSV, така и в JSON. Само когато предикатът върне `true` се прилага маската.
-
-## Последствия
-
-- **Кеш политиката остава.** `Cache-Control: public, max-age=3600` за CSV и `public, s-maxage=3600, stale-while-revalidate=86400` за JSON остават непроменени — маскираните байтове са безопасни за кеширане, защото не съдържат естествено-личностни идентификатори. Edge кешът е приоритет за DDoS устойчивостта (ADR-0001 §3) и отпадането му би влошило публичната достъпност на експортите.
-- **R2 CSV кешът се регенерира при първото презареждане след тази промяна.** Ключът на обекта е `csv//` ([`apps/web/app/lib/csv-export.ts`](../apps/web/app/lib/csv-export.ts)). Следващото bulk презареждане (през `scripts/import.mjs`) инкрементира `freshnessVersion` и новите обекти се записват вече маскирани; старите се презаписват естествено (без миграция).
-- **Брой маскирани редове е равен на броя флагнати от предиката.** Всеки ред в CSV стрийма се оценява независимо от `isNaturalPersonBidder(name, legalForm)`; маскирането е per-row, не per-batch. Контролната проверка за това е в `packages/db/src/queries/contracts.test.ts` (тестът „masks the contractor and clears contractor_eik when legal_form is a sole-trader form (ЕТ)") и в `packages/db/src/queries/companies.test.ts` (тестът „writes MASKED_NATURAL_PERSON_LABEL + empty EIK for an ЕТ row in the rollup branch").
-- **HTML профилът не е засегнат.** `noindex` мета тагът за естествени лица остава непроменен — предикатът е същият, но маркерът вече е в общата логика, не в inline код.
-- **Подаване на документацията надолу по веригата.** В [`apps/web/app/routes/privacy.tsx`](../apps/web/app/routes/privacy.tsx) е добавена секция `# natural-person-data`, която описва подхода за потребители и журналисти: кои идентификатори се маскират, кои остават, и причината (`noindex` плюс етикет вместо истинското име).
-- **`authorities.csv` получава само `X-Robots-Tag: noindex`.** Възложителите в корпуса винаги имат попълнен ЕИК (публични органи) — не се прилага маскиране на тялото. Маркерът е политическа последователност (еднаква политика за всички CSV), не техническо маскиране.
-- **Стримовете не се рефакторират.** Контрактите `streamContractsCsv`/`streamCompaniesCsv`/`streamAuthoritiesCsv` остават същите; маскирането е вътрешен branch в per-row цикъла, който разчита на новата `b.legal_form` колона в SELECT-а.
-
-## Засегнати повърхности
-
-### Споделена логика
-
-- [`packages/shared/src/format.ts`](../packages/shared/src/format.ts) — нови `isNaturalPersonBidder(name, legalForm)` и `MASKED_NATURAL_PERSON_LABEL`; запазен `isNaturalPersonProfileName` за backwards compatibility (все още се използва от `streamCompanySitemap`).
-- [`packages/shared/src/format.test.ts`](../packages/shared/src/format.test.ts) — нови тестове за предиката, етикета и „shared predicate surface" (truth-table блок).
-
-### DB заявки (CSV стрийм + JSON getContract)
-
-- [`packages/db/src/queries/contracts.ts`](../packages/db/src/queries/contracts.ts) — `streamContractsCsv` проектира `b.legal_form AS bidder_legal_form` и прилага маскиращия branch в per-row цикъла (редове ~436, ~450-453).
-- [`packages/db/src/queries/companies.ts`](../packages/db/src/queries/companies.ts) — `streamCompaniesCsv` и двата клона на `source()` (`company_totals` rollup и base-aggregation CTE) проектират `b.legal_form`; per-row цикълът маскира `eik`/`name`.
-- [`packages/db/src/queries/details.ts`](../packages/db/src/queries/details.ts) — `getContract` проектира `b.legal_form AS bidder_legal_form` (съществуващият `JOIN bidders b` се преизползва); `ContractDetailRow` и типът на return-а се разширяват с `bidder_legal_form: string | null`.
-- [`packages/db/src/queries/contracts.test.ts`](../packages/db/src/queries/contracts.test.ts) — нов `describe('streamContractsCsv masking', ...)` блок (4 теста).
-- [`packages/db/src/queries/companies.test.ts`](../packages/db/src/queries/companies.test.ts) — нов `describe('streamCompaniesCsv masking', ...)` блок (4 теста).
-
-### Web route-ове
-
-- [`apps/web/app/routes/contract.json.tsx`](../apps/web/app/routes/contract.json.tsx) — добавя чистия хелпер `maskContractForPrivacy(record, bidderLegalForm): ContractRecord` (модулен export, за тестваемост) и в reference-equality гейта на маскирането извиква `markPrivacyMaskApplied(headers)` от `apps/web/app/lib/security.ts` — служи като вътрешен сигнал за worker-а, не пише `X-Robots-Tag` директно. 404 клонът и `Cache-Control` остават непроменени.
-- [`apps/web/app/routes/contract.json.test.ts`](../apps/web/app/routes/contract.json.test.ts) — нов тестов файл (3 теста за хелпера + 4 за loader-а, общо 7). Тестовете за loader-а твърдят, че маркерът `X-Privacy-Mask: applied` присъства на маскирания отговор и че `X-Robots-Tag: noindex` **не** присъства на loader output-а — последното е прерогатив на worker-а.
-- [`apps/web/app/routes/company.tsx`](../apps/web/app/routes/company.tsx) — премахва inline `isSingleNaturalPersonProfile`; HTML `noindex` сега идва от `isNaturalPersonBidder`. За естествено лице loader-ът `mutate`-ва `company.eik = null` и връща `Response.json({...}, { headers: { 'X-Privacy-Mask': 'applied' } })`. `headers()` export-ът препраща `X-Privacy-Mask` от `loaderHeaders`, за да достигне маркерът до worker-а и при HTML отговора (виж по-долу за `.data` близнака).
-- **`.data` близнак на `/companies/:eik`** (React Router v7 single-fetch при `ssr:true`) — споделя същия `loader` от `company.tsx`, така че маскирането и сигнализирането се случват автоматично за `.data` заявките: при естествено лице `company.eik = null` е видимо в turbo-stream payload-а и worker-ът излъчва `X-Robots-Tag: noindex` на отговора (включително в edge-кешираното копие).
-- [`apps/web/app/routes/contracts.csv.tsx`](../apps/web/app/routes/contracts.csv.tsx), [`apps/web/app/routes/companies.csv.tsx`](../apps/web/app/routes/companies.csv.tsx), [`apps/web/app/routes/authorities.csv.tsx`](../apps/web/app/routes/authorities.csv.tsx) — без промяна на кода; маркерът идва от `servedCsvExport`.
-
-### Worker — централизирана точка за прилагане
-
-- [`apps/web/workers/app.ts`](../apps/web/workers/app.ts), функция `hardenResponse` — **единственото** място в авторския код, което пише `X-Robots-Tag: noindex` в изходящия отговор. Извиква `applyPrivacyMaskHeaders(headers)` от [`apps/web/app/lib/security.ts`](../apps/web/app/lib/security.ts), което превежда маркера `X-Privacy-Mask: applied` (сложен от който и да е route loader или `csv-export.ts`) в публичния `X-Robots-Tag: noindex` и след това изтрива самия маркер. Поставянето на изтриването в `hardenResponse` гарантира, че вътрешният маркер не достига нито edge кеша (`edgeCache.put(key, hardened.clone())`), нито клиента. Поради тази централизация **всяка бъдеща машинно-четима повърхност** (CSV, JSON, `.data`, нов feed) наследява `X-Robots-Tag: noindex` автоматично, стига route-ът (или helper в `csv-export.ts`) да сложи маркера през `markPrivacyMaskApplied(headers)` — само с едно място за поддръжка на политиката.
-
-### Web помощен код
-
-- [`apps/web/app/lib/security.ts`](../apps/web/app/lib/security.ts) — двата нови хелпера на политиката: `markPrivacyMaskApplied(headers)` (route-ът го извиква, за да маркира отговора) и `applyPrivacyMaskHeaders(headers)` (worker-ът го извиква, за да преведе маркера в `X-Robots-Tag: noindex` и да го изтрие). Константата `PRIVACY_MASK_APPLIED` (`'applied'`) е литерален тип и е единственият позволен израз за стойност на маркера.
-- [`apps/web/app/lib/csv-export.ts`](../apps/web/app/lib/csv-export.ts) — `X-Robots-Tag: noindex` **не** се задава тук. На негово място `markCsvCache` и 304 клонът извикват `markPrivacyMaskApplied(headers)` от `security.ts`; политическото решение (кой получава `noindex`) се взема в worker-а. Съществуващите `Cache-Control` и `Content-Disposition` остават.
-- [`apps/web/app/lib/csv-export.test.ts`](../apps/web/app/lib/csv-export.test.ts) — обновен `describe('servedCsvExport privacy', ...)` блок (тестовете вече твърдят `X-Privacy-Mask: applied` на `response.headers` и **липса** на `X-Robots-Tag` — последното е прерогатив на worker-а).
-
-### Потребителска документация
-
-- [`apps/web/app/routes/privacy.tsx`](../apps/web/app/routes/privacy.tsx) — нова секция `# natural-person-data` след съществуващия `# data` блок. Обяснява маскирането на естествено-личностни идентификатори и `noindex` политиката в машинно-четимия изход.
-
-## Доказателство
-
-### Тестове — маскиране и noindex (тези файлове са цитирани в success criteria на Issue #173)
-
-| Файл | Тестове | Покритие |
-| --- | --- | --- |
-| [`packages/shared/src/format.test.ts`](../packages/shared/src/format.test.ts) | `isNaturalPersonBidder`, `MASKED_NATURAL_PERSON_LABEL`, `shared predicate surface — single source of truth for the noindex / masking decision` | Предикатът и етикетът — единственият източник на истината. |
-| [`packages/db/src/queries/contracts.test.ts`](../packages/db/src/queries/contracts.test.ts) | `streamContractsCsv masking` (4 теста) | Per-row CSV маскиране: ЕТ маскира, ООД запазва, leading-ЕТ хевристика, консорциум ДЗЗД запазва `kind`. |
-| [`packages/db/src/queries/companies.test.ts`](../packages/db/src/queries/companies.test.ts) | `streamCompaniesCsv masking` (4 теста) | Двата клона на `source()` (rollup + base-aggregation) маскират ЕТ и запазват ООД. |
-| [`apps/web/app/lib/csv-export.test.ts`](../apps/web/app/lib/csv-export.test.ts) | `servedCsvExport privacy` (5 теста) | `X-Robots-Tag: noindex` на MISS/HIT/dynamic за трите CSV повърхности; тялото съдържа етикета и изключва оригиналното име; `Cache-Control` остава `public, max-age=3600`. |
-| [`apps/web/app/routes/contract.json.test.ts`](../apps/web/app/routes/contract.json.test.ts) | `maskContractForPrivacy` (3 теста) + `contract.json loader` (4 теста) | Чист хелпер + интеграция през loader-а: ЕТ маскира и получава `noindex`, АД преминава без `noindex`, 404 не е засегнат, `Cache-Control` е `public, s-maxage=3600, stale-while-revalidate=86400`. |
-
-### Финални exit code-ове от `ralph/verification-baseline.md`
-
-Източник: [`ralph/verification-baseline.md`](../ralph/verification-baseline.md) (секция „Post-edit", записана СЛЕД приключване на T-001 … T-012).
-
-| Команда | Exit code | Резюме |
-| --- | --- | --- |
-| `pnpm typecheck` | 0 | 7 turbo tasks успешни; widened return type на `getContract` и `maskContractForPrivacy` хелперът са ковариантни с предишните форми. |
-| `pnpm --filter @sigma/shared test` | 0 | 2 файла, 42 теста минават (включително новите за `isNaturalPersonBidder`, `MASKED_NATURAL_PERSON_LABEL` и shared-predicate surface). |
-| `pnpm --filter @sigma/web test` | 0 | 31 файла, 296 теста минават (включително новите 5 в `csv-export.test.ts` и новите 7 в `contract.json.test.ts`). |
-| `pnpm --filter @sigma/db test` | 1 | 25 файла, 174 теста, 171 минават, 3 **предварително съществуващи** failure-а (не са въведени от тази промяна): `integrity-checks.test.ts` (rollup-reconciliation) и два timeout-а в `refresh-slice.test.ts`. Маскиращите файлове (`contracts.test.ts`, `companies.test.ts`) минават изцяло. |
-| `pnpm lint` | 1 | 5 **предварително съществуващи** prettier warning-а във файлове извън обхвата (`RiskIndicators.tsx`, `riskLogic.test.ts`, `companies.test.ts`, `companies.ts`, `contract.json.test.ts`). `docs/architecture.md` е в `.prettierignore`. |
-
-Нетният delta спрямо чистия baseline (преди прилагане на T-001 … T-012): **+11 нови теста, 0 нови failure-а** (преди: 160 passed / 3 failed; след: 171 passed / 3 failed). Промяната не влошава нито един предварително съществуващ failure, както изисква success criteria.
-
-### Оперативна бележка
-
-При първото bulk презареждане след deploy-а на тази промяна R2 обектите под `csv//` ще бъдат презаписани с вече маскирани байтове. До този момент — ако някой направи `wrangler r2 object get` — може да види оригиналните CSV файлове в кеша. Това е приемливо защото (1) файловете са в `robots.txt` + `X-Robots-Tag: noindex` и не се индексират, и (2) следващото презареждане ще ги подмени естествено.
+- [`core-scope.md`](core-scope.md) — доменен модел и речник на данните.
+- [`etl.md`](etl.md) — ETL pipeline и емисията от ЦАИС ЕОП.
+- [`v1-implementation-plan.md`](v1-implementation-plan.md) — precompute слой и пагинация.
+- [`integrity-gate.md`](integrity-gate.md) — reconciliation gate (hard asserts върху тоталите).
+- [`deploy.md`](deploy.md) — деплой към Cloudflare.
+- [`accessibility.md`](accessibility.md) — достъпност (WCAG 2.1 AA).
+- [`spec/ai-assistant.md`](spec/ai-assistant.md) — спецификация на AI асистента (планиран).
diff --git a/docs/privacy-masking.md b/docs/privacy-masking.md
index f9ed7268a..27fae4d86 100644
--- a/docs/privacy-masking.md
+++ b/docs/privacy-masking.md
@@ -1,6 +1,6 @@
# Маскиране на естествени лица и `X-Robots-Tag: noindex` — ръководство за разработчици
-Това е оперативното ръководство за механиката зад политиката от [ADR-0002](architecture.md#adr-0002--политика-за-поверителност-на-машинно-четим-изход-issue-173) и решението за централизация от [ADR-0003](adr/0003-centralized-x-robots-tag-worker.md). За потребителското описание виж [`apps/web/app/routes/privacy.tsx`](../apps/web/app/routes/privacy.tsx) (`#natural-person-data`).
+Това е оперативното ръководство за механиката зад политиката от [ADR-0007](adr/0007-privacy-masking.md) и решението за централизация от [ADR-0008](adr/0008-centralized-x-robots-tag-worker.md). За потребителското описание виж [`apps/web/app/routes/privacy.tsx`](../apps/web/app/routes/privacy.tsx) (`#natural-person-data`).
## Как работи механиката
From 1f26be942f1d5c1fb6d9df38c630e79b5b7268dd Mon Sep 17 00:00:00 2001
From: Lyuboslav Lyubenov
Date: Mon, 6 Jul 2026 15:39:22 +0300
Subject: [PATCH 07/27] style: prettier-format the privacy-masking files for
blocking lint
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The three files modified by PR #183 carried pre-existing prettier debt that the original review flagged (`pnpm lint` exit 1 with `contract.json.test.ts`, `companies.test.ts`, `companies.ts`). The repo's CI is configured as blocking lint (`2d93cd5`, comment in .github/workflows/ci.yml), so this would have blocked the PR from merging. Run `pnpm prettier --write` on the three files — no semantic changes.
---
apps/web/app/routes/contract.json.test.ts | 12 +++--
packages/db/src/queries/companies.test.ts | 62 ++++++++++++++---------
packages/db/src/queries/companies.ts | 17 +------
3 files changed, 49 insertions(+), 42 deletions(-)
diff --git a/apps/web/app/routes/contract.json.test.ts b/apps/web/app/routes/contract.json.test.ts
index 43f17af4b..48b0ced00 100644
--- a/apps/web/app/routes/contract.json.test.ts
+++ b/apps/web/app/routes/contract.json.test.ts
@@ -152,7 +152,10 @@ describe('contract.json loader', () => {
expect(response.status).toBe(200);
expect(response.headers.get('X-Privacy-Mask')).toBe('applied');
expect(response.headers.get('X-Robots-Tag')).toBeNull();
- const body = (await response.json()) as { bidder: { eik: string | null; name: string; displayName: string }; sourceNames: { bidder: string } };
+ const body = (await response.json()) as {
+ bidder: { eik: string | null; name: string; displayName: string };
+ sourceNames: { bidder: string };
+ };
expect(body.bidder.eik).toBeNull();
expect(body.bidder.name).toBe(MASKED_NATURAL_PERSON_LABEL);
expect(body.bidder.displayName).toBe(MASKED_NATURAL_PERSON_LABEL);
@@ -188,7 +191,10 @@ describe('contract.json loader', () => {
expect(response.status).toBe(200);
expect(response.headers.get('X-Privacy-Mask')).toBeNull();
expect(response.headers.get('X-Robots-Tag')).toBeNull();
- const body = (await response.json()) as { bidder: { eik: string | null; name: string }; sourceNames: { bidder: string } };
+ const body = (await response.json()) as {
+ bidder: { eik: string | null; name: string };
+ sourceNames: { bidder: string };
+ };
expect(body.bidder.eik).toBe('123456789');
expect(body.bidder.name).toBe('СОФАРМА ТРЕЙДИНГ АД');
expect(body.sourceNames.bidder).toBe('СОФАРМА ТРЕЙДИНГ АД');
@@ -238,4 +244,4 @@ describe('contract.json loader', () => {
'public, s-maxage=3600, stale-while-revalidate=86400',
);
});
-});
\ No newline at end of file
+});
diff --git a/packages/db/src/queries/companies.test.ts b/packages/db/src/queries/companies.test.ts
index 56f628ca3..709cabb2d 100644
--- a/packages/db/src/queries/companies.test.ts
+++ b/packages/db/src/queries/companies.test.ts
@@ -221,33 +221,47 @@ describe('streamCompaniesCsv masking', () => {
it('keeps the other columns unchanged for both masked and legal-entity rows', async () => {
const csv = await streamCompaniesCsv(maskingDb(), {}).text();
- const [maskedEik, maskedName, maskedKind, maskedSettlement, maskedWon, maskedContracts, maskedAuth, maskedSector] = parseLine(
- csv.trim().split('\n')[1],
- );
- const [legalEik, legalName, legalKind, legalSettlement, legalWon, legalContracts, legalAuth, legalSector] = parseLine(
- csv.trim().split('\n')[2],
- );
+ const [
+ maskedEik,
+ maskedName,
+ maskedKind,
+ maskedSettlement,
+ maskedWon,
+ maskedContracts,
+ maskedAuth,
+ maskedSector,
+ ] = parseLine(csv.trim().split('\n')[1]);
+ const [
+ legalEik,
+ legalName,
+ legalKind,
+ legalSettlement,
+ legalWon,
+ legalContracts,
+ legalAuth,
+ legalSector,
+ ] = parseLine(csv.trim().split('\n')[2]);
expect(maskedEik).toBe('');
expect(maskedName).toBe(MASKED_NATURAL_PERSON_LABEL);
- expect([maskedKind, maskedSettlement, maskedWon, maskedContracts, maskedAuth, maskedSector]).toEqual([
- 'company',
- 'Варна',
- '500',
- '1',
- '1',
- '45',
- ]);
- expect([legalEik, legalName, legalKind, legalSettlement, legalWon, legalContracts, legalAuth, legalSector]).toEqual([
- '333333333',
- 'Пример ООД',
- 'company',
- 'Бургас',
- '700',
- '1',
- '1',
- '45',
- ]);
+ expect([
+ maskedKind,
+ maskedSettlement,
+ maskedWon,
+ maskedContracts,
+ maskedAuth,
+ maskedSector,
+ ]).toEqual(['company', 'Варна', '500', '1', '1', '45']);
+ expect([
+ legalEik,
+ legalName,
+ legalKind,
+ legalSettlement,
+ legalWon,
+ legalContracts,
+ legalAuth,
+ legalSector,
+ ]).toEqual(['333333333', 'Пример ООД', 'company', 'Бургас', '700', '1', '1', '45']);
});
it('masks rows whose legal_form is ЕТ regardless of which source() branch they came from (base-aggregation path)', async () => {
diff --git a/packages/db/src/queries/companies.ts b/packages/db/src/queries/companies.ts
index 7400af72f..32fb5b8a1 100644
--- a/packages/db/src/queries/companies.ts
+++ b/packages/db/src/queries/companies.ts
@@ -4,11 +4,7 @@
import type { CompanyListItem, EntityKind, FacetCount, Page } from '@sigma/api-contract';
import { CPV_SECTORS, ENTITY_TYPES } from '@sigma/config';
-import {
- cleanName,
- isNaturalPersonBidder,
- MASKED_NATURAL_PERSON_LABEL,
-} from '@sigma/shared';
+import { cleanName, isNaturalPersonBidder, MASKED_NATURAL_PERSON_LABEL } from '@sigma/shared';
import { csvCell } from './csv';
import { assertCovers } from './filter-guard';
import { filterSignature, keyset, pageCursors } from './keyset';
@@ -275,16 +271,7 @@ export function streamCompaniesCsv(db: D1Database, p: CompanyListParams): Respon
const name = isNatural ? MASKED_NATURAL_PERSON_LABEL : r.name;
const eik = isNatural ? '' : r.eik;
block +=
- [
- eik,
- name,
- r.kind,
- r.settlement,
- r.won_eur,
- r.contracts,
- r.authorities,
- r.primary_sector,
- ]
+ [eik, name, r.kind, r.settlement, r.won_eur, r.contracts, r.authorities, r.primary_sector]
.map(csvCell)
.join(',') + '\n';
afterId = r.bidder_id;
From 6ac65392026b7918965a062e7e377fab00dcd972 Mon Sep 17 00:00:00 2001
From: Lyuboslav Lyubenov
Date: Tue, 28 Jul 2026 00:01:29 +0300
Subject: [PATCH 08/27] fix(privacy): remove redundant markPrivacyMaskApplied
calls in csv-export
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The R2-body branch (responseFromR2Object) and the 304 branch each called
markPrivacyMaskApplied directly, then handed the response to markCsvCache,
which calls it again internally. The marker was applied twice on MISS/HIT/304
paths — idempotent in effect, but dead code that hid markCsvCache as the single
source of truth for the privacy marker on every CSV path (PR #183 review T-004,
"NO DEAD CODE / NO CODE DUPLICATION").
Drop the direct calls; rely solely on markCsvCache. Add a TDD guard that spies
on markPrivacyMaskApplied and asserts exactly one call per response path
(MISS/HIT/dynamic/304), so a future duplicate cannot sneak back in.
---
apps/web/app/lib/csv-export.test.ts | 53 ++++++++++++++++++++++++++++-
apps/web/app/lib/csv-export.ts | 6 ++--
2 files changed, 56 insertions(+), 3 deletions(-)
diff --git a/apps/web/app/lib/csv-export.test.ts b/apps/web/app/lib/csv-export.test.ts
index e575bfb61..ff7919cbf 100644
--- a/apps/web/app/lib/csv-export.test.ts
+++ b/apps/web/app/lib/csv-export.test.ts
@@ -1,7 +1,8 @@
-import { describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AUTHORITY_FILTER_KEYS, COMPANY_FILTER_KEYS, CONTRACT_FILTER_KEYS } from '@sigma/db';
import { MASKED_NATURAL_PERSON_LABEL } from '@sigma/shared';
import { DATA_SOURCE } from './dataSource';
+import * as security from './security';
import { isUnfilteredCsvExport, servedCsvExport } from './csv-export';
const REFRESHED_AT = '2026-06-13T10:00:00Z';
@@ -581,4 +582,54 @@ describe('servedCsvExport privacy', () => {
expect(response.headers.get('X-Privacy-Mask')).toBe('applied');
expect(response.headers.get('X-Robots-Tag')).toBeNull();
});
+
+ // Guards against the duplicate-call smell flagged in PR #183 review (T-004): `markCsvCache` already
+ // invokes `markPrivacyMaskApplied` on the final headers, so an earlier call inside `responseFromR2Object`
+ // (or on the 304 branch) was redundant. The marker must be applied, but exactly once per response —
+ // a second call is dead code that hides the single source of truth (markCsvCache).
+ describe('marks the privacy mask exactly once per response (no duplicate calls)', () => {
+ let markSpy: ReturnType;
+
+ beforeEach(() => {
+ markSpy = vi.spyOn(security, 'markPrivacyMaskApplied');
+ });
+ afterEach(() => markSpy.mockRestore());
+
+ it('MISS (contracts): marks exactly once', async () => {
+ const r2 = new InMemoryR2();
+ await serve(r2, vi.fn(() => csvResponse()), { route: 'contracts' });
+ expect(markSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('HIT (companies): marks exactly once', async () => {
+ const r2 = new InMemoryR2();
+ await (await serve(r2, vi.fn(() => csvResponse()), { route: 'companies' })).text();
+ markSpy.mockClear();
+ await serve(r2, vi.fn(() => csvResponse('hit\n')), { route: 'companies' });
+ expect(markSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('dynamic (authorities, filtered): marks exactly once', async () => {
+ const r2 = new InMemoryR2();
+ await serve(r2, vi.fn(() => csvResponse('filtered\n')), {
+ route: 'authorities',
+ params: { sort: 'value-desc', q: 'foo' },
+ });
+ expect(markSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('304 (conditional GET on a primed object): marks exactly once', async () => {
+ const r2 = new InMemoryR2();
+ const primed = await serve(r2, vi.fn(() => csvResponse()));
+ const etag = primed.headers.get('ETag');
+ await primed.text();
+ markSpy.mockClear();
+ await serve(r2, vi.fn(() => csvResponse('hit\n')), {
+ request: new Request('http://local/contracts.csv', {
+ headers: { 'If-None-Match': etag ?? '' },
+ }),
+ });
+ expect(markSpy).toHaveBeenCalledTimes(1);
+ });
+ });
});
diff --git a/apps/web/app/lib/csv-export.ts b/apps/web/app/lib/csv-export.ts
index 3c9ee179e..2db063dea 100644
--- a/apps/web/app/lib/csv-export.ts
+++ b/apps/web/app/lib/csv-export.ts
@@ -100,7 +100,8 @@ function responseFromR2Object(
status: 304,
headers: { ETag: obj.httpEtag },
});
- markPrivacyMaskApplied(response304.headers);
+ // `markCsvCache` (below) stamps the privacy mask on the final headers — the single source of
+ // truth for the marker on every CSV path. Do not call `markPrivacyMaskApplied` here as well.
return markCsvCache(response304, cache);
}
@@ -114,8 +115,9 @@ function responseFromR2Object(
'Content-Length': String(range?.length ?? obj.size),
});
if (range) headers.set('Content-Range', `bytes ${range.start}-${range.end}/${obj.size}`);
- markPrivacyMaskApplied(headers);
+ // `markCsvCache` stamps the privacy mask on the final headers — the single source of truth for the
+ // marker on every CSV path. Do not call `markPrivacyMaskApplied` here as well (was a duplicate call).
return markCsvCache(new Response(obj.body, { status: range ? 206 : 200, headers }), cache);
}
From b47225b9aff5b699cda486b833e340a0cfb93bcd Mon Sep 17 00:00:00 2001
From: Lyuboslav Lyubenov
Date: Tue, 28 Jul 2026 00:06:13 +0300
Subject: [PATCH 09/27] fix(privacy): guard natural-person mask with
bidder_kind to stop consortium over-masking
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
isNaturalPersonBidder's docstring delegates consortium filtering to the caller —
a JV is a legal entity even if a lead member's name / legal_form matches a
sole-trader signal. But streamContractsCsv and streamCompaniesCsv both invoked
it WITHOUT a bidder_kind guard, so a consortium such as "ЕТ Иван Петров; Строй
ООД" (or any consortium whose legal_form collided with a sole-trader form) was
masked to MASKED_NATURAL_PERSON_LABEL with its ЕИК cleared.
The result was privacy-safe (over-masking, no leak) but a behavioral change
that dropped the lead member's name + ЕИК and contradicted the predicate's
contract. Add an early bidder_kind/kind !== 'consortium' guard in both
streamers so consortium rows keep the "… и др." shape and their ЕИК.
TDD: failing tests first (consortium with ЕТ lead name + ЕТ legal_form, and the
leading-ЕТ name heuristic with legal_form null), then the guard (PR #183 T-006).
---
packages/db/src/queries/companies.test.ts | 53 +++++++++++++++++++++++
packages/db/src/queries/companies.ts | 6 ++-
packages/db/src/queries/contracts.test.ts | 45 +++++++++++++++++++
packages/db/src/queries/contracts.ts | 6 ++-
4 files changed, 108 insertions(+), 2 deletions(-)
diff --git a/packages/db/src/queries/companies.test.ts b/packages/db/src/queries/companies.test.ts
index 709cabb2d..ac2af47c6 100644
--- a/packages/db/src/queries/companies.test.ts
+++ b/packages/db/src/queries/companies.test.ts
@@ -311,6 +311,59 @@ describe('streamCompaniesCsv masking', () => {
const header = csv.trim().split('\n')[0];
expect(header).toBe('eik,name,kind,settlement,won_eur,contracts,authorities,primary_sector');
});
+
+ // Regression for PR #183 review T-006 (companies mirror of the contracts bug): a consortium whose
+ // name / legal_form collides with a sole-trader signal was masked as a natural person. `kind` must
+ // take precedence so a JV keeps its name + ЕИК. The maskingDb() above only seeds `company` rows, so
+ // we build a dedicated fixture here.
+ it('does not mask a consortium row whose lead member looks like a sole trader (ЕТ name / sole-trader legal_form)', async () => {
+ const consortiumRows: CompanyTotalsRow[] = [
+ {
+ bidder_id: 'eik:201345678',
+ name: 'ЕТ Иван Петров; Строй ООД',
+ kind: 'consortium',
+ ownership_kind: null,
+ eik: '201345678',
+ eik_valid: 1,
+ settlement: 'Пловдив',
+ won_eur: 900,
+ contracts: 1,
+ authorities: 1,
+ primary_sector: '45',
+ eu_eur: 0,
+ first_date: '2024-03-01',
+ last_date: '2024-03-02',
+ legal_form: 'ЕТ',
+ },
+ ];
+ const db: D1Database = {
+ prepare(sql: string) {
+ let bound: unknown[] = [];
+ return {
+ bind(...args: unknown[]) {
+ bound = args;
+ return this;
+ },
+ async all() {
+ if (sql.includes('ORDER BY bidder_id')) {
+ const afterId = bound.at(-2) as string;
+ return { results: consortiumRows.filter((r) => r.bidder_id > afterId) as T[] };
+ }
+ return { results: consortiumRows as T[] };
+ },
+ async first() {
+ return { n: consortiumRows.length } as T;
+ },
+ };
+ },
+ } as unknown as D1Database;
+
+ const csv = await streamCompaniesCsv(db, {}).text();
+ const [eik, name, kind] = parseLine(csv.trim().split('\n')[1]);
+ expect(name).toBe('ЕТ Иван Петров; Строй ООД');
+ expect(eik).toBe('201345678');
+ expect(kind).toBe('consortium');
+ });
});
describe('prototype-key params (untrusted query values)', () => {
diff --git a/packages/db/src/queries/companies.ts b/packages/db/src/queries/companies.ts
index 32fb5b8a1..f8c3b6462 100644
--- a/packages/db/src/queries/companies.ts
+++ b/packages/db/src/queries/companies.ts
@@ -267,7 +267,11 @@ export function streamCompaniesCsv(db: D1Database, p: CompanyListParams): Respon
}
let block = '';
for (const r of results) {
- const isNatural = isNaturalPersonBidder(cleanName(r.name), r.legal_form);
+ // `isNaturalPersonBidder`'s docstring delegates consortium filtering to the caller (a JV is a
+ // legal entity even if a lead member's name / legal_form looks like a sole trader). Guard with
+ // `kind` first so a consortium such as „ЕТ Иван Петров; Строй ООД" is NOT masked as a natural
+ // person — it keeps its name + ЕИК. Mirrors the guard in streamContractsCsv (PR #183 T-006).
+ const isNatural = r.kind !== 'consortium' && isNaturalPersonBidder(cleanName(r.name), r.legal_form);
const name = isNatural ? MASKED_NATURAL_PERSON_LABEL : r.name;
const eik = isNatural ? '' : r.eik;
block +=
diff --git a/packages/db/src/queries/contracts.test.ts b/packages/db/src/queries/contracts.test.ts
index aeceaa666..cd9f96cb5 100644
--- a/packages/db/src/queries/contracts.test.ts
+++ b/packages/db/src/queries/contracts.test.ts
@@ -331,4 +331,49 @@ describe('streamContractsCsv masking', () => {
expect(rows[1]![6]).toBe('999999999');
expect(rows[1]![7]).toBe('consortium');
});
+
+ // Regression for PR #183 review T-006: `isNaturalPersonBidder`'s docstring delegates consortium
+ // filtering to the caller, but `streamContractsCsv` invoked it WITHOUT a `bidder_kind ===
+ // 'consortium'` guard. A consortium whose `bidder_name` starts with „ЕТ " (a lead member that is a
+ // sole trader, e.g. „ЕТ Иван Петров; Строй ООД") or whose `legal_form` collides with a sole-trader
+ // form was masked as a natural person — privacy-safe (over-masking) but a behavioral change that
+ // drops the lead member's name + ЕИК and contradicts the predicate's contract. Consortium rows now
+ // bypass the natural-person mask and keep the „… и др." shape from `entityName`.
+ it('does not mask a consortium row whose lead member looks like a sole trader (ЕТ name / sole-trader legal_form)', async () => {
+ const db = csvDb([
+ makeCsvRow({
+ id: 'c:et-led-consortium',
+ rowid: 1,
+ bidder_name: 'ЕТ Иван Петров; Строй ООД',
+ bidder_kind: 'consortium',
+ bidder_legal_form: 'ЕТ',
+ contractor_eik: '201345678',
+ }),
+ ]);
+
+ const rows = parseCsv(await streamContractsCsv(db, {}).text());
+
+ expect(rows[1]![5]).toBe('ЕТ Иван Петров и др.');
+ expect(rows[1]![6]).toBe('201345678');
+ expect(rows[1]![7]).toBe('consortium');
+ });
+
+ it('does not mask a consortium row matched only by the leading-ЕТ name heuristic (legal_form null)', async () => {
+ const db = csvDb([
+ makeCsvRow({
+ id: 'c:et-named-consortium',
+ rowid: 1,
+ bidder_name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ; Логистика АД',
+ bidder_kind: 'consortium',
+ bidder_legal_form: null,
+ contractor_eik: '201345678',
+ }),
+ ]);
+
+ const rows = parseCsv(await streamContractsCsv(db, {}).text());
+
+ expect(rows[1]![5]).toBe('ЕТ ДРИФТ - НИКОЛАЙ КИРОВ и др.');
+ expect(rows[1]![6]).toBe('201345678');
+ expect(rows[1]![7]).toBe('consortium');
+ });
});
diff --git a/packages/db/src/queries/contracts.ts b/packages/db/src/queries/contracts.ts
index 2b13ed936..5c4e30f54 100644
--- a/packages/db/src/queries/contracts.ts
+++ b/packages/db/src/queries/contracts.ts
@@ -457,7 +457,11 @@ export function streamContractsCsv(db: D1Database, p: ContractListParams): Respo
let block = '';
for (const r of results) {
const bidderName = cleanName(r.bidder_name);
- const isNatural = isNaturalPersonBidder(bidderName, r.bidder_legal_form);
+ // `isNaturalPersonBidder`'s docstring delegates consortium filtering to the caller (a JV is a
+ // legal entity even if a lead member's name / legal_form looks like a sole trader). Guard with
+ // `bidder_kind` first so a consortium such as „ЕТ Иван Петров; Строй ООД" is NOT over-masked as
+ // a natural person — it keeps the „… и др." shape and the consortium ЕИК.
+ const isNatural = r.bidder_kind !== 'consortium' && isNaturalPersonBidder(bidderName, r.bidder_legal_form);
const contractor = isNatural
? MASKED_NATURAL_PERSON_LABEL
: entityName(bidderName, r.bidder_kind);
From d56e23d7b80483f20df6e9dd63c411753aee9d53 Mon Sep 17 00:00:00 2001
From: Lyuboslav Lyubenov
Date: Tue, 28 Jul 2026 00:07:30 +0300
Subject: [PATCH 10/27] docs(privacy): correct stale isNaturalPersonBidder
docstring (no inline duplication)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The docstring claimed the legal_form rules were "carried inline in
apps/web/app/routes/company.tsx until the route migrates" — but ADR-0007 §1
already removed the legacy inline isSingleNaturalPersonProfile, and company.tsx
now calls this shared predicate directly (verified: no legal_form string-
matching exists outside packages/shared). The stale claim created exactly the
divergence risk the PR #183 reviewer flagged under "NO CODE DUPLICATION": a
future reader could believe a second copy still lives in the route and maintain
it separately.
Rewrite the docstring to state the predicate is the single source of truth and
enumerate the downstream surfaces that consume it (HTML noindex, CSV masking,
JSON masking), with a pointer to the bidder_kind/kind consortium guards added
in the CSV streamers (PR #183 T-006). No behavior change.
---
packages/shared/src/format.ts | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/packages/shared/src/format.ts b/packages/shared/src/format.ts
index dc7845f3b..e39a13fe6 100644
--- a/packages/shared/src/format.ts
+++ b/packages/shared/src/format.ts
@@ -207,9 +207,13 @@ export const MASKED_NATURAL_PERSON_LABEL = 'Частно лице';
/**
* Canonical natural-person / sole-trader predicate for machine-readable masking. Combines the
- * `legal_form` rules (carried inline in `apps/web/app/routes/company.tsx` until the route migrates)
- * with the leading-`ЕТ` name heuristic — so callers only need one check to decide whether to mask
- * ЕИК and the raw source name. Caller is responsible for filtering consortium rows; the function
+ * `legal_form` rules with the leading-`ЕТ` name heuristic — so callers only need one check to
+ * decide whether to mask ЕИК and the raw source name. This is the SINGLE source of truth: every
+ * downstream surface (HTML `noindex` in `apps/web/app/routes/company.tsx`, CSV masking in
+ * `streamContractsCsv` / `streamCompaniesCsv`, JSON masking in `/contracts/:id.json`) calls this
+ * predicate — there is no inline duplicate of the `legal_form` rules in any route (ADR-0007 §1
+ * removed the legacy inline `isSingleNaturalPersonProfile`). Caller is responsible for filtering
+ * consortium rows (see the `bidder_kind` / `kind` guards in the CSV streamers); the function
* itself only inspects `legalForm` and `name`.
*/
export function isNaturalPersonBidder(name: string, legalForm: string | null): boolean {
From 707313243a2db084fd6a84be1e762f3379a3d799 Mon Sep 17 00:00:00 2001
From: Lyuboslav Lyubenov
Date: Tue, 28 Jul 2026 01:56:17 +0300
Subject: [PATCH 11/27] =?UTF-8?q?docs(privacy):=20record=20displayName=20+?=
=?UTF-8?q?=20slug=20policy=20decisions=20(ADR-0007=20=C2=A76,=20=C2=A77)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two PR #183 review threads asked for explicit product decisions on the company
profile masking surface. Both are recorded here as policy.
§6 — displayName stays visible in the HTML profile and its `.data` twin; only the
ЕИК is masked. The trading name is PUBLIC (rendered verbatim on the HTML page and
in ); the sensitive natural-person identifier is the ЕИК. The `.data`
turbo-stream is React Router v7's single-fetch transport for client-side
navigations, NOT a standalone export like /contracts/:id.json — masking the name
there would break client-rendered pages. Consistent policy: name = public, ЕИК =
sensitive. company.tsx loader comment now states this; the company.data.test.ts
assertion locks displayName-verbatim + eik-null as the contract.
§7 — the name-keyed natural-person slug (n + base64url(name)) is a tracked
limitation, not changed in this PR. The name is public (§6), the sitemap already
filters these records, and reworking the slug scheme is cross-cutting (URL
stability, internal links, identity system) and out of scope for a masking PR.
No behavior change.
---
apps/web/app/routes/company.data.test.ts | 7 ++++++-
apps/web/app/routes/company.tsx | 23 ++++++++++++++++++-----
docs/adr/0007-privacy-masking.md | 8 ++++++++
3 files changed, 32 insertions(+), 6 deletions(-)
diff --git a/apps/web/app/routes/company.data.test.ts b/apps/web/app/routes/company.data.test.ts
index 6f7bfd582..e44227f5f 100644
--- a/apps/web/app/routes/company.data.test.ts
+++ b/apps/web/app/routes/company.data.test.ts
@@ -113,7 +113,7 @@ beforeEach(() => {
});
describe('company.data loader — natural-person branch', () => {
- it('returns a Response carrying X-Privacy-Mask: applied with company.eik === null (behaviors 1 + 2)', async () => {
+ it('masks only the ЕИК (sensitive ID), keeps the public trading displayName, and marks noindex (behaviors 1 + 2)', async () => {
const natural = makeCompany({ legalForm: 'ЕТ', displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ' });
installStubs(natural);
@@ -127,7 +127,12 @@ describe('company.data loader — natural-person branch', () => {
const body = (await response.json()) as {
company: { eik: string | null; displayName: string };
};
+ // ЕИК is the sensitive natural-person identifier → masked.
expect(body.company.eik).toBeNull();
+ // displayName is the PUBLIC trading name, rendered verbatim on the HTML page; the `.data` twin
+ // is React Router's single-fetch transport for client navigations (NOT a standalone export like
+ // /contracts/:id.json), so the name must stay verbatim or client-rendered pages break. Only the
+ // ЕИК is masked. This locks the policy decision recorded in ADR-0007 §3 + PR #183 review.
expect(body.company.displayName).toBe('ЕТ ДРИФТ - НИКОЛАЙ КИРОВ');
});
});
diff --git a/apps/web/app/routes/company.tsx b/apps/web/app/routes/company.tsx
index a1232b71c..1bf20985b 100644
--- a/apps/web/app/routes/company.tsx
+++ b/apps/web/app/routes/company.tsx
@@ -68,11 +68,24 @@ export async function loader({ params, context }: Route.LoaderArgs) {
getEntityNetwork(db, { kind: 'company', id }, { includeCenterOptions: false }),
]);
if (!company) throw new Response('Not Found', { status: 404 });
- // Single-fetch `.data` twin shares this loader with the HTML response, so we mask here once
- // and signal the worker via the internal `X-Privacy-Mask` marker. The mutation clears the
- // natural person's ЕИК on the returned object (covers `.data`); the marker is translated into
- // `X-Robots-Tag: noindex` by `hardenResponse` in `apps/web/workers/app.ts`. Legal-entity
- // records keep the plain-object return (no marker, no mutation).
+ // Privacy policy for the company profile (ADR-0007 §3, decision recorded in PR #183 review):
+ // the trading `displayName` is PUBLIC — it is rendered verbatim on the HTML page
+ // (``, breadcrumbs, ``) and is the same string a
+ // visitor sees. The sensitive natural-person identifier is the ЕИК, which we mask here.
+ //
+ // The `.data` turbo-stream twin is NOT a standalone machine-readable export (unlike
+ // `/contracts/:id.json` or the CSV exports, which DO mask the name). It is React Router v7's
+ // single-fetch transport for client-side navigations: when a user clicks a ``
+ // the browser fetches `.data` and re-renders the SAME HTML page from `company.displayName`.
+ // Masking the name in `.data` would therefore make client-rendered pages show
+ // `MASKED_NATURAL_PERSON_LABEL` — breaking the legitimate user-facing HTML view that ADR-0007
+ // explicitly preserves. Only the ЕИК (the sensitive ID) is masked, consistently across both the
+ // HTML and `.data` representations. The whole response is marked `noindex` regardless.
+ //
+ // The mutation clears the natural person's ЕИК on the returned object (covers `.data`); the
+ // `X-Privacy-Mask` marker is translated into `X-Robots-Tag: noindex` by `hardenResponse` in
+ // `apps/web/workers/app.ts`. Legal-entity records keep the plain-object return (no marker, no
+ // mutation).
if (isNaturalPersonBidder(company.displayName, company.legalForm)) {
company.eik = null;
return Response.json(
diff --git a/docs/adr/0007-privacy-masking.md b/docs/adr/0007-privacy-masking.md
index d919e7ceb..63efaa8be 100644
--- a/docs/adr/0007-privacy-masking.md
+++ b/docs/adr/0007-privacy-masking.md
@@ -31,6 +31,14 @@ HTML профилът на фирма вече прилагаше `noindex` ме
4. **Символът `MASKED_NATURAL_PERSON_LABEL` е единичен източник на истината.** Експортиран от `@sigma/shared` (баррелът [`packages/shared/src/index.ts`](../../packages/shared/src/index.ts)). Тестовете го импортират по символ, не по литерален текст — преименуване на етикета (напр. на „Физическо лице") няма да счупи нито един тест.
5. **Юридическите лица не се пипат.** `legal_form` `АД`/`ООД`/`ЕАД`/`ЕООД` остават видими както в CSV, така и в JSON. Само когато предикатът върне `true` се прилага маската.
+## Уточнения на решението (приети в ревюто на PR #183)
+
+Две бележки от ревюто на PR #183 изискаха изрично продуктово решение. Решенията са записани тук като част от политиката.
+
+6. **Името (`displayName`) на естественото лице остава видимо в HTML профила и в `.data` близнака; само ЕИК-ът се маскира.** Търговското име (напр. „ЕТ ДРИФТ - НИКОЛАЙ КИРОВ") е ПУБЛИЧНО — то се рендира буквално на HTML страницата (``, breadcrumbs, ``) и е същият низ, който посетителят вижда. Чувствителният идентификатор на физическото лице е ЕИК-ът, който се маскира. Това разграничение е консистентно: име = публично, ЕИК = чувствителен. `.data` turbo-stream близнакът на `/companies/:eik` НЕ е самостоятелен машинно-четим експорт (както са `/contracts/:id.json` и CSV-тата, които маскират името), а е transport-ът за client-side навигация при React Router v7 single-fetch — маскирането на името там би счупило клиентски рендираните страници (`MASKED_NATURAL_PERSON_LABEL` вместо името). Целият отговор остава маркиран `X-Robots-Tag: noindex`. Тестът `company.data.test.ts` заключва това поведение (`displayName` остава буквален, `eik === null`).
+
+7. **Slug-ът на name-keyed естествено лице е проследявано ограничение, не се променя в този PR.** За фирма без валиден ЕИК slug-ът е `n + base64url(name)` ([`packages/db/src/queries/identity.ts`](../../packages/db/src/queries/identity.ts)) — обратимо кодиране на суровото име. Името обаче е публично (точка 6), sitemap-ът вече филтрира тези записи ([`streamCompanySitemap`](../../packages/db/src/queries/sitemaps.ts) пропуска `isNaturalPersonProfileName`), а промяната на slug схемата е cross-cutting (URL стабилност, вътрешни линкове, bookmarks, identity система) и е извън обхвата на masking PR. Остава като проследявано ограничение за бъдещ PR, ако рискът от реверсибилно кодирано име в URL-а се реши, че трябва да се затвори.
+
## Последствия
- **Кеш политиката остава.** `Cache-Control: public, max-age=3600` за CSV и `public, s-maxage=3600, stale-while-revalidate=86400` за JSON остават непроменени — маскираните байтове са безопасни за кеширане, защото не съдържат естествено-личностни идентификатори. Edge кешът е приоритет за DDoS устойчивостта (ADR-0001 §3) и отпадането му би влошило публичната достъпност на експортите.
From 127edd63a04400c053007f3ad809e123cf7bc1a3 Mon Sep 17 00:00:00 2001
From: Lyuboslav Lyubenov
Date: Tue, 28 Jul 2026 13:12:32 +0300
Subject: [PATCH 12/27] fix(privacy): add consortium guard to JSON masker for
parity with CSV path
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The `/contracts/:id.json` masker (`maskContractForPrivacy`) lacked the
`bidder_kind !== 'consortium'` guard that the CSV streamer already has
(`contracts.ts:459`). A consortium whose display name begins with „ЕТ "
(first member is a sole trader, e.g. „ЕТ Иван Петров; Строй ООД") was
over-masked to „Частно лице" — losing the „… и др." shape, the consortium
ЕИК, and gaining an unearned `noindex`.
`isNaturalPersonBidder`'s docstring delegates consortium filtering to the
caller; this adds the caller guard, mirroring the CSV path exactly. Flagged
as MAJOR 1 in the PR #183 review of head a9b18ae.
TDD: failing consortium cases first (name-based + legal_form-based, plus a
loader-level marker-omission case), then the guard.
---
apps/web/app/routes/contract.json.test.ts | 97 +++++++++++++++++++++++
apps/web/app/routes/contract.json.tsx | 8 ++
2 files changed, 105 insertions(+)
diff --git a/apps/web/app/routes/contract.json.test.ts b/apps/web/app/routes/contract.json.test.ts
index 48b0ced00..a7046f153 100644
--- a/apps/web/app/routes/contract.json.test.ts
+++ b/apps/web/app/routes/contract.json.test.ts
@@ -137,6 +137,69 @@ describe('maskContractForPrivacy', () => {
expect(masked.bidder.name).toBe(MASKED_NATURAL_PERSON_LABEL);
expect(masked.sourceNames.bidder).toBe(MASKED_NATURAL_PERSON_LABEL);
});
+
+ it('does NOT mask a consortium whose first member is a sole trader (kind=consortium guard)', () => {
+ // Real-world shape: a JV whose display name begins with "ЕТ " because the first member is a sole
+ // trader ("ЕТ Иван Петров; Строй ООД"). The CSV streamer already gates this with `bidder_kind !==
+ // 'consortium'` (contracts.ts:459); the JSON masker must apply the same guard so a consortium is
+ // never over-masked to "Частно лице" — it keeps the "… и др." shape, the consortium ЕИК, and no
+ // noindex. This is the parity case the PR #183 review (MAJOR 1) flagged as missing here.
+ const consortium = makeRecord({
+ bidder: {
+ slug: 'bidder-consortium',
+ name: 'ЕТ Иван Петров; Строй ООД',
+ displayName: 'ЕТ Иван Петров и др.',
+ kind: 'consortium',
+ typeLabel: null,
+ settlement: 'Sofia',
+ eik: '200000000',
+ sector: null,
+ totalContracts: 3,
+ totalEur: 5000,
+ },
+ sourceNames: {
+ authority: 'Some Authority',
+ bidder: 'ЕТ Иван Петров; Строй ООД',
+ },
+ });
+ consortium.bidder_legal_form = null; // name-based heuristic is what would otherwise match
+
+ const masked = maskContractForPrivacy(consortium, consortium.bidder_legal_form);
+ // Reference equality = not masked → caller will NOT set the noindex marker.
+ expect(masked).toBe(consortium);
+ expect(masked.bidder.eik).toBe('200000000');
+ expect(masked.bidder.name).toBe('ЕТ Иван Петров; Строй ООД');
+ expect(masked.bidder.displayName).toBe('ЕТ Иван Петров и др.');
+ expect(masked.sourceNames.bidder).toBe('ЕТ Иван Петров; Строй ООД');
+ });
+
+ it('does NOT mask a consortium whose legal_form matches a sole-trader form (kind=consortium guard)', () => {
+ // Belt-and-braces: even when `legal_form` is literally "ЕТ", a consortium must be exempt — the
+ // `kind` signal is authoritative over the name/legal_form heuristic for JV entities.
+ const consortium = makeRecord({
+ bidder: {
+ slug: 'bidder-consortium',
+ name: 'ЕТ Петров; ВИСТА ООД',
+ displayName: 'ЕТ Петров и др.',
+ kind: 'consortium',
+ typeLabel: null,
+ settlement: null,
+ eik: '200000001',
+ sector: null,
+ totalContracts: 2,
+ totalEur: 3000,
+ },
+ sourceNames: {
+ authority: 'Some Authority',
+ bidder: 'ЕТ Петров; ВИСТА ООД',
+ },
+ });
+ consortium.bidder_legal_form = 'ЕТ';
+
+ const masked = maskContractForPrivacy(consortium, consortium.bidder_legal_form);
+ expect(masked).toBe(consortium);
+ expect(masked.bidder.eik).toBe('200000001');
+ });
});
describe('contract.json loader', () => {
@@ -212,6 +275,40 @@ describe('contract.json loader', () => {
expect(body).toEqual({ error: 'not_found' });
});
+ it('does NOT set the privacy marker for a consortium whose first member is a sole trader (behavior 5)', async () => {
+ // Loader-level proof of the MAJOR 1 guard: reference-equality from maskContractForPrivacy must
+ // propagate to the marker decision, so a consortium gets NO X-Privacy-Mask (→ no X-Robots-Tag).
+ const consortium = makeRecord({
+ bidder: {
+ slug: 'bidder-consortium',
+ name: 'ЕТ Иван Петров; Строй ООД',
+ displayName: 'ЕТ Иван Петров и др.',
+ kind: 'consortium',
+ typeLabel: null,
+ settlement: 'Sofia',
+ eik: '200000000',
+ sector: null,
+ totalContracts: 3,
+ totalEur: 5000,
+ },
+ sourceNames: {
+ authority: 'Some Authority',
+ bidder: 'ЕТ Иван Петров; Строй ООД',
+ },
+ });
+ consortium.bidder_legal_form = null;
+ vi.mocked(getContract).mockResolvedValueOnce(consortium);
+
+ const response = await loader(loaderArgs('c-consortium'));
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get('X-Privacy-Mask')).toBeNull();
+ expect(response.headers.get('X-Robots-Tag')).toBeNull();
+ const body = (await response.json()) as { bidder: { eik: string | null; name: string } };
+ expect(body.bidder.eik).toBe('200000000');
+ expect(body.bidder.name).toBe('ЕТ Иван Петров; Строй ООД');
+ });
+
it('preserves the public, s-maxage=3600 Cache-Control policy on the success branch (behavior 4)', async () => {
vi.mocked(getContract).mockResolvedValueOnce(makeRecord());
const masked = await loader(loaderArgs('c-1'));
diff --git a/apps/web/app/routes/contract.json.tsx b/apps/web/app/routes/contract.json.tsx
index 4ddc2382c..4658f9ed2 100644
--- a/apps/web/app/routes/contract.json.tsx
+++ b/apps/web/app/routes/contract.json.tsx
@@ -19,11 +19,19 @@ function safeJson(value: unknown): string {
* replaced by the canonical masking label when `isNaturalPersonBidder(name, bidderLegalForm)`
* matches. Returns the input by reference when the record identifies a legal entity, so callers
* can use reference equality to decide whether to set the noindex header.
+ *
+ * The `record.bidder.kind === 'consortium'` guard mirrors the CSV streamer
+ * (`bidder_kind !== 'consortium' && isNaturalPersonBidder(...)` in contracts.ts:459): a JV whose
+ * first member is a sole trader has a display name beginning "ЕТ …", so `isNaturalPersonBidder`
+ * alone would over-mask the consortium to "Частно лице" — losing the "… и др." shape and the
+ * consortium ЕИК. `isNaturalPersonBidder`'s docstring explicitly delegates consortium filtering
+ * to the caller; the guard here is that caller (PR #183 review, MAJOR 1).
*/
export function maskContractForPrivacy(
record: ContractRecord & { bidder_legal_form: string | null },
bidderLegalForm: string | null,
): ContractRecord {
+ if (record.bidder.kind === 'consortium') return record;
if (!isNaturalPersonBidder(record.bidder.name, bidderLegalForm)) return record;
return {
...record,
From 6f65c83bd9a9409852b35a161e2d7aa723ddc01a Mon Sep 17 00:00:00 2001
From: Lyuboslav Lyubenov
Date: Tue, 28 Jul 2026 13:14:23 +0300
Subject: [PATCH 13/27] =?UTF-8?q?fix(privacy):=20mask=20sole-trader=20?=
=?UTF-8?q?=D0=95=D0=98=D0=9A=20on=20contract=20page=20+=20.data=20twin?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`contract.tsx` was the most-indexable surface still open: its loader returned
`{ contract }` raw with no privacy marker, `robots.txt` does not block
`/contracts/:id` (or its `.data` twin), and the page rendered `c.bidder.eik`
verbatim — so a sole-trader's ЕИК was indexable on both the HTML page and the
RRv7 single-fetch `.data` payload. That is a worse exposure than the already-
closed `.json`/`.csv` paths.
Masking + signalling in the SHARED loader covers both surfaces at once (the
`.data` twin reuses the same loader), mirroring `company.tsx:89` exactly:
ЕИК (the sensitive natural-person ID) → null on the returned object, the
trading displayName stays PUBLIC (ADR-0007 §6), and the `X-Privacy-Mask:
applied` marker is translated to `X-Robots-Tag: noindex` by the worker. The
`kind === 'consortium'` guard matches the JSON masker (MAJOR 1) and the CSV
streamer so a JV is never over-masked/noindexed. `headers()` forwards the
marker onto the HTML response (RR does not auto-propagate loader headers).
Flagged as MAJOR 2 in the PR #183 review of head a9b18ae.
TDD: failing loader/headers/pipeline cases first, then the loader change.
---
apps/web/app/routes/contract.data.test.ts | 224 ++++++++++++++++++++++
apps/web/app/routes/contract.tsx | 43 ++++-
2 files changed, 265 insertions(+), 2 deletions(-)
create mode 100644 apps/web/app/routes/contract.data.test.ts
diff --git a/apps/web/app/routes/contract.data.test.ts b/apps/web/app/routes/contract.data.test.ts
new file mode 100644
index 000000000..9784e34fb
--- /dev/null
+++ b/apps/web/app/routes/contract.data.test.ts
@@ -0,0 +1,224 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { ContractRecord } from '@sigma/api-contract';
+import { applyPrivacyMaskHeaders } from '../lib/security';
+import { getContract } from '@sigma/db';
+import { headers, loader } from './contract';
+
+vi.mock('@sigma/db', () => ({
+ getContract: vi.fn(),
+ contractIdFromSlug: (slug: string) => 'c:' + slug,
+}));
+
+// Minimal ContractRecord builder. The loader only reads `bidder.{name,displayName,kind,eik}` and
+// `bidder_legal_form`, so the rest is inert fixture mass (kept small to stay readable).
+function makeRecord(overrides: Partial = {}): ContractRecord & {
+ bidder_legal_form: string | null;
+} {
+ return {
+ id: 'c-1',
+ subject: 'Sample contract',
+ unp: '00001-2024-0001',
+ contractNumber: null,
+ documentNumber: null,
+ eopTenderId: null,
+ lotLabel: null,
+ signedAt: '2024-01-01',
+ publishedAt: null,
+ dateSuspect: false,
+ startDate: null,
+ endDate: null,
+ contractKind: null,
+ cpvCode: null,
+ cpvDescription: null,
+ sector: null,
+ procedureLabel: 'Открита',
+ bidsReceived: 1,
+ bidsRejected: 0,
+ bidsSme: 0,
+ bidsNonEea: 0,
+ euFunded: false,
+ euProgramme: null,
+ durationDays: null,
+ value: {
+ estimatedEur: 1000,
+ procedureEstimatedEur: 1000,
+ signingEur: 1000,
+ currentEur: 1000,
+ deltaPct: 0,
+ suspect: false,
+ },
+ frameworkAwards: null,
+ authority: {
+ slug: 'auth-1',
+ name: 'Some Authority',
+ displayName: 'Some Authority',
+ typeLabel: null,
+ settlement: 'Sofia',
+ eik: '000000000',
+ sector: null,
+ totalContracts: 1,
+ totalEur: 1000,
+ },
+ bidder: {
+ slug: 'bidder-1',
+ name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ',
+ displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ',
+ kind: 'company',
+ typeLabel: null,
+ settlement: 'Plovdiv',
+ eik: '123456789',
+ sector: null,
+ totalContracts: 1,
+ totalEur: 1000,
+ },
+ lots: null,
+ subcontractor: null,
+ sourceNames: { authority: 'Some Authority', bidder: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ' },
+ bidder_legal_form: 'ЕТ',
+ ...overrides,
+ };
+}
+
+function loaderArgs(id: string): Parameters[0] {
+ return {
+ params: { id },
+ context: { cloudflare: { env: { DB: {} as never } } },
+ } as unknown as Parameters[0];
+}
+
+beforeEach(() => {
+ vi.mocked(getContract).mockReset();
+});
+
+// MAJOR 2 (PR #183 review): the contract detail page (`/contracts/:id`) and its RRv7 single-fetch
+// `.data` twin share ONE loader, so masking + signalling in the loader covers BOTH the rendered HTML
+// and the machine-readable `.data` payload. This is the most-indexable surface (robots.txt does not
+// block /contracts/:id or its .data twin), so a sole-trader ЕИК leaked here is a worse exposure than
+// the already-closed .json/.csv paths. The policy mirrors `company.tsx:89` exactly: ЕИК (the
+// sensitive natural-person ID) → null, the trading displayName stays PUBLIC (ADR-0007 §6), and the
+// `X-Privacy-Mask: applied` marker is set so the worker translates it to `X-Robots-Tag: noindex`.
+describe('contract.data loader — natural-person bidder branch', () => {
+ it('clears the sole-trader ЕИК and marks the response noindex (covers HTML + .data twin)', async () => {
+ const natural = makeRecord();
+ vi.mocked(getContract).mockResolvedValueOnce(natural);
+
+ const result = await loader(loaderArgs('c-1'));
+
+ expect(result).toBeInstanceOf(Response);
+ const response = result as Response;
+ expect(response.status).toBe(200);
+ expect(response.headers.get('X-Privacy-Mask')).toBe('applied');
+ // The loader must NOT emit X-Robots-Tag directly — that is the worker's job (ADR-0008).
+ expect(response.headers.get('X-Robots-Tag')).toBeNull();
+ const body = (await response.json()) as { contract: { bidder: { eik: string | null } } };
+ // ЕИК is the sensitive natural-person identifier → masked on the shared object.
+ expect(body.contract.bidder.eik).toBeNull();
+ });
+
+ it('keeps the consortium ЕИК and sets no marker (kind=consortium guard, parity with JSON masker)', async () => {
+ const consortium = makeRecord({
+ bidder: {
+ slug: 'bidder-consortium',
+ name: 'ЕТ Иван Петров; Строй ООД',
+ displayName: 'ЕТ Иван Петров и др.',
+ kind: 'consortium',
+ typeLabel: null,
+ settlement: 'Sofia',
+ eik: '200000000',
+ sector: null,
+ totalContracts: 3,
+ totalEur: 5000,
+ },
+ sourceNames: { authority: 'Some Authority', bidder: 'ЕТ Иван Петров; Строй ООД' },
+ });
+ consortium.bidder_legal_form = null;
+ vi.mocked(getContract).mockResolvedValueOnce(consortium);
+
+ const result = await loader(loaderArgs('c-consortium'));
+
+ // Plain object return = no marker, no masking. A consortium is never over-masked/noindexed.
+ expect(result).not.toBeInstanceOf(Response);
+ const plain = result as { contract: ContractRecord };
+ expect(plain.contract.bidder.eik).toBe('200000000');
+ });
+});
+
+describe('contract.data loader — legal-entity bidder branch', () => {
+ it('returns a plain object (not a Response) with the ЕИК unchanged and no marker', async () => {
+ const legal = makeRecord({
+ bidder: {
+ slug: 'bidder-2',
+ name: 'СОФАРМА ТРЕЙДИНГ АД',
+ displayName: 'СОФАРМА ТРЕЙДИНГ АД',
+ kind: 'company',
+ typeLabel: null,
+ settlement: 'Sofia',
+ eik: '121817309',
+ sector: null,
+ totalContracts: 1,
+ totalEur: 1000,
+ },
+ sourceNames: { authority: 'Some Authority', bidder: 'СОФАРМА ТРЕЙДИНГ АД' },
+ });
+ legal.bidder_legal_form = 'АД';
+ vi.mocked(getContract).mockResolvedValueOnce(legal);
+
+ const result = await loader(loaderArgs('c-2'));
+
+ expect(result).not.toBeInstanceOf(Response);
+ const plain = result as { contract: ContractRecord };
+ expect(plain.contract.bidder.eik).toBe('121817309');
+ });
+});
+
+// The `headers()` export forwards the loader-set marker onto the HTML response so the worker's
+// `hardenResponse` can translate it. React Router's `getDocumentHeadersImpl` does not auto-propagate
+// loader headers (only Set-Cookie), so the route must forward explicitly — same shape as company.tsx.
+describe('contract.data headers() — forwards the privacy mask marker', () => {
+ it('returns X-Privacy-Mask + Cache-Control when the loader set the marker', () => {
+ const loaderHeaders = new Headers({ 'X-Privacy-Mask': 'applied' });
+
+ const result = headers({
+ loaderHeaders,
+ parentHeaders: new Headers(),
+ actionHeaders: new Headers(),
+ errorHeaders: undefined,
+ } as unknown as Parameters[0]);
+
+ expect(result['Cache-Control']).toBe('public, s-maxage=3600, stale-while-revalidate=86400');
+ expect(result['X-Privacy-Mask']).toBe('applied');
+ });
+
+ it('returns only Cache-Control when loaderHeaders carry no marker', () => {
+ const loaderHeaders = new Headers();
+
+ const result = headers({
+ loaderHeaders,
+ parentHeaders: new Headers(),
+ actionHeaders: new Headers(),
+ errorHeaders: undefined,
+ } as unknown as Parameters[0]);
+
+ expect(result['Cache-Control']).toBe('public, s-maxage=3600, stale-while-revalidate=86400');
+ expect('X-Privacy-Mask' in result).toBe(false);
+ });
+});
+
+// Worker-pipeline proof: the marker the loader sets must translate to X-Robots-Tag: noindex through
+// the real `applyPrivacyMaskHeaders` (the worker helper) and the marker must be stripped. This is the
+// contract the noindex guarantee depends on for both the HTML page and the `.data` twin.
+describe('contract.data worker pipeline — applyPrivacyMaskHeaders on the loader return', () => {
+ it('translates X-Privacy-Mask: applied into X-Robots-Tag: noindex and removes the marker', async () => {
+ const natural = makeRecord();
+ vi.mocked(getContract).mockResolvedValueOnce(natural);
+
+ const result = await loader(loaderArgs('c-1'));
+ expect(result).toBeInstanceOf(Response);
+ const response = result as Response;
+
+ applyPrivacyMaskHeaders(response.headers);
+
+ expect(response.headers.get('X-Robots-Tag')).toBe('noindex');
+ expect(response.headers.has('X-Privacy-Mask')).toBe(false);
+ });
+});
diff --git a/apps/web/app/routes/contract.tsx b/apps/web/app/routes/contract.tsx
index c9450cc63..ea882f7bd 100644
--- a/apps/web/app/routes/contract.tsx
+++ b/apps/web/app/routes/contract.tsx
@@ -1,5 +1,14 @@
import { Link } from 'react-router';
-import { count, longDate, money, moneyBare, plural, signedMoney, signedPct } from '@sigma/shared';
+import {
+ count,
+ isNaturalPersonBidder,
+ longDate,
+ money,
+ moneyBare,
+ plural,
+ signedMoney,
+ signedPct,
+} from '@sigma/shared';
import { contractIdFromSlug, contractSlug, getContract, getDb } from '@sigma/db';
import type { ContractDetail } from '@sigma/api-contract';
import type { Route } from './+types/contract';
@@ -12,6 +21,7 @@ import { annexNeedsExpand, annexParagraphs, annexPreview } from '../lib/annexTex
import { publicCache } from '../lib/cache';
import { eopSourceFiles } from '../lib/eopSource';
import { seoMeta } from '../lib/meta';
+import { markPrivacyMaskApplied } from '../lib/security';
/**
* Compose the muted sub-line under „Брой оферти". The AOP feed gives us the gross submitted count
@@ -81,7 +91,16 @@ export function meta({ data, params, matches }: Route.MetaArgs) {
});
}
-export function headers() {
+export function headers({ loaderHeaders }: Route.HeadersArgs) {
+ // Forward the internal privacy-mask marker set by the loader on the masked Response. React
+ // Router's `getDocumentHeadersImpl` does not auto-propagate loader headers (only `Set-Cookie`),
+ // so the route must forward explicitly — without this the worker `hardenResponse` cannot
+ // translate the marker into `X-Robots-Tag: noindex` on the HTML response (same shape as
+ // `company.tsx:47`). The `.data` RRv7 single-fetch twin shares the same loader, so the marker
+ // set on the loader Response covers both surfaces.
+ if (loaderHeaders.get('X-Privacy-Mask') === 'applied') {
+ return { 'Cache-Control': publicCache(3600), 'X-Privacy-Mask': 'applied' };
+ }
return { 'Cache-Control': publicCache(3600) };
}
@@ -89,6 +108,26 @@ export async function loader({ params, context }: Route.LoaderArgs) {
if (!params.id?.trim()) throw new Response('Not Found', { status: 404 });
const contract = await getContract(getDb(context.cloudflare.env), contractIdFromSlug(params.id));
if (!contract) throw new Response('Not Found', { status: 404 });
+
+ // Privacy policy for the contract detail page (ADR-0007 §6, decision recorded in PR #183 review):
+ // the trading `displayName` is PUBLIC, the ЕИК is the sensitive natural-person identifier. This
+ // is the MOST-indexable surface — `robots.txt` does not block `/contracts/:id` (or its `.data`
+ // twin), and the contract page is among the most-visited. Masking + signalling in the shared
+ // loader covers both the rendered HTML and the RRv7 single-fetch `.data` payload at once, rather
+ // than per-surface. The policy mirrors `company.tsx:89` exactly: ЕИК → null on the shared object,
+ // the marker is translated to `X-Robots-Tag: noindex` by `hardenResponse` in workers/app.ts. The
+ // `kind === 'consortium'` guard matches the JSON masker (MAJOR 1) and the CSV streamer
+ // (`bidder_kind !== 'consortium'`, contracts.ts:459) so a JV whose first member is a sole trader
+ // is never over-masked/noindexed. Legal-entity records keep the plain-object return (no marker).
+ const isNatural =
+ contract.bidder.kind !== 'consortium' &&
+ isNaturalPersonBidder(contract.bidder.name, contract.bidder_legal_form);
+ if (isNatural) {
+ contract.bidder.eik = null;
+ const responseHeaders = new Headers({ 'Cache-Control': publicCache(3600) });
+ markPrivacyMaskApplied(responseHeaders);
+ return Response.json({ contract }, { headers: responseHeaders });
+ }
return { contract };
}
From 85822a7b0fab7c9d185cd9f3d3b2f81bbf56601e Mon Sep 17 00:00:00 2001
From: Lyuboslav Lyubenov
Date: Tue, 28 Jul 2026 13:15:49 +0300
Subject: [PATCH 14/27] test(privacy): prove contract .data marker reaches
X-Robots-Tag via real worker
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The PR #183 review (MAJOR 3) noted the marker→`.data`→`X-Robots-Tag` forwarding
was only proven through fixtures that INJECT the marker by hand in the stubbed
RR handler — which proves the worker CAN translate a marker, not that a real
loader's marker survives the pipeline to the final `.data` HTTP response. That
left a „green tests, hidden gap" risk on the most-indexable surface.
Add four cases driving the REAL `worker.fetch` (→ handleRequest → hardenResponse
→ applyPrivacyMaskHeaders → edgeCache.put) against `/contracts/.data`:
masked sole-trader → noindex + marker stripped + masked body preserved; cached
entry carries noindex (HIT-path invariant); second request HITs and serves
noindex verbatim; legal-entity negative (no marker → no noindex). The handler
returns the exact shape `contract.tsx`'s masked loader branch now produces
(MAJOR 2), so this is an honest end-to-end proof of the forwarding guarantee.
Note: the review's suggested path-based worker match (the weekly-digest
`DIGEST_DETAIL_PATH` precedent) does not exist in this codebase — the worker
does no path-based matching; the marker-based design (ADR-0008) is the
established architecture and is sound, so this keeps it.
---
apps/web/workers/app.nofollow.test.ts | 107 ++++++++++++++++++++++++++
1 file changed, 107 insertions(+)
diff --git a/apps/web/workers/app.nofollow.test.ts b/apps/web/workers/app.nofollow.test.ts
index 232d6aa7a..90c522168 100644
--- a/apps/web/workers/app.nofollow.test.ts
+++ b/apps/web/workers/app.nofollow.test.ts
@@ -92,6 +92,44 @@ vi.mock('react-router', () => ({
});
}
+ // MAJOR 3 (PR #183 review): the contract detail `.data` twin. The handler returns the SAME shape
+ // the real `contract.tsx` loader now produces for a sole-trader contract — the marker is set by
+ // the loader (`markPrivacyMaskApplied`), the body carries the masked ЕИК (`bidder.eik === null`),
+ // and the content-type is RRv7's single-fetch `text/x-script`. This drives the REAL worker
+ // `fetch` → `handleRequest` → `hardenResponse` → `applyPrivacyMaskHeaders` pipeline, proving the
+ // loader-set marker actually reaches `X-Robots-Tag: noindex` on the final `.data` HTTP response
+ // (the gap the review named: the existing fixtures injected the marker by hand, which only proves
+ // the worker CAN translate a marker, not that a real loader's marker survives the pipeline).
+ if (path === '/contracts/sole-trader.data') {
+ return new Response(
+ 'turbo-stream-{"contract":{"bidder":{"eik":null,"name":"ЕТ ДРИФТ - НИКОЛАЙ КИРОВ"}}}',
+ {
+ status: 200,
+ headers: {
+ 'Content-Type': 'text/x-script',
+ 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
+ 'X-Privacy-Mask': 'applied',
+ },
+ },
+ );
+ }
+
+ if (path === '/contracts/legal-entity.data') {
+ // MAJOR 3 negative fixture: a legal-entity contract `.data` twin. The loader's legal-entity
+ // branch returns a plain object with the ЕИК intact and NO marker — the worker must NOT
+ // synthesise `X-Robots-Tag` (the marker is required for the translation, never path-based).
+ return new Response(
+ 'turbo-stream-{"contract":{"bidder":{"eik":"121817309","name":"СОФАРМА ТРЕЙДИНГ АД"}}}',
+ {
+ status: 200,
+ headers: {
+ 'Content-Type': 'text/x-script',
+ 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
+ },
+ },
+ );
+ }
+
return new Response('not found', { status: 404 });
},
}));
@@ -322,3 +360,72 @@ describe('app.ts hardenResponse — natural-person `.data` flow (F2 / T-008)', (
expect(cached!.has('X-Privacy-Mask')).toBe(false);
});
});
+
+// MAJOR 3 (PR #183 review): end-to-end proof that a loader-set `X-Privacy-Mask` marker actually
+// reaches `X-Robots-Tag: noindex` on the contract detail `.data` twin through the REAL worker
+// pipeline (fetch → handleRequest → hardenResponse → applyPrivacyMaskHeaders → edgeCache.put). The
+// review's concern: the marker-based design depends on the loader's marker surviving to the HTTP
+// response, and the prior fixtures injected the marker by hand in the stubbed handler — which only
+// proves the worker CAN translate a marker, not that a real loader's marker does. These cases drive
+// `worker.fetch` directly so the genuine `hardenResponse` runs; the handler returns the exact shape
+// `contract.tsx`'s masked loader branch now produces (MAJOR 2). This closes the "green tests, hidden
+// gap" risk for the most-indexable surface.
+describe('app.ts hardenResponse — contract detail `.data` marker→noindex through the real pipeline (MAJOR 3)', () => {
+ it('drives a masked sole-trader /contracts/.data to X-Robots-Tag: noindex, marker stripped, masked body preserved', async () => {
+ const url = 'https://x/contracts/sole-trader.data';
+ const { response, edge, body } = await fetchAndSettle(url);
+
+ expect(edge).toBe('MISS');
+ expect(response.headers.get('Content-Type')).toBe('text/x-script');
+ // The loader-set marker reached the worker and was translated — the core forwarding guarantee.
+ expect(response.headers.get('X-Robots-Tag')).toBe('noindex');
+ expect(response.headers.has('X-Privacy-Mask')).toBe(false);
+ // The masked payload (ЕИК nulled) survived the pipeline byte-for-byte.
+ expect(body).toContain('"eik":null');
+ expect(body).toContain('ЕТ ДРИФТ');
+ });
+
+ it('caches the post-hardening Response — the .data entry carries X-Robots-Tag: noindex, no marker', async () => {
+ const url = 'https://x/contracts/sole-trader.data';
+ await fetchAndSettle(url);
+
+ // The cached copy is what `edgeCache.put(key, hardened.clone())` stored AFTER translation. The
+ // HIT path will serve these headers verbatim without re-running the marker translation — the
+ // cache-safety invariant for the contract `.data` surface.
+ const cached = getCachedHeaders(url);
+ expect(cached).not.toBeNull();
+ expect(cached!.get('X-Robots-Tag')).toBe('noindex');
+ expect(cached!.get('Content-Type')).toBe('text/x-script');
+ expect(cached!.has('X-Privacy-Mask')).toBe(false);
+ });
+
+ it('a second /contracts/.data request HITs and serves X-Robots-Tag: noindex verbatim', async () => {
+ const url = 'https://x/contracts/sole-trader.data';
+ const first = await fetchAndSettle(url);
+ expect(first.edge).toBe('MISS');
+ expect(first.response.headers.get('X-Robots-Tag')).toBe('noindex');
+
+ const second = await fetchAndSettle(url);
+ expect(second.edge).toBe('HIT');
+ expect(second.response.headers.get('X-Robots-Tag')).toBe('noindex');
+ expect(second.response.headers.has('X-Privacy-Mask')).toBe(false);
+ expect(second.body).toContain('"eik":null');
+ });
+
+ it('does NOT emit X-Robots-Tag for a legal-entity contract `.data` request without a marker', async () => {
+ const url = 'https://x/contracts/legal-entity.data';
+ const { response, edge, body } = await fetchAndSettle(url);
+
+ expect(edge).toBe('MISS');
+ expect(response.headers.get('Content-Type')).toBe('text/x-script');
+ expect(response.headers.get('X-Robots-Tag')).toBeNull();
+ expect(response.headers.has('X-Privacy-Mask')).toBe(false);
+ // Legal-entity contract keeps the ЕИК — the worker must not invent a noindex policy.
+ expect(body).toContain('"eik":"121817309"');
+
+ const cached = getCachedHeaders(url);
+ expect(cached).not.toBeNull();
+ expect(cached!.get('X-Robots-Tag')).toBeNull();
+ expect(cached!.has('X-Privacy-Mask')).toBe(false);
+ });
+});
From 1d4ca8605db5250063dfcdde002754fa78b5f74d Mon Sep 17 00:00:00 2001
From: Lyuboslav Lyubenov
Date: Tue, 28 Jul 2026 16:29:47 +0300
Subject: [PATCH 15/27] test(privacy): adapt masking fixtures to upstream getDb
+ ContractParty changes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
After rebasing #183 onto upstream/main, the masking test fixtures needed two
adaptations to upstream's new APIs (no behaviour change to the production
masking logic):
- Add `getDb` to the `@sigma/db` mocks in the three loader tests. Upstream's
read-only D1 chokepoint (#199/#225) means loaders now call
`getContract(getDb(env), …)` instead of `getContract(env.DB, …)`; the mock
passes the env's DB through so the stubbed `getContract` still resolves.
- Add the new required `orderingUnit: null` (canonical-identity #251) and
`amendments: []` (annex history #165) fields to the `ContractParty` /
`ContractRecord` test builders so they satisfy the widened types.
All masking assertions unchanged. `pnpm --filter @sigma/web test` → 424
passing; `pnpm --filter @sigma/db test` → 297 passing; typecheck exit 0.
---
apps/web/app/routes/company.data.test.ts | 1 +
apps/web/app/routes/contract.data.test.ts | 6 ++++++
apps/web/app/routes/contract.json.test.ts | 10 ++++++++++
3 files changed, 17 insertions(+)
diff --git a/apps/web/app/routes/company.data.test.ts b/apps/web/app/routes/company.data.test.ts
index e44227f5f..fd6349d3f 100644
--- a/apps/web/app/routes/company.data.test.ts
+++ b/apps/web/app/routes/company.data.test.ts
@@ -11,6 +11,7 @@ vi.mock('@sigma/db', () => ({
getCompany: vi.fn(),
getSpendingTrend: vi.fn(),
getEntityNetwork: vi.fn(),
+ getDb: (env: unknown) => (env as { DB: unknown }).DB,
}));
vi.mock('../lib/coverage', async () => {
diff --git a/apps/web/app/routes/contract.data.test.ts b/apps/web/app/routes/contract.data.test.ts
index 9784e34fb..b6a3d572d 100644
--- a/apps/web/app/routes/contract.data.test.ts
+++ b/apps/web/app/routes/contract.data.test.ts
@@ -6,6 +6,7 @@ import { headers, loader } from './contract';
vi.mock('@sigma/db', () => ({
getContract: vi.fn(),
+ getDb: (env: unknown) => (env as { DB: unknown }).DB,
contractIdFromSlug: (slug: string) => 'c:' + slug,
}));
@@ -50,6 +51,7 @@ function makeRecord(overrides: Partial = {}): ContractRecord & {
frameworkAwards: null,
authority: {
slug: 'auth-1',
+ orderingUnit: null,
name: 'Some Authority',
displayName: 'Some Authority',
typeLabel: null,
@@ -61,6 +63,7 @@ function makeRecord(overrides: Partial = {}): ContractRecord & {
},
bidder: {
slug: 'bidder-1',
+ orderingUnit: null,
name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ',
displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ',
kind: 'company',
@@ -73,6 +76,7 @@ function makeRecord(overrides: Partial = {}): ContractRecord & {
},
lots: null,
subcontractor: null,
+ amendments: [],
sourceNames: { authority: 'Some Authority', bidder: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ' },
bidder_legal_form: 'ЕТ',
...overrides,
@@ -119,6 +123,7 @@ describe('contract.data loader — natural-person bidder branch', () => {
const consortium = makeRecord({
bidder: {
slug: 'bidder-consortium',
+ orderingUnit: null,
name: 'ЕТ Иван Петров; Строй ООД',
displayName: 'ЕТ Иван Петров и др.',
kind: 'consortium',
@@ -148,6 +153,7 @@ describe('contract.data loader — legal-entity bidder branch', () => {
const legal = makeRecord({
bidder: {
slug: 'bidder-2',
+ orderingUnit: null,
name: 'СОФАРМА ТРЕЙДИНГ АД',
displayName: 'СОФАРМА ТРЕЙДИНГ АД',
kind: 'company',
diff --git a/apps/web/app/routes/contract.json.test.ts b/apps/web/app/routes/contract.json.test.ts
index a7046f153..f56e749a0 100644
--- a/apps/web/app/routes/contract.json.test.ts
+++ b/apps/web/app/routes/contract.json.test.ts
@@ -4,6 +4,7 @@ import { MASKED_NATURAL_PERSON_LABEL } from '@sigma/shared';
vi.mock('@sigma/db', () => ({
getContract: vi.fn(),
+ getDb: (env: unknown) => (env as { DB: unknown }).DB,
contractIdFromSlug: (slug: string) => 'c:' + slug,
}));
@@ -49,6 +50,7 @@ function makeRecord(overrides: Partial = {}): ContractRecord & {
frameworkAwards: null,
authority: {
slug: 'auth-1',
+ orderingUnit: null,
name: 'Some Authority',
displayName: 'Some Authority',
typeLabel: null,
@@ -60,6 +62,7 @@ function makeRecord(overrides: Partial = {}): ContractRecord & {
},
bidder: {
slug: 'bidder-1',
+ orderingUnit: null,
name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ',
displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ',
kind: 'company',
@@ -72,6 +75,7 @@ function makeRecord(overrides: Partial = {}): ContractRecord & {
},
lots: null,
subcontractor: null,
+ amendments: [],
sourceNames: {
authority: 'Some Authority',
bidder: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ',
@@ -105,6 +109,7 @@ describe('maskContractForPrivacy', () => {
const record = makeRecord({
bidder: {
slug: 'bidder-2',
+ orderingUnit: null,
name: 'СОФАРМА ТРЕЙДИНГ АД',
displayName: 'СОФАРМА ТРЕЙДИНГ АД',
kind: 'company',
@@ -147,6 +152,7 @@ describe('maskContractForPrivacy', () => {
const consortium = makeRecord({
bidder: {
slug: 'bidder-consortium',
+ orderingUnit: null,
name: 'ЕТ Иван Петров; Строй ООД',
displayName: 'ЕТ Иван Петров и др.',
kind: 'consortium',
@@ -179,6 +185,7 @@ describe('maskContractForPrivacy', () => {
const consortium = makeRecord({
bidder: {
slug: 'bidder-consortium',
+ orderingUnit: null,
name: 'ЕТ Петров; ВИСТА ООД',
displayName: 'ЕТ Петров и др.',
kind: 'consortium',
@@ -231,6 +238,7 @@ describe('contract.json loader', () => {
const record = makeRecord({
bidder: {
slug: 'bidder-2',
+ orderingUnit: null,
name: 'СОФАРМА ТРЕЙДИНГ АД',
displayName: 'СОФАРМА ТРЕЙДИНГ АД',
kind: 'company',
@@ -281,6 +289,7 @@ describe('contract.json loader', () => {
const consortium = makeRecord({
bidder: {
slug: 'bidder-consortium',
+ orderingUnit: null,
name: 'ЕТ Иван Петров; Строй ООД',
displayName: 'ЕТ Иван Петров и др.',
kind: 'consortium',
@@ -319,6 +328,7 @@ describe('contract.json loader', () => {
const legal = makeRecord({
bidder: {
slug: 'bidder-2',
+ orderingUnit: null,
name: 'СОФАРМА ТРЕЙДИНГ АД',
displayName: 'СОФАРМА ТРЕЙДИНГ АД',
kind: 'company',
From 53ca492d7b238d2870c560d64f5bb06ac1122f49 Mon Sep 17 00:00:00 2001
From: LyuboslavLyubenov
Date: Wed, 5 Aug 2026 13:12:00 +0300
Subject: [PATCH 16/27] test(privacy): satisfy ContractRecord shape and
prettier formatting after rebase
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The merge onto current upstream surfaced three pre-existing issues that need to be
addressed for the test suite and lint to pass:
- apps/web/app/routes/contract.json.test.ts and contract.data.test.ts: add
cohort: null to the makeRecord() fixture. Upstream's ContractRecord type now
requires ContractCohortBenchmark | null (the 'Подобни договори' benchmark from
the new cohort-band feature in PR #210), and the fixtures predated it.
- apps/web/app/lib/csv-export.test.ts, packages/db/src/queries/companies.ts,
packages/db/src/queries/contracts.ts: prettier format. These three files were
reformatted by the upstream prettier version (3.8.3 vs whatever the original
PR ran on) — same content, just whitespace. The lint gate is blocking on
these, so format fixes are non-optional.
Verification: pnpm typecheck (7/7 packages clean), pnpm --filter @sigma/web
test (532 passing), pnpm --filter @sigma/shared test (60 passing), pnpm lint
(prettier --check clean).
---
apps/web/app/lib/csv-export.test.ts | 51 +++++++++++++++++------
apps/web/app/routes/contract.data.test.ts | 1 +
apps/web/app/routes/contract.json.test.ts | 1 +
packages/db/src/queries/companies.ts | 3 +-
packages/db/src/queries/contracts.ts | 3 +-
5 files changed, 44 insertions(+), 15 deletions(-)
diff --git a/apps/web/app/lib/csv-export.test.ts b/apps/web/app/lib/csv-export.test.ts
index ff7919cbf..56d236a29 100644
--- a/apps/web/app/lib/csv-export.test.ts
+++ b/apps/web/app/lib/csv-export.test.ts
@@ -597,38 +597,63 @@ describe('servedCsvExport privacy', () => {
it('MISS (contracts): marks exactly once', async () => {
const r2 = new InMemoryR2();
- await serve(r2, vi.fn(() => csvResponse()), { route: 'contracts' });
+ await serve(
+ r2,
+ vi.fn(() => csvResponse()),
+ { route: 'contracts' },
+ );
expect(markSpy).toHaveBeenCalledTimes(1);
});
it('HIT (companies): marks exactly once', async () => {
const r2 = new InMemoryR2();
- await (await serve(r2, vi.fn(() => csvResponse()), { route: 'companies' })).text();
+ await (
+ await serve(
+ r2,
+ vi.fn(() => csvResponse()),
+ { route: 'companies' },
+ )
+ ).text();
markSpy.mockClear();
- await serve(r2, vi.fn(() => csvResponse('hit\n')), { route: 'companies' });
+ await serve(
+ r2,
+ vi.fn(() => csvResponse('hit\n')),
+ { route: 'companies' },
+ );
expect(markSpy).toHaveBeenCalledTimes(1);
});
it('dynamic (authorities, filtered): marks exactly once', async () => {
const r2 = new InMemoryR2();
- await serve(r2, vi.fn(() => csvResponse('filtered\n')), {
- route: 'authorities',
- params: { sort: 'value-desc', q: 'foo' },
- });
+ await serve(
+ r2,
+ vi.fn(() => csvResponse('filtered\n')),
+ {
+ route: 'authorities',
+ params: { sort: 'value-desc', q: 'foo' },
+ },
+ );
expect(markSpy).toHaveBeenCalledTimes(1);
});
it('304 (conditional GET on a primed object): marks exactly once', async () => {
const r2 = new InMemoryR2();
- const primed = await serve(r2, vi.fn(() => csvResponse()));
+ const primed = await serve(
+ r2,
+ vi.fn(() => csvResponse()),
+ );
const etag = primed.headers.get('ETag');
await primed.text();
markSpy.mockClear();
- await serve(r2, vi.fn(() => csvResponse('hit\n')), {
- request: new Request('http://local/contracts.csv', {
- headers: { 'If-None-Match': etag ?? '' },
- }),
- });
+ await serve(
+ r2,
+ vi.fn(() => csvResponse('hit\n')),
+ {
+ request: new Request('http://local/contracts.csv', {
+ headers: { 'If-None-Match': etag ?? '' },
+ }),
+ },
+ );
expect(markSpy).toHaveBeenCalledTimes(1);
});
});
diff --git a/apps/web/app/routes/contract.data.test.ts b/apps/web/app/routes/contract.data.test.ts
index 784987f0f..c4b41f46e 100644
--- a/apps/web/app/routes/contract.data.test.ts
+++ b/apps/web/app/routes/contract.data.test.ts
@@ -76,6 +76,7 @@ function makeRecord(overrides: Partial = {}): ContractRecord & {
},
lots: null,
subcontractor: null,
+ cohort: null,
amendments: [],
sourceNames: { authority: 'Some Authority', bidder: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ' },
bidder_legal_form: 'ЕТ',
diff --git a/apps/web/app/routes/contract.json.test.ts b/apps/web/app/routes/contract.json.test.ts
index c80981580..0efde3c1c 100644
--- a/apps/web/app/routes/contract.json.test.ts
+++ b/apps/web/app/routes/contract.json.test.ts
@@ -75,6 +75,7 @@ function makeRecord(overrides: Partial = {}): ContractRecord & {
},
lots: null,
subcontractor: null,
+ cohort: null,
amendments: [],
sourceNames: {
authority: 'Some Authority',
diff --git a/packages/db/src/queries/companies.ts b/packages/db/src/queries/companies.ts
index f8c3b6462..96e78a925 100644
--- a/packages/db/src/queries/companies.ts
+++ b/packages/db/src/queries/companies.ts
@@ -271,7 +271,8 @@ export function streamCompaniesCsv(db: D1Database, p: CompanyListParams): Respon
// legal entity even if a lead member's name / legal_form looks like a sole trader). Guard with
// `kind` first so a consortium such as „ЕТ Иван Петров; Строй ООД" is NOT masked as a natural
// person — it keeps its name + ЕИК. Mirrors the guard in streamContractsCsv (PR #183 T-006).
- const isNatural = r.kind !== 'consortium' && isNaturalPersonBidder(cleanName(r.name), r.legal_form);
+ const isNatural =
+ r.kind !== 'consortium' && isNaturalPersonBidder(cleanName(r.name), r.legal_form);
const name = isNatural ? MASKED_NATURAL_PERSON_LABEL : r.name;
const eik = isNatural ? '' : r.eik;
block +=
diff --git a/packages/db/src/queries/contracts.ts b/packages/db/src/queries/contracts.ts
index c7b8eed28..e464f3f20 100644
--- a/packages/db/src/queries/contracts.ts
+++ b/packages/db/src/queries/contracts.ts
@@ -468,7 +468,8 @@ export function streamContractsCsv(db: D1Database, p: ContractListParams): Respo
// legal entity even if a lead member's name / legal_form looks like a sole trader). Guard with
// `bidder_kind` first so a consortium such as „ЕТ Иван Петров; Строй ООД" is NOT over-masked as
// a natural person — it keeps the „… и др." shape and the consortium ЕИК.
- const isNatural = r.bidder_kind !== 'consortium' && isNaturalPersonBidder(bidderName, r.bidder_legal_form);
+ const isNatural =
+ r.bidder_kind !== 'consortium' && isNaturalPersonBidder(bidderName, r.bidder_legal_form);
const contractor = isNatural
? MASKED_NATURAL_PERSON_LABEL
: entityName(bidderName, r.bidder_kind);
From e85f17ce697442f192811ad5323ce505cbfe77ab Mon Sep 17 00:00:00 2001
From: LyuboslavLyubenov
Date: Sun, 16 Aug 2026 13:07:51 +0300
Subject: [PATCH 17/27] test(privacy): satisfy
ContractValueTimeline.currentValueDoubled after rebase
---
apps/web/app/routes/contract.data.test.ts | 1 +
apps/web/app/routes/contract.json.test.ts | 1 +
2 files changed, 2 insertions(+)
diff --git a/apps/web/app/routes/contract.data.test.ts b/apps/web/app/routes/contract.data.test.ts
index fda1be66d..6b9758fd5 100644
--- a/apps/web/app/routes/contract.data.test.ts
+++ b/apps/web/app/routes/contract.data.test.ts
@@ -47,6 +47,7 @@ function makeRecord(overrides: Partial = {}): ContractRecord & {
currentEur: 1000,
deltaPct: 0,
suspect: false,
+ currentValueDoubled: false,
},
frameworkAwards: null,
authority: {
diff --git a/apps/web/app/routes/contract.json.test.ts b/apps/web/app/routes/contract.json.test.ts
index 0efde3c1c..373ad075d 100644
--- a/apps/web/app/routes/contract.json.test.ts
+++ b/apps/web/app/routes/contract.json.test.ts
@@ -46,6 +46,7 @@ function makeRecord(overrides: Partial = {}): ContractRecord & {
currentEur: 1000,
deltaPct: 0,
suspect: false,
+ currentValueDoubled: false,
},
frameworkAwards: null,
authority: {
From 8521e4da7ecc78b916d94ca2089efef21f291dcb Mon Sep 17 00:00:00 2001
From: LyuboslavLyubenov
Date: Wed, 19 Aug 2026 13:09:41 +0300
Subject: [PATCH 18/27] docs: clean leftover merge-conflict markers and align
ADR numbers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PR #183 review (ydimitrof, 2026-08-18) flagged three doc issues:
- docs/adr/README.md had a stray `<<<<<<< HEAD` line on the index table
(PR branch carried 0033-0034 from the privacy work; upstream brought
0033-0037 from the registry-evidence work; the merge was botched and
dropped the closing half of the conflict).
- docs/README.md had the same kind of conflict — both sides listed
different amendment-implementation plans (#305 vs #306). Kept both.
- docs/architecture.md referenced ADR (0033)/(0034) but linked to the
privacy ADRs at adr/0036-privacy-masking.md / 0037-centralized-x-robots-tag-worker.md.
Fixed the visible numbers to (0036)/(0037) so the reader is not misled
into looking up unrelated registry-evidence records.
Verified with `git grep -nE '^(<{7}|={7}|>{7})'` — no conflict markers remain.
---
docs/README.md | 3 ---
docs/adr/README.md | 1 -
docs/architecture.md | 4 ++--
3 files changed, 2 insertions(+), 6 deletions(-)
diff --git a/docs/README.md b/docs/README.md
index 187cfe4f7..29804698c 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -12,11 +12,8 @@
- [`etl-architecture.md`](etl-architecture.md) — целевата ETL архитектура (RFC): предложение за състоянието и реда на изпълнение.
- [`v1-implementation-plan.md`](v1-implementation-plan.md) — precompute слоят и пагинацията (защо rollup-и и keyset вместо per-request GROUP BY / OFFSET).
- [`implementation-plans/286-ocds-amendment-unp.md`](implementation-plans/286-ocds-amendment-unp.md) — защо OCDS анексите не се свързват с договор (OCID вместо УНП) и планът за поправка през bridge-а `tender.id → УНП` + prefer-EOP dedup (#286).
-<<<<<<< HEAD
- [`implementation-plans/306-amendment-contract-namespace-link.md`](implementation-plans/306-amendment-contract-namespace-link.md) — защо 1 937 EOP анекса не се свързват с договор (номерът на анекса е в друго именно пространство от деловодния номер) и планът за поправка чрез value-anchor (`value_before → signing_value`, 99.99% точност) (#306).
-=======
- [`implementation-plans/305-amendment-value-double-count.md`](implementation-plans/305-amendment-value-double-count.md) — защо стойността на анекс се удвоява (ЦАИС ЕОП слага новия **тотал** в полето за промяна) и планът за откриване/поправка: `annex_total_suspect` флаг + текстова хеуристика за възстановяване на истинския тотал (#305).
->>>>>>> origin/main
- [`integrity-gate.md`](integrity-gate.md) — reconciliation gate-ът: hard asserts върху тоталите при import/CI.
- [`anomaly-report.md`](anomaly-report.md) — cross-row аномалии при опресняване: какво `value_flag` не хваща на ниво отделен договор.
- [`deploy.md`](deploy.md) — деплой към Cloudflare: двата Worker-а (`sigma`, `sigma-etl`) и споделеният D1 per environment.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 7d9a5e959..7088d7bbd 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -40,7 +40,6 @@
| [0030](0030-family-ownership-withheld-nameless-aggregate.md) | Дял на свързано лице не влиза в поименната повърхност (v1): събира се и се одитира, но се отчита само като безименен сбор (GDPR C-37/20; заменя ADR-0023) | Заменено от [ADR-0032](0032-family-ownership-published-under-public-interest.md) |
| [0031](0031-suppressions-version-controlled-fingerprinted.md) | Свалянията на връзки са версиониран списък с HMAC-отпечатък (salt = CI secret), прилаган при зареждане — не служебна таблица; не изтича сигнала „кой е свален" към прод | Прието |
| [0032](0032-family-ownership-published-under-public-interest.md) | Дял на свързано лице се публикува на поименната повърхност наравно със собствения — на основание надделяващ обществен интерес (ЗДОИ чл. 41и; C-184/20 забранява само поименните данни); близкият никога не се назовава, връзката не се твърди; заменя ADR-0030 | Прието |
-<<<<<<< HEAD
| [0033](0033-registry-evidence-replaces-name-distinctiveness.md) | Връзка се публикува само срещу проверим факт от Търговския регистър — доказателственият ред заменя нивата по отличителност на името; евристиката вече обосновава твърдение (изрично изключение от ADR-0007), барът за акционерни форми е обединение от три сигнала, монотонността е гейт, не хранилище; заменя ADR-0009/0015/0017 | Прието (дизайн) |
| [0034](0034-registry-lookups-and-decisions-share-one-monthly-run.md) | Справките в ТР и решенията текат в едно месечно задание — суровите актове не преживяват изпълнителя, а решението не може да се вземе без тях; заменя твърдението за каденцията в ADR-0033 | Прието |
| [0035](0035-registry-evidence-must-also-establish-the-company.md) | Стъпало 2 („Документ") публикува само ако извън фирменото наименование нещо установи, че дружеството е декларираното — деклариран ЕИК, съвпадащо седалище или отличителна фирма; иначе `document_uncorroborated`, което се брои; заменя твърдението в ADR-0033, че стъпало 2 не е гейтнато | Прието |
diff --git a/docs/architecture.md b/docs/architecture.md
index 3adcdd915..222a553d2 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -40,8 +40,8 @@
Виж индекса в [`adr/README.md`](adr/README.md). Ключовите за v1: рендиране/сигурност (0001),
D1 като хранилище (0002), `value_flag` стойностна база (0003), CSP `style-src` (0004), blue/green
rollback (0005), dedup на двата източника (0006), [`noindex` + маскиране на естествено-личностни
-идентификатори в машинно-четим изход (0033)](adr/0036-privacy-masking.md) и
-[централизирано авторство на `X-Robots-Tag: noindex` в worker-а (0034)](adr/0037-centralized-x-robots-tag-worker.md).
+идентификатори в машинно-четим изход (0036)](adr/0036-privacy-masking.md) и
+[централизирано авторство на `X-Robots-Tag: noindex` в worker-а (0037)](adr/0037-centralized-x-robots-tag-worker.md).
## Справочни документи
From 5d33ea52c27c2b9868f9147dcf283f600af69dc0 Mon Sep 17 00:00:00 2001
From: LyuboslavLyubenov
Date: Wed, 19 Aug 2026 13:09:48 +0300
Subject: [PATCH 19/27] fix(privacy): guard company loader against consortium
over-mask
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PR #183 review (ydimitrof, BLOCKING #1, 2026-08-18) caught the same
over-masking hole that contract.tsx and contract.json.tsx already guard
against: `isNaturalPersonBidder` delegates consortium filtering to the
caller, so a ДЗЗД whose first member is an ЕТ ("ЕТ Иван Петров; Строй
ООД") was being over-masked to "Частно лице" with a zeroed ЕИК — exactly
the privacy-safe-but-information-losing case the contract siblings
already prevent.
Added a regression test (consortium-with-sole-trader-first-member) that
asserts the loader returns the plain object with `company.eik` unchanged
and no `X-Privacy-Mask` marker. Implemented the mirror guard:
`company.kind !== 'consortium'` precedes the `isNaturalPersonBidder`
check. Updated the inline ADR-0036 §3 comment to call out the symmetry
with the contract loaders.
---
apps/web/app/routes/company.data.test.ts | 27 ++++++++++++++++++++++++
apps/web/app/routes/company.tsx | 12 ++++++++++-
2 files changed, 38 insertions(+), 1 deletion(-)
diff --git a/apps/web/app/routes/company.data.test.ts b/apps/web/app/routes/company.data.test.ts
index 98786c588..e2bfd8b79 100644
--- a/apps/web/app/routes/company.data.test.ts
+++ b/apps/web/app/routes/company.data.test.ts
@@ -113,6 +113,33 @@ beforeEach(() => {
vi.mocked(getEntityNetwork).mockReset();
});
+describe('company.data loader — consortium-with-sole-trader-first-member branch (mirror of contract.tsx/contract.json.tsx)', () => {
+ it('does NOT over-mask a consortium whose displayName starts with "ЕТ " — keeps ЕИК, no privacy marker (regression for company.tsx consortium guard)', async () => {
+ // A ДЗЗД (consortium) whose first member is a sole trader has a display name beginning
+ // "ЕТ …". `isNaturalPersonBidder` delegates consortium filtering to the caller, so without
+ // the explicit `kind !== 'consortium'` guard the loader would zero the consortium's ЕИК and
+ // stamp noindex — the same over-masking bug that contract.tsx / contract.json.tsx guard against
+ // (MAJOR 1 in the PR #183 review). The fix mirrors those guards: the consortium branch must
+ // return the plain object with company.eik unchanged and no marker.
+ const consortiumWithSoleTraderFirst = makeCompany({
+ kind: 'consortium',
+ isConsortium: true,
+ legalForm: null,
+ displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ; СТРОЙ ООД',
+ name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ; СТРОЙ ООД',
+ eik: '121817309',
+ });
+ installStubs(consortiumWithSoleTraderFirst);
+
+ const result = await loader(loaderArgs('121817309'));
+
+ expect(result).not.toBeInstanceOf(Response);
+ const plain = result as { company: CompanyDetail };
+ expect(plain.company.eik).toBe('121817309');
+ expect(plain.company.displayName).toBe('ЕТ ДРИФТ - НИКОЛАЙ КИРОВ; СТРОЙ ООД');
+ });
+});
+
describe('company.data loader — natural-person branch', () => {
it('masks only the ЕИК (sensitive ID), keeps the public trading displayName, and marks noindex (behaviors 1 + 2)', async () => {
const natural = makeCompany({ legalForm: 'ЕТ', displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ' });
diff --git a/apps/web/app/routes/company.tsx b/apps/web/app/routes/company.tsx
index 1eb7568ad..a698612e1 100644
--- a/apps/web/app/routes/company.tsx
+++ b/apps/web/app/routes/company.tsx
@@ -86,7 +86,17 @@ export async function loader({ params, context }: Route.LoaderArgs) {
// `X-Privacy-Mask` marker is translated into `X-Robots-Tag: noindex` by `hardenResponse` in
// `apps/web/workers/app.ts`. Legal-entity records keep the plain-object return (no marker, no
// mutation).
- if (isNaturalPersonBidder(company.displayName, company.legalForm)) {
+ //
+ // Consortium guard (mirrors `contract.tsx:133-134` and `contract.json.tsx:27`): a ДЗЗД whose
+ // first member is a sole trader has a display name beginning "ЕТ …" — `isNaturalPersonBidder`
+ // delegates consortium filtering to the caller, so without this guard the loader would
+ // over-mask the consortium, zeroing its ЕИК and stamping noindex (regression caught by
+ // `company.data.test.ts` — the consortium branch returns a plain object with `company.eik`
+ // unchanged and no marker, same as legal-entity records).
+ if (
+ company.kind !== 'consortium' &&
+ isNaturalPersonBidder(company.displayName, company.legalForm)
+ ) {
company.eik = null;
return Response.json(
{ company, coverage, trend, network },
From 3cd5d23cc7e5f165ea520a3319cedef347268f95 Mon Sep 17 00:00:00 2001
From: LyuboslavLyubenov
Date: Wed, 19 Aug 2026 13:09:53 +0300
Subject: [PATCH 20/27] perf(db): project legal_form only on the CSV path
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PR #183 review (ydimitrof, #5, 2026-08-18): `source()` in
companies.ts was projecting `b.legal_form AS legal_form` (and doing a
LEFT JOIN on bidders) on every `listCompanies` query, but
`toCompanyListItem` does not consume it — only the CSV streamer
(`streamCompaniesCsv`) needs it for the natural-person masker. The
join is on PK so the cost was bounded, but on uncached list queries it
was wasted work.
Added a `legalForm` option to `source()` that controls only the
unfiltered rollup subquery. The base-aggregation CTE always projects
legal_form (it already INNER JOINs bidders for the GROUP BY, so the
projection is free, and keeping it consistent lets both consumers share
the same SQL when filters are active). Added two tests pinning the SQL
shape: list path must not contain `LEFT JOIN bidders` /
`b.legal_form AS legal_form` on the rollup branch; CSV path must.
---
packages/db/src/queries/companies.test.ts | 37 +++++++++++++++++++++++
packages/db/src/queries/companies.ts | 26 +++++++++++++---
2 files changed, 58 insertions(+), 5 deletions(-)
diff --git a/packages/db/src/queries/companies.test.ts b/packages/db/src/queries/companies.test.ts
index ac2af47c6..5a93beeb1 100644
--- a/packages/db/src/queries/companies.test.ts
+++ b/packages/db/src/queries/companies.test.ts
@@ -366,6 +366,43 @@ describe('streamCompaniesCsv masking', () => {
});
});
+describe('listCompanies source() projection — legal_form only when needed (PR #183 review T-007)', () => {
+ // The listCompanies hot path returns CompanyListItem via toCompanyListItem, which does not read
+ // legal_form. Projecting `b.legal_form AS legal_form` plus the LEFT JOIN on every list query is
+ // wasted work. Only the CSV streamer needs legal_form (for the natural-person masker). The
+ // unfiltered rollup subquery must therefore omit the join + projection on the list path but
+ // keep them on the CSV path. (The base-aggregation CTE is shared and must project legal_form
+ // because the CSV path may go through it too.)
+ function spySqlDb(): { db: D1Database; sql: string[] } {
+ const db = fakeDb();
+ const sql: string[] = [];
+ const real = db.prepare.bind(db);
+ db.prepare = ((q: string) => {
+ sql.push(q);
+ return real(q);
+ }) as typeof db.prepare;
+ return { db, sql };
+ }
+
+ it('omits LEFT JOIN bidders in the rollup subquery on the listCompanies path (no legal_form needed)', async () => {
+ const { db, sql } = spySqlDb();
+ await listCompanies(db, {});
+ const rollupQueries = sql.filter((q) => q.includes('company_totals') && q.includes('FROM ('));
+ expect(rollupQueries.length).toBeGreaterThan(0);
+ expect(rollupQueries.every((q) => !q.includes('LEFT JOIN bidders'))).toBe(true);
+ expect(rollupQueries.every((q) => !q.includes('b.legal_form AS legal_form'))).toBe(true);
+ });
+
+ it('keeps LEFT JOIN bidders + b.legal_form projection in the rollup subquery on the streamCompaniesCsv path', async () => {
+ const { db, sql } = spySqlDb();
+ await streamCompaniesCsv(db, {}).text();
+ const rollupQueries = sql.filter((q) => q.includes('company_totals') && q.includes('FROM ('));
+ expect(rollupQueries.length).toBeGreaterThan(0);
+ expect(rollupQueries.some((q) => q.includes('LEFT JOIN bidders'))).toBe(true);
+ expect(rollupQueries.some((q) => q.includes('b.legal_form AS legal_form'))).toBe(true);
+ });
+});
+
describe('prototype-key params (untrusted query values)', () => {
function spyDb(): { db: D1Database; sql: string[] } {
const db = fakeDb();
diff --git a/packages/db/src/queries/companies.ts b/packages/db/src/queries/companies.ts
index 96e78a925..7e365b8a1 100644
--- a/packages/db/src/queries/companies.ts
+++ b/packages/db/src/queries/companies.ts
@@ -75,13 +75,28 @@ function needsBase(p: CompanyListParams): boolean {
/**
* The FROM source: the rollup table, or a scoped base-aggregation CTE for sector/year/EU cross-cuts.
* Keep consumed filter keys in sync with COMPANY_FILTER_KEYS and companyFilterSignature().
+ *
+ * `legalForm` controls whether the `b.legal_form AS legal_form` projection is added on the
+ * rollup subquery (the unfiltered path). Only the CSV streamer needs it (the natural-person
+ * masker keys off `r.legal_form`); the HTML list path maps rows through `toCompanyListItem`
+ * which drops it. The base-aggregation CTE (filtered path) always projects legal_form: it
+ * already does an INNER JOIN on `bidders b` for the grouping, so the projection is free, and
+ * keeping it consistent means the same SQL works for both `listCompanies` and
+ * `streamCompaniesCsv` when filters are active.
*/
-function source(p: CompanyListParams): { from: string; params: unknown[] } {
- if (!needsBase(p))
+function source(
+ p: CompanyListParams,
+ opts: { legalForm?: boolean } = {},
+): { from: string; params: unknown[] } {
+ const projectLegalForm = opts.legalForm ?? false;
+ if (!needsBase(p)) {
+ const project = projectLegalForm ? ', b.legal_form AS legal_form' : '';
+ const join = projectLegalForm ? ' LEFT JOIN bidders AS b ON b.id = ct.bidder_id' : '';
return {
- from: `(SELECT ct.*, b.legal_form AS legal_form FROM company_totals AS ct LEFT JOIN bidders AS b ON b.id = ct.bidder_id)`,
+ from: `(SELECT ct.*${project} FROM company_totals AS ct${join})`,
params: [],
};
+ }
const where: string[] = ['c.amount_eur IS NOT NULL'];
const params: unknown[] = [];
if (p.sectors?.length) {
@@ -96,6 +111,7 @@ function source(p: CompanyListParams): { from: string; params: unknown[] } {
if (eu === 'eu') where.push('c.eu_funded = 1');
else if (eu === 'national') where.push('(c.eu_funded IS NULL OR c.eu_funded = 0)');
const single = p.sectors?.length === 1 ? p.sectors[0]! : null;
+ // Base-aggregation CTE always projects legal_form (see docstring above).
const from = `(
SELECT b.id AS bidder_id, b.name, b.kind, b.ownership_kind, b.eik_normalized AS eik, b.eik_valid, b.settlement,
b.legal_form AS legal_form,
@@ -150,7 +166,7 @@ export async function listCompanies(
): Promise> {
const sort = SORTS[p.sort as keyof typeof SORTS] ?? SORTS['won'];
const pageSize = p.pageSize ?? 25;
- const src = source(p);
+ const src = source(p); // legal_form not needed — toCompanyListItem drops it
const ew = entityWhere(p);
const signature = companyFilterSignature(p);
const ks = keyset({
@@ -233,7 +249,7 @@ export async function getCompanyFacets(db: D1Database): Promise {
/** Streamed CSV of the company leaderboard (honours the same filters as the list page). */
export function streamCompaniesCsv(db: D1Database, p: CompanyListParams): Response {
- const src = source(p);
+ const src = source(p, { legalForm: true }); // CSV masker keys off r.legal_form
const ew = entityWhere(p);
const cols = [
'eik',
From a13e9a547530064d0e60104552a2ad7e1fa53e43 Mon Sep 17 00:00:00 2001
From: LyuboslavLyubenov
Date: Wed, 19 Aug 2026 13:09:59 +0300
Subject: [PATCH 21/27] docs(security): document the blanket-CSV call site for
markPrivacyMaskApplied
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PR #183 review (ydimitrof, #3, 2026-08-18): the docstring on
`markPrivacyMaskApplied` said "callers must invoke this only when
the response body contains masked natural-person data", but
`markCsvCache` in csv-export.ts invokes it unconditionally for every
CSV response. The blanket call is intentional — the policy documented
in `apps/web/app/routes/privacy.tsx` and `docs/privacy-masking.md`
applies `noindex` to all three public CSV exports regardless of body
content, because CSV is a bulk machine-readable surface — but the
docstring was misleading future callers.
Expanded the docstring to enumerate the two legitimate call sites
(per-row maskers vs blanket-policy surfaces) and reference the policy
docs. Added a regression test pinning the blanket behaviour across all
three CSV routes (contracts, companies, authorities) when the body
contains zero masked rows.
---
apps/web/app/lib/csv-export.test.ts | 16 ++++++++++++++++
apps/web/app/lib/security.ts | 17 +++++++++++++++--
2 files changed, 31 insertions(+), 2 deletions(-)
diff --git a/apps/web/app/lib/csv-export.test.ts b/apps/web/app/lib/csv-export.test.ts
index 56d236a29..fb2d6dab2 100644
--- a/apps/web/app/lib/csv-export.test.ts
+++ b/apps/web/app/lib/csv-export.test.ts
@@ -571,6 +571,22 @@ describe('servedCsvExport privacy', () => {
expect(text).toBe(maskedBody);
});
+ // Locks in the blanket noindex policy documented in `privacy.tsx` and `docs/privacy-masking.md`:
+ // every CSV export carries `X-Privacy-Mask: applied` regardless of whether the body actually
+ // contains masked natural-person rows. The `authorities.csv` route is explicitly excluded from
+ // body masking (only ЕИК/name redaction is suppressed) but still gets the marker — CSV is a
+ // bulk machine-readable surface and the noindex signal is enforced blanket-wide.
+ it('stamps the privacy mask marker even when the CSV body contains zero masked rows (blanket CSV policy)', async () => {
+ const legalOnlyBody = 'eik,name\n121817309,СОФАРМА ТРЕЙДИНГ АД\n';
+ const r2 = new InMemoryR2();
+ for (const route of ['contracts', 'companies', 'authorities'] as const) {
+ const stream = vi.fn(() => csvResponse(legalOnlyBody));
+ const response = await serve(r2, stream, { route });
+ expect(response.headers.get('X-Privacy-Mask')).toBe('applied');
+ expect(response.headers.get('X-Robots-Tag')).toBeNull();
+ }
+ });
+
it('preserves Cache-Control: public, max-age=3600 when the streamer emits a row with masked sole-trader identifiers (contracts) and stamps the privacy mask marker', async () => {
const maskedBody = `eik,name\n,${MASKED_NATURAL_PERSON_LABEL}\n`;
const r2 = new InMemoryR2();
diff --git a/apps/web/app/lib/security.ts b/apps/web/app/lib/security.ts
index c043bdeea..379f778b1 100644
--- a/apps/web/app/lib/security.ts
+++ b/apps/web/app/lib/security.ts
@@ -73,8 +73,21 @@ export const PRIVACY_MASK_MARKER = 'X-Privacy-Mask';
export const PRIVACY_MASK_APPLIED = 'applied' as const;
// Route-layer helper: stamps the privacy-mask marker onto a `Headers` object so the downstream
-// worker `hardenResponse` can pick it up and translate it into `X-Robots-Tag: noindex`. Callers
-// must invoke this only when the response body contains masked natural-person data.
+// worker `hardenResponse` can pick it up and translate it into `X-Robots-Tag: noindex`.
+//
+// Two legitimate call sites:
+// 1. **Per-row maskers** (e.g. `/companies/:eik.data`, `/contracts/:id.json`) — call only when
+// the response body actually contains masked natural-person data, so a legal-entity response
+// stays out of the noindex bucket.
+// 2. **Blanket-policy surfaces** (the three public CSV exports — `/contracts.csv`,
+// `/companies.csv`, `/authorities.csv`) — the policy documented in `privacy.tsx` and
+// `docs/privacy-masking.md` says EVERY CSV export carries `noindex` regardless of whether
+// any specific row is a natural person, because CSV is a bulk machine-readable surface and
+// the noindex signal is enforced blanket-wide. CSV callers therefore invoke this helper
+// unconditionally inside `markCsvCache` (csv-export.ts).
+//
+// Workers translate the marker to `X-Robots-Tag: noindex` and delete it before cache/client, so
+// future callers can pick the strategy that fits their surface without leaking the marker.
export function markPrivacyMaskApplied(headers: Headers): void {
headers.set(PRIVACY_MASK_MARKER, PRIVACY_MASK_APPLIED);
}
From 3fd03afecbc2071a06f2288f45b9c3fe7dc7c9c0 Mon Sep 17 00:00:00 2001
From: LyuboslavLyubenov
Date: Thu, 20 Aug 2026 13:02:32 +0300
Subject: [PATCH 22/27] fix(privacy): mirror the loader consortium guard in
company meta()
---
apps/web/app/routes/company.data.test.ts | 91 ++++++++++++++++++++++++
apps/web/app/routes/company.tsx | 9 ++-
2 files changed, 99 insertions(+), 1 deletion(-)
diff --git a/apps/web/app/routes/company.data.test.ts b/apps/web/app/routes/company.data.test.ts
index e2bfd8b79..d333be5c1 100644
--- a/apps/web/app/routes/company.data.test.ts
+++ b/apps/web/app/routes/company.data.test.ts
@@ -252,6 +252,97 @@ describe('company.data meta() — natural-person noindex branch', () => {
});
});
+describe('company.data meta() — consortium-with-sole-trader-first-member branch', () => {
+ it('does NOT emit a noindex meta tag for a consortium whose displayName starts with "ЕТ " (mirrors the loader consortium guard)', () => {
+ // The loader (lines ~96-105 of company.tsx) gates masking on
+ // `company.kind !== 'consortium'`, returning a plain object (no privacy marker) for a ДЗЗД
+ // whose first member is a sole trader. `meta()` must mirror that guard — without it,
+ // `isNaturalPersonBidder(displayName, legalForm)` returns `true` for "ЕТ …; СТРОЙ ООД"
+ // (legalForm null), and `meta()` would stamp `` on a
+ // HTML page that the loader and `.data` twin agree is indexable. That contradicts the
+ // policy recorded in ADR-0036 §3 + the consortium guard added in 5d33ea5.
+ //
+ // Regression caught by ydimitrof in the PR #183 review of head a13e9a5 (the only unresolved
+ // thread on this PR).
+ const consortiumWithSoleTraderFirst = makeCompany({
+ kind: 'consortium',
+ isConsortium: true,
+ legalForm: null,
+ displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ; СТРОЙ ООД',
+ name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ; СТРОЙ ООД',
+ eik: '121817309',
+ });
+ const data = {
+ company: consortiumWithSoleTraderFirst,
+ coverage: makeCoverageMeta(),
+ trend: makeTrend(),
+ network: makeNetwork(),
+ };
+
+ const tags = meta({
+ data,
+ params: { eik: '121817309' },
+ matches: [],
+ location: {
+ pathname: '/companies/121817309',
+ search: '',
+ hash: '',
+ state: null,
+ key: 'default',
+ },
+ } as unknown as Parameters[0]) as Array<{
+ name?: string;
+ content?: string;
+ title?: string;
+ }>;
+
+ const robots = tags.find((t) => t.name === 'robots' && t.content === 'noindex');
+ expect(robots).toBeUndefined();
+ });
+
+ it('still emits noindex for a prose-consortium (kind=consortium, membershipNote set) so policy does not regress', () => {
+ // The prose branch was added intentionally: when the consortium parser returns raw prose
+ // (single-name consortium the parser couldn't resolve), the membership note itself can carry
+ // identifying names and the page is noindexed. The consortium-with-sole-trader-first-member
+ // guard must not regress this case.
+ const proseConsortium = makeCompany({
+ kind: 'consortium',
+ isConsortium: true,
+ legalForm: null,
+ displayName: 'КОНСОРЦИУМ ПЪРВА ГРУПА',
+ name: 'КОНСОРЦИУМ ПЪРВА ГРУПА',
+ eik: '121817309',
+ membershipNote: 'КОНСОРЦИУМ ПЪРВА ГРУПА',
+ });
+ const data = {
+ company: proseConsortium,
+ coverage: makeCoverageMeta(),
+ trend: makeTrend(),
+ network: makeNetwork(),
+ };
+
+ const tags = meta({
+ data,
+ params: { eik: '121817309' },
+ matches: [],
+ location: {
+ pathname: '/companies/121817309',
+ search: '',
+ hash: '',
+ state: null,
+ key: 'default',
+ },
+ } as unknown as Parameters[0]) as Array<{
+ name?: string;
+ content?: string;
+ title?: string;
+ }>;
+
+ const robots = tags.find((t) => t.name === 'robots' && t.content === 'noindex');
+ expect(robots).toBeDefined();
+ });
+});
+
describe('company.data worker pipeline — applyPrivacyMaskHeaders on the loader return', () => {
it('translates X-Privacy-Mask: applied into X-Robots-Tag: noindex and removes the marker (behavior 7)', async () => {
const natural = makeCompany({ legalForm: 'ЕТ', displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ' });
diff --git a/apps/web/app/routes/company.tsx b/apps/web/app/routes/company.tsx
index a698612e1..20fe4c929 100644
--- a/apps/web/app/routes/company.tsx
+++ b/apps/web/app/routes/company.tsx
@@ -36,7 +36,14 @@ export function meta({ data, params, matches }: Route.MetaArgs) {
});
if (
data?.company &&
- (isNaturalPersonBidder(data.company.displayName, data.company.legalForm) ||
+ // Consortium guard mirrors the loader (5d33ea5): `isNaturalPersonBidder` delegates consortium
+ // filtering to the caller, so without `kind !== 'consortium'` a ДЗЗД whose displayName starts
+ // with "ЕТ …" would have the meta noindex stamped on an HTML page the loader agrees is
+ // indexable. The prose branch (kind === 'consortium' && membershipNote) is unrelated and
+ // stays — single-name consortia the parser couldn't resolve can still carry identifying names
+ // in the membership note, so they remain noindexed.
+ ((data.company.kind !== 'consortium' &&
+ isNaturalPersonBidder(data.company.displayName, data.company.legalForm)) ||
(data.company.kind === 'consortium' && Boolean(data.company.membershipNote)))
) {
metaTags.push({ name: 'robots', content: 'noindex' });
From 1d316a362af65ec298e67edf35111faeca2156f8 Mon Sep 17 00:00:00 2001
From: Lyuboslav Lyubenov
Date: Fri, 21 Aug 2026 13:04:55 +0300
Subject: [PATCH 23/27] fix(privacy): extend noindex to prose-consortium .data
twin
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PR #183 review (lyubomir-bozhinov, 2026-08-20) caught the same class
of .data/HTML asymmetry as the prior consortium guards: meta() emits
for a prose-consortium (kind === 'consortium'
&& membershipNote), but the loader returned the plain object without
the X-Privacy-Mask marker, so the worker did not stamp X-Robots-Tag:
noindex on the .data twin. A crawler that doesn't honour would
index the raw membershipNote (which itself can carry identifying
names).
TDD: three new cases in company.data.test.ts pin the new branch
(marker set, ЕИК unchanged) and the negative case (membershipNote null
falls through to the plain-object path).
Implementation: a second guard in company.tsx loader mirrors the
natural-person branch — same Response.json wrap, same marker — but
without the field mutation, because the consortium ЕИК is a public
legal-entity identifier and must not be zeroed. ADR-0036 §8 records
the policy decision and cites the loader branch.
Verified: pnpm --filter @sigma/web test (550 passing, +3),
pnpm typecheck (7/7), pnpm prettier --check (clean),
pnpm check:docs (ok).
---
apps/web/app/routes/company.data.test.ts | 86 ++++++++++++++++++++++++
apps/web/app/routes/company.tsx | 13 ++++
docs/adr/0036-privacy-masking.md | 2 +
3 files changed, 101 insertions(+)
diff --git a/apps/web/app/routes/company.data.test.ts b/apps/web/app/routes/company.data.test.ts
index d333be5c1..de2960365 100644
--- a/apps/web/app/routes/company.data.test.ts
+++ b/apps/web/app/routes/company.data.test.ts
@@ -386,3 +386,89 @@ describe('company.data worker pipeline — applyPrivacyMaskHeaders on the loader
expect(outHeaders.get('X-Passthrough')).toBe('1');
});
});
+
+describe('company.data loader — prose-consortium (kind=consortium, membershipNote) branch', () => {
+ it('sets X-Privacy-Mask: applied WITHOUT zeroing the consortium ЕИК (the same noindex policy as meta() applies to the .data twin)', async () => {
+ // Prose consortia are single-name consortium records the parser couldn't resolve into structured
+ // members; their `membershipNote` itself can carry identifying names (per the meta() comment at
+ // company.tsx:42-49). The HTML page already emits for this branch (see the
+ // dedicated meta() test "still emits noindex for a prose-consortium"). The .data twin must carry
+ // the same noindex signal — otherwise the machine-readable twin leaks the same membership-note
+ // content indexable to crawlers that don't honour . The ЕИК of the consortium stays
+ // public (it's a legal entity), so the loader sets the marker without clearing company.eik.
+ const proseConsortium = makeCompany({
+ kind: 'consortium',
+ isConsortium: true,
+ legalForm: null,
+ displayName: 'КОНСОРЦИУМ ПЪРВА ГРУПА',
+ name: 'КОНСОРЦИУМ ПЪРВА ГРУПА',
+ eik: '121817309',
+ membershipNote: 'КОНСОРЦИУМ ПЪРВА ГРУПА',
+ });
+ installStubs(proseConsortium);
+
+ const result = await loader(loaderArgs('121817309'));
+
+ expect(result).toBeInstanceOf(Response);
+ const response = result as Response;
+ expect(response.status).toBe(200);
+ expect(response.headers.get('X-Privacy-Mask')).toBe('applied');
+ expect(response.headers.get('X-Robots-Tag')).toBeNull();
+ const body = (await response.json()) as { company: CompanyDetail };
+ // Consortium ЕИК is public (legal entity) — must not be zeroed by this branch.
+ expect(body.company.eik).toBe('121817309');
+ // membershipNote is part of the response body (preserved verbatim) — the noindex signal on the
+ // .data twin is what protects it from indexing.
+ expect(body.company.membershipNote).toBe('КОНСОРЦИУМ ПЪРВА ГРУПА');
+ });
+
+ it('returns a plain object (no marker) when kind=consortium but membershipNote is null — the prose guard does not over-trigger', async () => {
+ // The prose-consortium branch fires on `membershipNote` presence, not on the kind alone. A
+ // consortium whose parser was able to resolve structured members has membershipNote === null
+ // and must stay indexable (mirrors the consortium-with-sole-trader-first-member guard).
+ const structuredConsortium = makeCompany({
+ kind: 'consortium',
+ isConsortium: true,
+ legalForm: null,
+ displayName: 'СТРОЙ ООД; ПЪТ ИНЖЕНЕРИНГ АД',
+ name: 'СТРОЙ ООД; ПЪТ ИНЖЕНЕРИНГ АД',
+ eik: '121817309',
+ membershipNote: null,
+ });
+ installStubs(structuredConsortium);
+
+ const result = await loader(loaderArgs('121817309'));
+
+ expect(result).not.toBeInstanceOf(Response);
+ const plain = result as { company: CompanyDetail };
+ expect(plain.company.eik).toBe('121817309');
+ expect(plain.company.membershipNote).toBeNull();
+ });
+
+ it('translates the prose-consortium X-Privacy-Mask marker into X-Robots-Tag: noindex via the worker pipeline', async () => {
+ // End-to-end proof that the loader-set marker reaches a noindex header on the .data response
+ // (mirrors the natural-person worker-pipeline case but does not zero the ЕИК).
+ const proseConsortium = makeCompany({
+ kind: 'consortium',
+ isConsortium: true,
+ legalForm: null,
+ displayName: 'КОНСОРЦИУМ ПЪРВА ГРУПА',
+ name: 'КОНСОРЦИУМ ПЪРВА ГРУПА',
+ eik: '121817309',
+ membershipNote: 'КОНСОРЦИУМ ПЪРВА ГРУПА',
+ });
+ installStubs(proseConsortium);
+
+ const result = await loader(loaderArgs('121817309'));
+ expect(result).toBeInstanceOf(Response);
+ const response = result as Response;
+
+ applyPrivacyMaskHeaders(response.headers);
+
+ expect(response.headers.get('X-Robots-Tag')).toBe('noindex');
+ expect(response.headers.has('X-Privacy-Mask')).toBe(false);
+ const body = (await response.json()) as { company: CompanyDetail };
+ expect(body.company.eik).toBe('121817309');
+ expect(body.company.membershipNote).toBe('КОНСОРЦИУМ ПЪРВА ГРУПА');
+ });
+});
diff --git a/apps/web/app/routes/company.tsx b/apps/web/app/routes/company.tsx
index 20fe4c929..a156bc518 100644
--- a/apps/web/app/routes/company.tsx
+++ b/apps/web/app/routes/company.tsx
@@ -100,6 +100,13 @@ export async function loader({ params, context }: Route.LoaderArgs) {
// over-mask the consortium, zeroing its ЕИК and stamping noindex (regression caught by
// `company.data.test.ts` — the consortium branch returns a plain object with `company.eik`
// unchanged and no marker, same as legal-entity records).
+ //
+ // Prose-consortium noindex: a consortium the parser couldn't resolve into structured members
+ // carries a raw `membershipNote` that can itself hold identifying names. `meta()` (above)
+ // already emits `` for this branch on the HTML page; the `.data` twin
+ // must carry the same signal or crawlers that don't honour `` see the membership note
+ // verbatim. The consortium ЕИК stays public (legal entity), so we only set the marker — no
+ // zeroing, no name masking (mirror of the natural-person branch but without the field mutation).
if (
company.kind !== 'consortium' &&
isNaturalPersonBidder(company.displayName, company.legalForm)
@@ -110,6 +117,12 @@ export async function loader({ params, context }: Route.LoaderArgs) {
{ headers: { 'X-Privacy-Mask': 'applied' } },
);
}
+ if (company.kind === 'consortium' && company.membershipNote) {
+ return Response.json(
+ { company, coverage, trend, network },
+ { headers: { 'X-Privacy-Mask': 'applied' } },
+ );
+ }
return { company, coverage, trend, network };
});
}
diff --git a/docs/adr/0036-privacy-masking.md b/docs/adr/0036-privacy-masking.md
index 183f6aa0c..d7d2f92f3 100644
--- a/docs/adr/0036-privacy-masking.md
+++ b/docs/adr/0036-privacy-masking.md
@@ -39,6 +39,8 @@ HTML профилът на фирма вече прилагаше `noindex` ме
7. **Slug-ът на name-keyed естествено лице е проследявано ограничение, не се променя в този PR.** За фирма без валиден ЕИК slug-ът е `n + base64url(name)` ([`packages/db/src/queries/identity.ts`](../../packages/db/src/queries/identity.ts)) — обратимо кодиране на суровото име. Името обаче е публично (точка 6), sitemap-ът вече филтрира тези записи ([`streamCompanySitemap`](../../packages/db/src/queries/sitemaps.ts) пропуска `isNaturalPersonProfileName`), а промяната на slug схемата е cross-cutting (URL стабилност, вътрешни линкове, bookmarks, identity система) и е извън обхвата на masking PR. Остава като проследявано ограничение за бъдещ PR, ако рискът от реверсибилно кодирано име в URL-а се реши, че трябва да се затвори.
+8. **Prose-консорциум (`kind === 'consortium' && membershipNote`) също получава `X-Robots-Tag: noindex` на `.data` близнака, но БЕЗ маскиране на полета.** Когато парсерът не е успял да раздели обединение на структурирани членове, записът носи суров `membershipNote` (напр. „ЕТ Иван Петров; Строй ООД — и др."), който сам по себе си може да съдържа идентифициращи имена. `meta()` на [`company.tsx`](../../apps/web/app/routes/company.tsx) вече излъчва `` за този клон на HTML страницата. За да бъде покритието симетрично (HTML и `.data`), loader-ът на `company.tsx` също слага `X-Privacy-Mask: applied` маркер върху `.data` отговора за този клон — worker-ът го превежда в `X-Robots-Tag: noindex` (както за естествените лица). ЕИК-ът на обединението остава непроменен (това е юридическо лице с публичен идентификатор); маскиране на име/ЕИК не се прилага — само `noindex` сигнал, идентичен на HTML meta политиката. Решението е записано в [`company.tsx`](../../apps/web/app/routes/company.tsx) (клонът за prose-консорциум в loader-а, редове 119–124) и тествано в `company.data.test.ts` (`company.data loader — prose-consortium branch`). Засегнати са единствено обединения с неструктуриран `membershipNote`; обединения със структурирани членове (`membershipNote === null`) остават индексируеми — те вече имат ЕИК на обединението и не носят identifying prose.
+
## Последствия
- **Кеш политиката остава.** `Cache-Control: public, max-age=3600` за CSV и `public, s-maxage=3600, stale-while-revalidate=86400` за JSON остават непроменени — маскираните байтове са безопасни за кеширане, защото не съдържат естествено-личностни идентификатори. Edge кешът е приоритет за DDoS устойчивостта (ADR-0001 §3) и отпадането му би влошило публичната достъпност на експортите.
From 3458dae49264f87f3d5d87a280edfe3075a3cd72 Mon Sep 17 00:00:00 2001
From: LyuboslavLyubenov
Date: Sat, 22 Aug 2026 13:12:13 +0300
Subject: [PATCH 24/27] fix(privacy): mask sole-trader rows in leaderboard list
mappers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The shared toCompanyListItem (used by /companies + /companies.data and the
home top-10) and toItem (used by /contracts + /contracts.data and the home
single-offer tables) returned ЕИК + source name verbatim for sole traders,
so the leaderboard HTML page AND its RRv7 single-fetch .data twin both
served the natural-person identifier un-masked — exactly the #173 CWE-359
class the existing CSV/JSON streamers already guard against. This is the
third surface (PR #183 review #1).
Mirror the CSV streamer guard: bidder_kind !== 'consortium' &&
isNaturalPersonBidder(...) zeroes ЕИК, replaces name/displayName with
MASKED_NATURAL_PERSON_LABEL, and drops hasEik to false. JVs whose first
member is a sole trader (e.g. 'ЕТ Иван Петров; Строй ООД') keep their
consortium name + ЕИК verbatim, matching the existing consortium guards
in streamContractsCsv / streamCompaniesCsv / maskContractForPrivacy.
The contract list path gains a SELECT b.legal_form AS bidder_legal_form
projection on the shared SELECT/FROM block (listContracts,
listSingleOfferContracts, streamContractsCsv, contractsSummary) so the
masker has the sole-trader signal on every list query — base-aggregation
CTE was not touched, it already projects legal_form for its grouping.
---
packages/db/src/queries/contracts.test.ts | 72 +++++++++++++++++++++
packages/db/src/queries/contracts.ts | 19 +++++-
packages/db/src/queries/rows.test.ts | 77 +++++++++++++++++++++++
packages/db/src/queries/rows.ts | 33 ++++++++--
4 files changed, 192 insertions(+), 9 deletions(-)
diff --git a/packages/db/src/queries/contracts.test.ts b/packages/db/src/queries/contracts.test.ts
index cd9f96cb5..b006e30fa 100644
--- a/packages/db/src/queries/contracts.test.ts
+++ b/packages/db/src/queries/contracts.test.ts
@@ -32,6 +32,7 @@ const contractRow = {
bidder_id: 'eik:111111113',
bidder_name: 'Bidder',
bidder_kind: 'company' as const,
+ bidder_legal_form: 'ООД',
procedure_type: 'Открита процедура',
signed_at: '2024-01-01',
bids_received: 3,
@@ -377,3 +378,74 @@ describe('streamContractsCsv masking', () => {
expect(rows[1]![7]).toBe('consortium');
});
});
+
+describe('listContracts — privacy masking on the leaderboard list (PR #183 review #1)', () => {
+ // Sole traders must read "Частно лице" on /contracts + /contracts.data (RRv7 single-fetch twin)
+ // and on the home single-offer tables. The CSV streamer masks the same row upstream of bytes
+ // hitting R2; maskContractForPrivacy covers /contracts/:id.json. The list path is the third
+ // surface.
+ const soleTraderRow = {
+ id: 'c:et-1',
+ subject: 'S',
+ unp: 'UNP-et',
+ cpv_code: '45000000',
+ eu_funded: 0,
+ authority_id: 'auth:1',
+ authority_name: 'Authority',
+ bidder_id: 'eik:121817309',
+ bidder_name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ',
+ bidder_kind: 'company' as const,
+ bidder_legal_form: 'ЕТ',
+ procedure_type: 'Открита процедура',
+ signed_at: '2024-01-01',
+ bids_received: 3,
+ amount_eur: 1000,
+ sort_value: 1000,
+ };
+ const legalEntityRow = { ...contractRow };
+ const consortiumRow = {
+ ...contractRow,
+ bidder_name: 'ЕТ Иван Петров; Строй ООД',
+ bidder_kind: 'consortium' as const,
+ bidder_legal_form: null,
+ };
+
+ function dbFor(row: object | object[]): D1Database {
+ const rows = Array.isArray(row) ? row : [row];
+ return {
+ prepare(_sql: string) {
+ return {
+ bind() {
+ return this;
+ },
+ async all() {
+ return { results: rows as T[] };
+ },
+ async first() {
+ return { total: rows.length, eur: rows.length ? 1000 : 0, suspect: 0 } as T;
+ },
+ };
+ },
+ } as D1Database;
+ }
+
+ it('masks bidderName + bidderDisplayName for a sole trader (ЕТ, legal_form=ЕТ)', async () => {
+ const page = await listContracts(dbFor(soleTraderRow), { pageSize: 10 });
+ expect(page.items).toHaveLength(1);
+ expect(page.items[0]!.bidderName).toBe(MASKED_NATURAL_PERSON_LABEL);
+ expect(page.items[0]!.bidderDisplayName).toBe(MASKED_NATURAL_PERSON_LABEL);
+ });
+
+ it('preserves the legal-entity name verbatim', async () => {
+ const page = await listContracts(dbFor(legalEntityRow), { pageSize: 10 });
+ expect(page.items[0]!.bidderName).toBe('Bidder');
+ expect(page.items[0]!.bidderDisplayName).toBe('Bidder');
+ });
+
+ it('does NOT mask a consortium whose first member is a sole trader (MAJOR-class guard)', async () => {
+ const page = await listContracts(dbFor(consortiumRow), { pageSize: 10 });
+ expect(page.items[0]!.bidderName).toBe('ЕТ Иван Петров; Строй ООД');
+ expect(page.items[0]!.bidderKind).toBe('consortium');
+ expect(page.items[0]!.isConsortium).toBe(true);
+ });
+});
diff --git a/packages/db/src/queries/contracts.ts b/packages/db/src/queries/contracts.ts
index e464f3f20..cd5d9d1dc 100644
--- a/packages/db/src/queries/contracts.ts
+++ b/packages/db/src/queries/contracts.ts
@@ -104,6 +104,7 @@ interface ContractRow {
bidder_id: string;
bidder_name: string;
bidder_kind: 'company' | 'consortium';
+ bidder_legal_form: string | null;
procedure_type: string;
signed_at: string | null;
bids_received: number | null;
@@ -113,7 +114,7 @@ interface ContractRow {
const SELECT = `
SELECT c.id, COALESCE(NULLIF(c.contract_subject, ''), t.title) AS subject, t.source_id AS unp,
t.cpv_code, c.eu_funded, t.authority_id, a.name AS authority_name,
- c.bidder_id, b.name AS bidder_name, b.kind AS bidder_kind,
+ c.bidder_id, b.name AS bidder_name, b.kind AS bidder_kind, b.legal_form AS bidder_legal_form,
t.procedure_type, c.signed_at, c.bids_received, c.amount_eur`;
const FROM = `
FROM contracts c
@@ -211,6 +212,16 @@ function contractFilterSignature(p: ContractListParams): string {
function toItem(r: ContractRow): ContractListItem {
const authorityName = cleanName(r.authority_name);
const bidderName = cleanName(r.bidder_name);
+ // Privacy (PR #183 review): the contract list — /contracts + /contracts.data (RRv7 single-fetch
+ // twin) and the home single-offer tables — shares this mapper. A sole trader must read
+ // "Частно лице" with no source name exposed on the leaderboard; the CSV path already masks the
+ // same row upstream of bytes hitting R2 (streamContractsCsv), and the JSON masker
+ // (maskContractForPrivacy) covers /contracts/:id.json. This is the third surface. The
+ // bidder_kind !== 'consortium' guard is required by isNaturalPersonBidder's docstring
+ // (caller filters JVs).
+ const isNaturalPerson =
+ r.bidder_kind !== 'consortium' && isNaturalPersonBidder(bidderName, r.bidder_legal_form);
+ const maskedBidderName = isNaturalPerson ? MASKED_NATURAL_PERSON_LABEL : bidderName;
return {
id: contractSlug(r.id),
subject: r.subject,
@@ -221,8 +232,10 @@ function toItem(r: ContractRow): ContractListItem {
authoritySlug: authoritySlug(r.authority_id),
authorityName,
bidderSlug: companySlug(r.bidder_id),
- bidderName,
- bidderDisplayName: entityName(bidderName, r.bidder_kind),
+ bidderName: maskedBidderName,
+ bidderDisplayName: isNaturalPerson
+ ? MASKED_NATURAL_PERSON_LABEL
+ : entityName(maskedBidderName, r.bidder_kind),
bidderKind: r.bidder_kind,
procedureLabel: procedureGroup(r.procedure_type).label,
signedAt: r.signed_at,
diff --git a/packages/db/src/queries/rows.test.ts b/packages/db/src/queries/rows.test.ts
index be7b28674..a8694dae4 100644
--- a/packages/db/src/queries/rows.test.ts
+++ b/packages/db/src/queries/rows.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
+import { MASKED_NATURAL_PERSON_LABEL } from '@sigma/shared';
import { toAuthorityListItem, toCompanyListItem, typeLabel } from './rows';
describe('typeLabel', () => {
@@ -78,6 +79,82 @@ describe('toCompanyListItem', () => {
});
});
+describe('toCompanyListItem — privacy masking on the leaderboard list (PR #183 review #1)', () => {
+ // /companies and /companies.data and the home top-10 all share this mapper. Mask ЕИК + name on
+ // the natural-person branch so a sole trader's ЕИК does not leak through the leaderboard list
+ // payload (HTML + RRv7 single-fetch .data twin). The CSV streamer already masks the same row
+ // upstream of bytes hitting R2 (companies.ts); the JSON masker for /contracts/:id.json masks
+ // maskContractForPrivacy; this closes the third surface. Consortium guard is required by
+ // isNaturalPersonBidder's docstring (caller filters JVs).
+ const baseRow = {
+ bidder_id: 'eik:121817309',
+ kind: 'company' as const,
+ ownership_kind: null,
+ eik: '121817309',
+ eik_valid: 1,
+ settlement: 'София',
+ won_eur: 50000,
+ contracts: 5,
+ authorities: 2,
+ primary_sector: '45',
+ eu_eur: 0,
+ first_date: '2022-01-01',
+ last_date: '2024-06-01',
+ };
+
+ it('masks ЕИК and name for a sole trader with legal_form=ЕТ', () => {
+ const item = toCompanyListItem({
+ ...baseRow,
+ name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ',
+ legal_form: 'ЕТ',
+ });
+ expect(item.eik).toBeNull();
+ expect(item.name).toBe(MASKED_NATURAL_PERSON_LABEL);
+ expect(item.displayName).toBe(MASKED_NATURAL_PERSON_LABEL);
+ expect(item.hasEik).toBe(false);
+ });
+
+ it('masks on the leading-ЕТ name heuristic when legal_form is null', () => {
+ // Same pattern CSV streamer guards with: a row whose name starts with "ЕТ " but lacks a
+ // legal_form value still trips the predicate.
+ const item = toCompanyListItem({
+ ...baseRow,
+ name: 'ЕТ Иван Петров',
+ legal_form: null,
+ });
+ expect(item.eik).toBeNull();
+ expect(item.name).toBe(MASKED_NATURAL_PERSON_LABEL);
+ });
+
+ it('preserves ЕИК + name verbatim for a legal entity (ООД)', () => {
+ const item = toCompanyListItem({
+ ...baseRow,
+ name: 'СТРОЙ ООД',
+ legal_form: 'ООД',
+ });
+ expect(item.eik).toBe('121817309');
+ expect(item.name).toBe('СТРОЙ ООД');
+ expect(item.displayName).toBe('СТРОЙ ООД');
+ expect(item.hasEik).toBe(true);
+ });
+
+ it('does NOT mask a consortium whose first member is a sole trader (MAJOR-class guard)', () => {
+ // Mirrors the CSV streamer guard: a JV like "ЕТ Иван Петров; Строй ООД" must keep its
+ // consortium name + ЕИК. Failing this is a regression to the pre-`bidder_kind !== 'consortium'`
+ // guard bug class.
+ const item = toCompanyListItem({
+ ...baseRow,
+ kind: 'consortium',
+ name: 'ЕТ Иван Петров; Строй ООД',
+ legal_form: null,
+ eik: '200000000',
+ });
+ expect(item.eik).toBe('200000000');
+ expect(item.name).toBe('ЕТ Иван Петров; Строй ООД');
+ expect(item.isConsortium).toBe(true);
+ });
+});
+
describe('toAuthorityListItem', () => {
const base = {
authority_id: 'auth:000695089',
diff --git a/packages/db/src/queries/rows.ts b/packages/db/src/queries/rows.ts
index c86048b3b..b7ab1833a 100644
--- a/packages/db/src/queries/rows.ts
+++ b/packages/db/src/queries/rows.ts
@@ -8,7 +8,12 @@ import type {
OwnershipKind,
} from '@sigma/api-contract';
import { ENTITY_TYPES } from '@sigma/config';
-import { cleanName, entityName } from '@sigma/shared';
+import {
+ MASKED_NATURAL_PERSON_LABEL,
+ cleanName,
+ entityName,
+ isNaturalPersonBidder,
+} from '@sigma/shared';
import { authoritySlug, companySlug } from './identity';
import { sectorRef } from './sectors';
@@ -48,16 +53,32 @@ export interface CompanyTotalsRow {
}
export function toCompanyListItem(r: CompanyTotalsRow): CompanyListItem {
- const hasEik = r.eik_valid === 1 && Boolean(r.eik);
+ // Privacy (PR #183 review): a sole trader / natural person has the same `ЕТ` / sole-trader signal
+ // in the rollup as on the detail page and in the CSV/JSON exports. Mask ЕИК and the source name
+ // here so /companies, /companies.data (RRv7 single-fetch twin), and the home top-10 all carry
+ // the masked values — they share this mapper, so the new branch covers all three in one place.
+ //
+ // Consortium guard mirrors the CSV streamer (`bidder_kind !== 'consortium' && isNaturalPersonBidder(...)`
+ // in companies.ts): isNaturalPersonBidder's docstring delegates JV filtering to the caller, so a
+ // consortium whose first member is a sole trader (e.g. "ЕТ Иван Петров; Строй ООД") would
+ // otherwise over-mask — losing the "… и др." shape and the consortium ЕИК. The guard keeps the
+ // JV's name + ЕИК verbatim.
+ //
+ // The unmasked name is also held back from `displayName`: a masked row must read "Частно лице"
+ // everywhere on the list page (and on the home page) — exposing the masked `displayName` next
+ // to a null ЕИК would let a crawler infer the natural-person class without needing the ЕИК.
+ const isNaturalPerson =
+ r.kind !== 'consortium' && isNaturalPersonBidder(cleanName(r.name), r.legal_form);
+ const name = isNaturalPerson ? MASKED_NATURAL_PERSON_LABEL : cleanName(r.name);
return {
slug: companySlug(r.bidder_id),
- name: cleanName(r.name),
- displayName: entityName(cleanName(r.name), r.kind),
+ name,
+ displayName: isNaturalPerson ? MASKED_NATURAL_PERSON_LABEL : entityName(name, r.kind),
kind: r.kind,
isConsortium: r.kind === 'consortium',
- eik: r.eik,
+ eik: isNaturalPerson ? null : r.eik,
eikValid: r.eik_valid === 1,
- hasEik,
+ hasEik: isNaturalPerson ? false : r.eik_valid === 1 && Boolean(r.eik),
ownershipKind: r.ownership_kind,
settlement: r.settlement,
sector: sectorRef(r.primary_sector),
From 6b1877ba57078b3c6a221ebcf49adecd06958c11 Mon Sep 17 00:00:00 2001
From: LyuboslavLyubenov
Date: Sat, 22 Aug 2026 13:12:22 +0300
Subject: [PATCH 25/27] fix(privacy): set noindex marker on leaderboard list
when a row is masked
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The leaderboard list mappers now mask sole-trader rows (prior commit), but
the .data twin of /companies and /contracts is a separate machine-readable
surface — search engines that don't honour the HTML meta/noindex tag would
still index the masked row (PR #183 review #1).
Stamp the internal X-Privacy-Mask: applied marker when ANY item on the
page is masked, and forward it via the route's headers() export. The
worker hardenResponse → applyPrivacyMaskHeaders translates the marker
into X-Robots-Tag: noindex on the .data response and strips the marker
before the edge cache. Mirrors the company.tsx + contract.tsx per-row
pattern. Marker is internal — it never reaches the client.
---
apps/web/app/routes/companies.tsx | 33 ++++++++++++++++++++++++++++---
apps/web/app/routes/contracts.tsx | 27 ++++++++++++++++++++++---
2 files changed, 54 insertions(+), 6 deletions(-)
diff --git a/apps/web/app/routes/companies.tsx b/apps/web/app/routes/companies.tsx
index d66f22c13..05450194a 100644
--- a/apps/web/app/routes/companies.tsx
+++ b/apps/web/app/routes/companies.tsx
@@ -1,5 +1,11 @@
import { Link, useNavigation, useSearchParams } from 'react-router';
-import { count, money, moneyBare, parseConsortiumMembers } from '@sigma/shared';
+import {
+ count,
+ MASKED_NATURAL_PERSON_LABEL,
+ money,
+ moneyBare,
+ parseConsortiumMembers,
+} from '@sigma/shared';
import { getCompanyFacets, listCompanies, getDb } from '@sigma/db';
import type { CompanyListItem } from '@sigma/api-contract';
import type { Route } from './+types/companies';
@@ -40,8 +46,21 @@ export function meta({ matches }: Route.MetaArgs) {
});
}
-export function headers() {
- return { 'Cache-Control': publicCache(1800) };
+export function headers({ loaderHeaders }: Route.HeadersArgs) {
+ // Forward the internal privacy-mask marker set by the loader on the `.data` Response. React
+ // Router's `getDocumentHeadersImpl` does not auto-propagate loader headers (only `Set-Cookie`),
+ // so the route must forward explicitly — without this the worker `hardenResponse` cannot
+ // translate the marker into `X-Robots-Tag: noindex` on the HTML response. (PR #183 review #1:
+ // the leaderboard list exposes masked sole-trader rows in `toCompanyListItem`; the marker
+ // ensures the .data twin — RRv7 single-fetch — also carries noindex when ANY row on the page
+ // is masked, so search engines don't surface the masked twin separately from the HTML.)
+ const headers: Record = {
+ 'Cache-Control': loaderHeaders.get('Cache-Control') ?? publicCache(1800),
+ };
+ if (loaderHeaders.get('X-Privacy-Mask') === 'applied') {
+ headers['X-Privacy-Mask'] = 'applied';
+ }
+ return headers;
}
export async function loader({ request, context }: Route.LoaderArgs) {
@@ -57,6 +76,14 @@ export async function loader({ request, context }: Route.LoaderArgs) {
getCompanyFacets(db),
getCoverageMeta(db),
]);
+ // Privacy (PR #183 review #1): if any row on this page was masked by the shared
+ // `toCompanyListItem` mapper (sole trader / natural person), stamp the privacy-mask marker so
+ // the worker `hardenResponse` translates it into `X-Robots-Tag: noindex` on the `.data` twin
+ // (RRv7 single-fetch). The marker is internal — it never reaches the client. Mirrors the
+ // company-detail loader's per-row marker pattern.
+ if (page.items.some((c) => c.name === MASKED_NATURAL_PERSON_LABEL)) {
+ return Response.json({ page, facets, coverage }, { headers: { 'X-Privacy-Mask': 'applied' } });
+ }
return { page, facets, coverage };
}
diff --git a/apps/web/app/routes/contracts.tsx b/apps/web/app/routes/contracts.tsx
index 5f7d8a061..77404ef88 100644
--- a/apps/web/app/routes/contracts.tsx
+++ b/apps/web/app/routes/contracts.tsx
@@ -1,5 +1,5 @@
import { Link, useNavigation, useSearchParams } from 'react-router';
-import { count, date, money, moneyBare } from '@sigma/shared';
+import { MASKED_NATURAL_PERSON_LABEL, count, date, money, moneyBare } from '@sigma/shared';
import { contractsSummary, getContractFacets, listContracts, getDb } from '@sigma/db';
import type { Route } from './+types/contracts';
import { Breadcrumbs } from '../components/Breadcrumbs';
@@ -39,8 +39,21 @@ export function meta({ matches }: Route.MetaArgs) {
});
}
-export function headers() {
- return { 'Cache-Control': publicCache(1800) };
+export function headers({ loaderHeaders }: Route.HeadersArgs) {
+ // Forward the internal privacy-mask marker set by the loader on the `.data` Response. React
+ // Router's `getDocumentHeadersImpl` does not auto-propagate loader headers (only `Set-Cookie`),
+ // so the route must forward explicitly — without this the worker `hardenResponse` cannot
+ // translate the marker into `X-Robots-Tag: noindex` on the HTML response. (PR #183 review #1:
+ // the contract leaderboard exposes masked sole-trader rows via the shared `toItem` mapper; the
+ // marker ensures the .data twin — RRv7 single-fetch — also carries noindex when ANY row on the
+ // page is masked.)
+ const headers: Record = {
+ 'Cache-Control': loaderHeaders.get('Cache-Control') ?? publicCache(1800),
+ };
+ if (loaderHeaders.get('X-Privacy-Mask') === 'applied') {
+ headers['X-Privacy-Mask'] = 'applied';
+ }
+ return headers;
}
export async function loader({ request, context }: Route.LoaderArgs) {
@@ -61,6 +74,14 @@ export async function loader({ request, context }: Route.LoaderArgs) {
getContractFacets(db),
]);
const result = await listContracts(db, params, summary);
+ // Privacy (PR #183 review #1): if any row on this page was masked by the shared `toItem`
+ // mapper (sole trader / natural person), stamp the privacy-mask marker so the worker
+ // `hardenResponse` translates it into `X-Robots-Tag: noindex` on the `.data` twin (RRv7
+ // single-fetch). The marker is internal — it never reaches the client. Mirrors the
+ // company-detail loader's per-row marker pattern.
+ if (result.items.some((c) => c.bidderName === MASKED_NATURAL_PERSON_LABEL)) {
+ return Response.json({ result, facets }, { headers: { 'X-Privacy-Mask': 'applied' } });
+ }
return { result, facets };
});
}
From 7e4773ce5e5e8e6227601f9e3b11acb0f9858b36 Mon Sep 17 00:00:00 2001
From: LyuboslavLyubenov
Date: Sat, 22 Aug 2026 13:12:30 +0300
Subject: [PATCH 26/27] fix(privacy): strip server-only bidder_legal_form from
contract.json body
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The masker maskContractForPrivacy widens its input to ContractRecord &
{ bidder_legal_form: string | null } so it has the sole-trader signal, but
the public ContractRecord API contract does NOT include that field — the
'not on the wire' invariant from the PR description was violated on every
loader branch:
- masked branch: ...record spread preserved the extra field → JSON body
carried the natural-person classifier alongside the masked name
- passthrough branch (legal entity / consortium): the masker returns the
record BY REFERENCE, so serializeJsonForScript(masked) serialized
bidder_legal_form straight from getContract's widened return shape
Add an explicit destructure that strips the field on every branch (TDD:
two loader tests assert body.bidder_legal_form is undefined on both the
masked sole-trader path and the legal-entity passthrough path). The
masker's own masked branch also drops the field, defense-in-depth.
---
apps/web/app/routes/contract.json.test.ts | 39 +++++++++++++++++++++++
apps/web/app/routes/contract.json.tsx | 30 +++++++++++++++--
2 files changed, 67 insertions(+), 2 deletions(-)
diff --git a/apps/web/app/routes/contract.json.test.ts b/apps/web/app/routes/contract.json.test.ts
index 373ad075d..3499275b7 100644
--- a/apps/web/app/routes/contract.json.test.ts
+++ b/apps/web/app/routes/contract.json.test.ts
@@ -236,6 +236,45 @@ describe('contract.json loader', () => {
expect(body.sourceNames.bidder).not.toBe('ЕТ ДРИФТ - НИКОЛАЙ КИРОВ');
});
+ it('does NOT leak the server-only bidder_legal_form field into the masked body (PR #183 review #2)', async () => {
+ // The record carries `bidder_legal_form` as a server-only input to the masker; the public
+ // ContractRecord API contract does not include the field, so the JSON response body must not
+ // either. Without the explicit destructure, the spread `...record` in the masker's masked
+ // branch preserved the extra field — a public payload that advertised the natural-person
+ // classification alongside the masked name.
+ vi.mocked(getContract).mockResolvedValueOnce(makeRecord());
+ const response = await loader(loaderArgs('c-1'));
+ const body = (await response.json()) as Record;
+ expect(body).not.toHaveProperty('bidder_legal_form');
+ });
+
+ it('does NOT leak the server-only bidder_legal_form field into a passthrough legal-entity body (PR #183 review #2)', async () => {
+ // Same invariant on the no-mask branch — when the masker returns the record by reference, the
+ // extra field is still server-only and must not reach the client. This is the path the legal
+ // entity / consortium negative cases take.
+ const record = makeRecord({
+ bidder: {
+ slug: 'bidder-2',
+ orderingUnit: null,
+ name: 'СОФАРМА ТРЕЙДИНГ АД',
+ displayName: 'СОФАРМА ТРЕЙДИНГ АД',
+ kind: 'company',
+ typeLabel: null,
+ settlement: 'Sofia',
+ eik: '123456789',
+ sector: null,
+ totalContracts: 1,
+ totalEur: 1000,
+ },
+ });
+ record.bidder_legal_form = 'АД';
+ vi.mocked(getContract).mockResolvedValueOnce(record);
+
+ const response = await loader(loaderArgs('c-2'));
+ const body = (await response.json()) as Record;
+ expect(body).not.toHaveProperty('bidder_legal_form');
+ });
+
it('passes a legal entity through verbatim and omits the privacy mask marker (behavior 2)', async () => {
const record = makeRecord({
bidder: {
diff --git a/apps/web/app/routes/contract.json.tsx b/apps/web/app/routes/contract.json.tsx
index 094ec073c..78e6570b1 100644
--- a/apps/web/app/routes/contract.json.tsx
+++ b/apps/web/app/routes/contract.json.tsx
@@ -26,8 +26,14 @@ export function maskContractForPrivacy(
): ContractRecord {
if (record.bidder.kind === 'consortium') return record;
if (!isNaturalPersonBidder(record.bidder.name, bidderLegalForm)) return record;
+ // Drop the server-only `bidder_legal_form` field explicitly so it never leaks into the JSON
+ // response body. The masker's input type widens to `ContractRecord & { bidder_legal_form }`,
+ // but the public ContractRecord API contract does not include the field — without this destructure
+ // the `...record` spread would carry the natural-person classifier straight through to the client
+ // alongside the masked name (PR #183 review #2).
+ const { bidder_legal_form: _omit, ...publicRecord } = record;
return {
- ...record,
+ ...publicRecord,
bidder: {
...record.bidder,
eik: null,
@@ -41,6 +47,21 @@ export function maskContractForPrivacy(
};
}
+/**
+ * Strip the server-only `bidder_legal_form` field from any contract record before serialization,
+ * so it never reaches the JSON response body regardless of which masker branch (masked, legal
+ * entity passthrough, or consortium passthrough) produced the value. The masker's passthrough
+ * branch returns the record BY REFERENCE, so it carries `bidder_legal_form` until the strip here
+ * (PR #183 review #2). The input shape is `ContractRecord` (post-masker) widened to include the
+ * optional server-only field, since both branches carry it.
+ */
+function stripServerOnlyFields(
+ record: ContractRecord & { bidder_legal_form?: string | null },
+): ContractRecord {
+ const { bidder_legal_form: _omit, ...publicRecord } = record;
+ return publicRecord;
+}
+
// Resource route: the assembled contract record as machine-readable JSON (/contracts/:id.json).
//
// Privacy: the natural-person masker (maskContractForPrivacy) zeros ЕИК and replaces the bidder
@@ -57,11 +78,16 @@ export async function loader({ params, context }: Route.LoaderArgs) {
const record = await getContract(getDb(context.cloudflare.env), contractIdFromSlug(id));
if (!record) return withDataSource(Response.json({ error: 'not_found' }, { status: 404 }));
const masked = maskContractForPrivacy(record, record.bidder_legal_form);
+ // Strip server-only fields (`bidder_legal_form`) from every code path — masked, legal entity
+ // passthrough, or consortium passthrough — so the natural-person classifier never reaches the
+ // client. The masker's passthrough branch returns `record` by reference, which still carries
+ // the server-only field until this strip (PR #183 review #2).
+ const publicRecord = stripServerOnlyFields(masked);
const headers = new Headers({
'Content-Type': 'application/json; charset=utf-8',
'X-Content-Type-Options': 'nosniff',
'Cache-Control': publicCache(3600),
});
if (masked !== record) headers.set('X-Robots-Tag', 'noindex');
- return withDataSource(new Response(serializeJsonForScript(masked), { headers }));
+ return withDataSource(new Response(serializeJsonForScript(publicRecord), { headers }));
}
From 75251ebd439dab187879450f603c6b8b6e5b0e09 Mon Sep 17 00:00:00 2001
From: LyuboslavLyubenov
Date: Mon, 24 Aug 2026 13:16:44 +0300
Subject: [PATCH 27/27] fix(db): project legal_form on the list path so
/companies + .data don't 500
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PR #183 review (lyubomir-bozhinov, 2026-08-24, MAJOR #1): COLS in companies.ts
names 'legal_form', but the rollup subquery in source() only projects it on
the CSV path (3cd5d23 made it conditional to skip the LEFT JOIN on the list
hot path). The conditional predated the masking mapper added in 3458dae —
toCompanyListItem reads r.legal_form for the sole-trader mask, so on real D1
the SELECT fails with 'no such column: legal_form' and /companies +
/companies.data return 500. The mocked-DB unit suite never executes the SQL
and shipped the bug.
Restore the LEFT JOIN + projection on the rollup branch (PK lookup, bounded
cost), flip the unit test that asserted the broken shape, and add an
end-to-end SQL test against real node:sqlite that pins the real-D1 behavior
(sole trader masked, ООД verbatim, consortium not over-masked, CSV parity).
---
.../src/queries/companies-rollup-sql.test.ts | 163 ++++++++++++++++++
packages/db/src/queries/companies.test.ts | 22 +--
packages/db/src/queries/companies.ts | 29 +++-
3 files changed, 196 insertions(+), 18 deletions(-)
create mode 100644 packages/db/src/queries/companies-rollup-sql.test.ts
diff --git a/packages/db/src/queries/companies-rollup-sql.test.ts b/packages/db/src/queries/companies-rollup-sql.test.ts
new file mode 100644
index 000000000..071c96af4
--- /dev/null
+++ b/packages/db/src/queries/companies-rollup-sql.test.ts
@@ -0,0 +1,163 @@
+///
+// PR #183 review (lyubomir-bozhinov, 2026-08-24, MAJOR #1): listCompanies's COLS includes
+// `legal_form` (toCompanyListItem reads it for natural-person masking — PR #183 T-001), but the
+// rollup branch of source() only projects it when the CSV streamer asks for it (the
+// `3cd5d23 perf(db): project legal_form only on the CSV path` optimization predates the masking
+// mapper). On a real D1 the SELECT therefore fails with `no such column: legal_form` at offset
+// N, and `/companies` + `/companies.data` return 500. The existing mocked-DB unit suite never
+// executes the SQL, so the bug shipped.
+//
+// This file replays the production SQL listCompanies emits, against the real D1 schema, on a real
+// SQLite engine (Node 22's `node:sqlite`). The tests are end-to-end for the SQL projection: a
+// passing run means listCompanies can execute against a D1 instance and that masking round-trips.
+//
+// The shape mirrors packages/db/src/queries/value-base-sql.test.ts (real-SQLite end-to-end of
+// the production rollups), and uses the same `d1()` shim to expose `node:sqlite` as D1Database.
+import { readFileSync, readdirSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { DatabaseSync } from 'node:sqlite';
+import { fileURLToPath } from 'node:url';
+import { afterEach, describe, expect, it } from 'vitest';
+import { MASKED_NATURAL_PERSON_LABEL } from '@sigma/shared';
+import { listCompanies, streamCompaniesCsv } from './companies';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const migrationsDir = resolve(here, '../../migrations');
+const migrations = readdirSync(migrationsDir)
+ .filter((file) => file.endsWith('.sql'))
+ .sort()
+ .map((file) => readFileSync(resolve(migrationsDir, file), 'utf8'));
+
+/** Minimal D1 facade over node:sqlite; no sqlite3 or wrangler subprocess is involved. */
+function d1(db: DatabaseSync): D1Database {
+ return {
+ prepare(sql: string) {
+ let bound: (string | number | null)[] = [];
+ const statement = {
+ bind(...params: (string | number | null)[]) {
+ bound = params;
+ return statement;
+ },
+ async all() {
+ return { results: db.prepare(sql).all(...bound) as T[] };
+ },
+ async first() {
+ return (db.prepare(sql).get(...bound) ?? null) as T | null;
+ },
+ async run() {
+ db.prepare(sql).run(...bound);
+ return { success: true };
+ },
+ };
+ return statement;
+ },
+ } as unknown as D1Database;
+}
+
+// Three companies: one sole trader (legal_form='ЕТ'), one legal entity (legal_form='ООД'), and one
+// consortium whose lead member is a sole trader (the over-mask guard target). Mirrors the seed
+// used in apps/web's integration lane for the privacy noindex proof (the lyubomir-bozhinov review
+// observation).
+const FIXTURE = `
+INSERT INTO bidders (id, name, bulstat, eik_normalized, eik_valid, is_consortium, kind, legal_form, settlement)
+VALUES
+ ('eik:999000111', 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ', '999000111', '999000111', 1, 0, 'company', 'ЕТ', 'София'),
+ ('eik:200000002', 'СТРОЙ ООД', '200000002', '200000002', 1, 0, 'company', 'ООД', 'Пловдив'),
+ ('eik:300000003', 'ЕТ Иван Петров; Строй ООД','300000003', '300000003', 1, 1, 'consortium', 'ДЗЗД', 'Варна');
+INSERT INTO company_totals
+ (bidder_id, name, kind, eik, eik_valid, settlement, won_eur, contracts, authorities, eu_eur, first_date, last_date)
+VALUES
+ ('eik:999000111', 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ', 'company', '999000111', 1, 'София', 9000000.0, 5, 1, 0, '2021-01-01', '2022-12-01'),
+ ('eik:200000002', 'СТРОЙ ООД', 'company', '200000002', 1, 'Пловдив', 8000000.0, 50, 1, 0, '2020-01-01', '2022-12-28'),
+ ('eik:300000003', 'ЕТ Иван Петров; Строй ООД','consortium', '300000003', 1, 'Варна', 7000000.0, 10, 1, 0, '2021-01-01', '2022-06-01');
+`;
+
+let open: DatabaseSync | null = null;
+
+function realDb(): { sqlite: DatabaseSync; db: D1Database } {
+ const sqlite = new DatabaseSync(':memory:');
+ for (const migration of migrations) sqlite.exec(migration);
+ sqlite.exec(FIXTURE);
+ open = sqlite;
+ return { sqlite, db: d1(sqlite) };
+}
+
+afterEach(() => {
+ open?.close();
+ open = null;
+});
+
+describe('listCompanies on real SQLite — rollup branch must include legal_form (PR #183 review MAJOR)', () => {
+ // Regression for PR #183 review (lyubomir-bozhinov, 2026-08-24, MAJOR #1): the previous
+ // `3cd5d23 perf(db): project legal_form only on the CSV path` commit assumed `toCompanyListItem`
+ // did not consume `legal_form`, but `3458dae fix(privacy): mask sole-trader rows in leaderboard
+ // list mappers` later added `r.legal_form` consumption to the mapper without updating the SQL
+ // source. The result is a SELECT that names `legal_form` against a FROM that does not project
+ // it. The mocked-DB unit suite passes because it returns rows directly without running SQL.
+ // On real D1 this returns 500 for `/companies` and `/companies.data`.
+
+ it('does not throw `no such column: legal_form` on the unfiltered rollup branch', async () => {
+ // Direct replay of the SQL listCompanies builds (matches companies.ts:65 COLS and
+ // companies.ts:182 SELECT). No need to call listCompanies itself — the assertion is purely
+ // that the column projection round-trips against the real schema.
+ const { db } = realDb();
+ await expect(listCompanies(db, {})).resolves.toMatchObject({ items: expect.any(Array) });
+ });
+
+ it('masks a sole trader (legal_form=ЕТ) on the rollup branch — ЕИК null + label replaced', async () => {
+ const { db } = realDb();
+ const page = await listCompanies(db, {});
+ const et = page.items.find((i) => i.slug === '999000111');
+ expect(et).toBeDefined();
+ expect(et?.eik).toBeNull();
+ expect(et?.name).toBe(MASKED_NATURAL_PERSON_LABEL);
+ expect(et?.displayName).toBe(MASKED_NATURAL_PERSON_LABEL);
+ expect(et?.hasEik).toBe(false);
+ });
+
+ it('keeps a legal entity (legal_form=ООД) verbatim on the rollup branch', async () => {
+ const { db } = realDb();
+ const page = await listCompanies(db, {});
+ const ood = page.items.find((i) => i.slug === '200000002');
+ expect(ood).toBeDefined();
+ expect(ood?.eik).toBe('200000002');
+ expect(ood?.name).toBe('СТРОЙ ООД');
+ expect(ood?.hasEik).toBe(true);
+ });
+
+ it('does NOT over-mask a consortium whose lead member is a sole trader (kind guard)', async () => {
+ const { db } = realDb();
+ const page = await listCompanies(db, {});
+ const consortium = page.items.find((i) => i.slug === '300000003');
+ expect(consortium).toBeDefined();
+ expect(consortium?.isConsortium).toBe(true);
+ expect(consortium?.eik).toBe('300000003');
+ expect(consortium?.name).toBe('ЕТ Иван Петров; Строй ООД');
+ });
+
+ it('round-trips with streamCompaniesCsv on the same rollup rows (parity between two surfaces)', async () => {
+ // The list mapper and the CSV streamer both mask the same sole-trader row through
+ // isNaturalPersonBidder(r.legal_form). On the rollup branch the SQL must project legal_form
+ // for BOTH, otherwise the list 500s while the CSV silently emits masked output.
+ const { db } = realDb();
+ const page = await listCompanies(db, {});
+ const csv = await streamCompaniesCsv(db, {}).text();
+ const csvLines = csv.trim().split('\n').slice(1); // skip header
+ const header = csv.trim().split('\n')[0]!.split(',');
+ const eikIdx = header.indexOf('eik');
+ const nameIdx = header.indexOf('name');
+
+ const etFromList = page.items.find((i) => i.slug === '999000111');
+ expect(etFromList?.name).toBe(MASKED_NATURAL_PERSON_LABEL);
+ expect(etFromList?.eik).toBeNull();
+
+ // CSV orders by bidder_id ASC: eik:200000002, eik:300000003, eik:999000111 — the masked
+ // sole-trader row is the third. Identify it by its masked-name token rather than position so
+ // the assertion survives fixture re-orderings.
+ const csvEtLine = csvLines.find(
+ (line) => line.split(',')[nameIdx] === MASKED_NATURAL_PERSON_LABEL,
+ );
+ expect(csvEtLine).toBeDefined();
+ expect(csvEtLine!.split(',')[eikIdx]).toBe(''); // CSV writes empty ЕИК for masked rows
+ });
+});
diff --git a/packages/db/src/queries/companies.test.ts b/packages/db/src/queries/companies.test.ts
index 5a93beeb1..28cc9a5ea 100644
--- a/packages/db/src/queries/companies.test.ts
+++ b/packages/db/src/queries/companies.test.ts
@@ -366,13 +366,15 @@ describe('streamCompaniesCsv masking', () => {
});
});
-describe('listCompanies source() projection — legal_form only when needed (PR #183 review T-007)', () => {
- // The listCompanies hot path returns CompanyListItem via toCompanyListItem, which does not read
- // legal_form. Projecting `b.legal_form AS legal_form` plus the LEFT JOIN on every list query is
- // wasted work. Only the CSV streamer needs legal_form (for the natural-person masker). The
- // unfiltered rollup subquery must therefore omit the join + projection on the list path but
- // keep them on the CSV path. (The base-aggregation CTE is shared and must project legal_form
- // because the CSV path may go through it too.)
+describe('listCompanies source() projection — legal_form on both paths (PR #183 review MAJOR #1)', () => {
+ // PR #183 review (lyubomir-bozhinov, 2026-08-24, MAJOR #1): `toCompanyListItem` masks sole-trader
+ // rows on `r.legal_form`, so the rollup subquery MUST project it on the list path too — a
+ // previous optimization (`3cd5d23 perf(db): project legal_form only on the CSV path`) made
+ // this conditional and broke the unfiltered `/companies` + `/companies.data` page with
+ // `no such column: legal_form` on real D1. Both call sites now request `legalForm: true`; the
+ // unfiltered rollup branch always projects legal_form via the LEFT JOIN on bidders (PK lookup,
+ // bounded cost). These unit tests assert the SQL shape; an end-to-end SQL test
+ // (`companies-rollup-sql.test.ts`) pins the real-D1 behavior.
function spySqlDb(): { db: D1Database; sql: string[] } {
const db = fakeDb();
const sql: string[] = [];
@@ -384,13 +386,13 @@ describe('listCompanies source() projection — legal_form only when needed (PR
return { db, sql };
}
- it('omits LEFT JOIN bidders in the rollup subquery on the listCompanies path (no legal_form needed)', async () => {
+ it('keeps LEFT JOIN bidders + b.legal_form projection in the rollup subquery on the listCompanies path', async () => {
const { db, sql } = spySqlDb();
await listCompanies(db, {});
const rollupQueries = sql.filter((q) => q.includes('company_totals') && q.includes('FROM ('));
expect(rollupQueries.length).toBeGreaterThan(0);
- expect(rollupQueries.every((q) => !q.includes('LEFT JOIN bidders'))).toBe(true);
- expect(rollupQueries.every((q) => !q.includes('b.legal_form AS legal_form'))).toBe(true);
+ expect(rollupQueries.every((q) => q.includes('LEFT JOIN bidders'))).toBe(true);
+ expect(rollupQueries.every((q) => q.includes('b.legal_form AS legal_form'))).toBe(true);
});
it('keeps LEFT JOIN bidders + b.legal_form projection in the rollup subquery on the streamCompaniesCsv path', async () => {
diff --git a/packages/db/src/queries/companies.ts b/packages/db/src/queries/companies.ts
index 7e365b8a1..753e08407 100644
--- a/packages/db/src/queries/companies.ts
+++ b/packages/db/src/queries/companies.ts
@@ -77,18 +77,28 @@ function needsBase(p: CompanyListParams): boolean {
* Keep consumed filter keys in sync with COMPANY_FILTER_KEYS and companyFilterSignature().
*
* `legalForm` controls whether the `b.legal_form AS legal_form` projection is added on the
- * rollup subquery (the unfiltered path). Only the CSV streamer needs it (the natural-person
- * masker keys off `r.legal_form`); the HTML list path maps rows through `toCompanyListItem`
- * which drops it. The base-aggregation CTE (filtered path) always projects legal_form: it
- * already does an INNER JOIN on `bidders b` for the grouping, so the projection is free, and
- * keeping it consistent means the same SQL works for both `listCompanies` and
- * `streamCompaniesCsv` when filters are active.
+ * rollup subquery (the unfiltered path). Both consumers need it on every path: the CSV streamer
+ * keys its natural-person masker off `r.legal_form`, and `toCompanyListItem` (the list-path mapper)
+ * ALSO reads `r.legal_form` for the same masking decision (PR #183 review T-001 — see the
+ * `fix(privacy): mask sole-trader rows in leaderboard list mappers` commit). The rollup subquery
+ * therefore ALWAYS projects legal_form on the list path; the `legalForm` flag is now a no-op
+ * preserved for the explicit "this query masks on legal_form" assertion at call sites. The
+ * base-aggregation CTE (filtered path) always projects legal_form: it already does an INNER JOIN
+ * on `bidders b` for the grouping, so the projection is free.
*/
function source(
p: CompanyListParams,
opts: { legalForm?: boolean } = {},
): { from: string; params: unknown[] } {
- const projectLegalForm = opts.legalForm ?? false;
+ // PR #183 review MAJOR #1 (lyubomir-bozhinov, 2026-08-24): listCompanies's COLS names
+ // `legal_form` (the mapper consumes it for masking), so the rollup subquery MUST project it.
+ // A previous perf optimization (`3cd5d23 perf(db): project legal_form only on the CSV path`)
+ // made this conditional, but the conditional predated the masking mapper's consumption and
+ // broke the unfiltered list path with `no such column: legal_form` on real D1. The join is on
+ // bidders.id (PK), so the cost is bounded — restoring it on both paths is the simplest
+ // correct change. The flag remains so callers can be explicit about their need and tests can
+ // assert the SQL shape.
+ const projectLegalForm = opts.legalForm ?? true;
if (!needsBase(p)) {
const project = projectLegalForm ? ', b.legal_form AS legal_form' : '';
const join = projectLegalForm ? ' LEFT JOIN bidders AS b ON b.id = ct.bidder_id' : '';
@@ -166,7 +176,10 @@ export async function listCompanies(
): Promise> {
const sort = SORTS[p.sort as keyof typeof SORTS] ?? SORTS['won'];
const pageSize = p.pageSize ?? 25;
- const src = source(p); // legal_form not needed — toCompanyListItem drops it
+ // toCompanyListItem masks sole-trader rows on `r.legal_form` (PR #183 review T-001) — the rollup
+ // subquery therefore must project it (the source() default is now to project, see PR #183 review
+ // MAJOR #1). Pass the flag explicitly so the SQL intent is searchable.
+ const src = source(p, { legalForm: true });
const ew = entityWhere(p);
const signature = companyFilterSignature(p);
const ks = keyset({