diff --git a/apps/web/app/lib/csv-export.test.ts b/apps/web/app/lib/csv-export.test.ts
index 9b69cd201..282cfc352 100644
--- a/apps/web/app/lib/csv-export.test.ts
+++ b/apps/web/app/lib/csv-export.test.ts
@@ -1,7 +1,9 @@
-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 { fakeD1 } from '@sigma/test-support';
import { DATA_SOURCE } from './dataSource';
+import * as security from './security';
import { isUnfilteredCsvExport, servedCsvExport } from './csv-export';
const REFRESHED_AT = '2026-06-13T10:00:00Z';
@@ -259,6 +261,8 @@ function csvBytesResponse(body: Uint8Array): Response {
});
}
+type CsvRoute = Parameters[0]['route'];
+
function serve(
r2: InMemoryR2,
stream: () => Response,
@@ -267,12 +271,14 @@ function serve(
params?: object;
sort?: string;
refreshedAt?: string | null | undefined;
+ route?: CsvRoute;
} = {},
): Promise {
+ const route = opts.route ?? 'contracts';
return servedCsvExport({
env: envWith(r2, opts.refreshedAt),
- request: opts.request ?? new Request('http://local/contracts.csv'),
- route: 'contracts',
+ request: opts.request ?? new Request(`http://local/${route}.csv`),
+ route,
params: opts.params ?? { sort: opts.sort ?? 'value-desc' },
stream,
});
@@ -510,3 +516,167 @@ describe('servedCsvExport', () => {
expect((await response.arrayBuffer()).byteLength).toBe(largeBody.byteLength);
});
});
+
+describe('servedCsvExport privacy', () => {
+ 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());
+
+ const response = await serve(r2, stream, { route: 'contracts' });
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get('X-Csv-Cache')).toBe('MISS');
+ expect(response.headers.get('X-Privacy-Mask')).toBe('applied');
+ expect(response.headers.get('X-Robots-Tag')).toBeNull();
+ });
+
+ 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();
+
+ const hitStream = vi.fn(() => csvResponse('from db\n'));
+ const response = await serve(r2, hitStream, { route: 'companies' });
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get('X-Csv-Cache')).toBe('HIT');
+ 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('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'));
+
+ const response = await serve(r2, stream, {
+ route: 'authorities',
+ params: { sort: 'value-desc', q: 'foo' },
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get('X-Csv-Cache')).toBe('dynamic');
+ 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();
+ });
+
+ it('preserves the masking label and excludes the verbatim source name when the streamer emits masked bytes (contracts)', async () => {
+ const VERBATIM_NAME = 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ';
+ const maskedBody = `id,name,eik\nrow-1,${MASKED_NATURAL_PERSON_LABEL},\n`;
+ const r2 = new InMemoryR2();
+ const stream = vi.fn(() => csvResponse(maskedBody));
+
+ const response = await serve(r2, stream, { route: 'contracts' });
+
+ const text = await response.text();
+ expect(text).toContain(MASKED_NATURAL_PERSON_LABEL);
+ expect(text).not.toContain(VERBATIM_NAME);
+ 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();
+ const stream = vi.fn(() => csvResponse(maskedBody));
+
+ const response = await serve(r2, stream, { route: 'contracts' });
+
+ expect(response.headers.get('Cache-Control')).toBe('public, max-age=3600');
+ 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 078dfc0c1..2db063dea 100644
--- a/apps/web/app/lib/csv-export.ts
+++ b/apps/web/app/lib/csv-export.ts
@@ -1,5 +1,6 @@
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';
@@ -56,6 +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);
+ markPrivacyMaskApplied(withSource.headers);
return withSource;
}
@@ -94,10 +96,13 @@ function responseFromR2Object(
cache: CsvCacheState,
) {
if (!hasBody(obj)) {
- return markCsvCache(
- new Response(null, { status: 304, headers: { ETag: obj.httpEtag } }),
- cache,
- );
+ const response304 = new Response(null, {
+ status: 304,
+ headers: { ETag: obj.httpEtag },
+ });
+ // `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);
}
const range = rangeInfo(obj);
@@ -111,6 +116,8 @@ function responseFromR2Object(
});
if (range) headers.set('Content-Range', `bytes ${range.start}-${range.end}/${obj.size}`);
+ // `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);
}
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..379f778b1 100644
--- a/apps/web/app/lib/security.ts
+++ b/apps/web/app/lib/security.ts
@@ -61,3 +61,44 @@ 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`.
+//
+// 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);
+}
+
+// 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/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/company.data.test.ts b/apps/web/app/routes/company.data.test.ts
new file mode 100644
index 000000000..2b186fbb2
--- /dev/null
+++ b/apps/web/app/routes/company.data.test.ts
@@ -0,0 +1,474 @@
+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(),
+ getDb: (env: unknown) => (env as { DB: unknown }).DB,
+}));
+
+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 — 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: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ' });
+ 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 };
+ };
+ // ЕИК 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-0039 §3 + PR #183 review.
+ 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 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-0039 §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: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ' });
+ 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');
+ });
+});
+
+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 c5220870a..d652633d1 100644
--- a/apps/web/app/routes/company.tsx
+++ b/apps/web/app/routes/company.tsx
@@ -1,7 +1,7 @@
import { Link } from 'react-router';
import {
count,
- isNaturalPersonProfileName,
+ isNaturalPersonBidder,
money,
moneyBare,
pct,
@@ -25,18 +25,6 @@ import { networkColumns, networkRows, trendYearColumns } from '../lib/entity-tab
import { withDbRetry } from '../lib/retry';
import { seoMeta } from '../lib/meta';
-function isSingleNaturalPersonProfile(kind: string, legalForm: string | null): boolean {
- if (kind === 'consortium' || !legalForm) return false;
- const normalized = legalForm.trim().toUpperCase();
- return (
- normalized === 'ЕТ' ||
- normalized === 'ET' ||
- normalized.includes('ЕДНОЛИЧЕН ТЪРГОВЕЦ') ||
- normalized.includes('SOLE TRADER') ||
- normalized.includes('INDIVIDUAL')
- );
-}
-
export function meta({ data, params, matches }: Route.MetaArgs) {
const name = data?.company.displayName ?? 'Компания';
const range = coverageRange(data?.coverage.coverageEndYear);
@@ -48,8 +36,14 @@ export function meta({ data, params, matches }: Route.MetaArgs) {
});
if (
data?.company &&
- (isSingleNaturalPersonProfile(data.company.kind, data.company.legalForm) ||
- isNaturalPersonProfileName(data.company.displayName) ||
+ // 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' });
@@ -57,7 +51,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) };
}
@@ -74,6 +75,54 @@ export async function loader({ params, context }: Route.LoaderArgs) {
getEntityNetwork(db, { kind: 'company', id }, { includeCenterOptions: false }),
]);
if (!company) throw new Response('Not Found', { status: 404 });
+ // Privacy policy for the company profile (ADR-0039 §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-0039
+ // 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).
+ //
+ // 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).
+ //
+ // 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)
+ ) {
+ company.eik = null;
+ return Response.json(
+ { company, coverage, trend, network },
+ { 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/apps/web/app/routes/contract.data.test.ts b/apps/web/app/routes/contract.data.test.ts
new file mode 100644
index 000000000..ab7eecbbd
--- /dev/null
+++ b/apps/web/app/routes/contract.data.test.ts
@@ -0,0 +1,232 @@
+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(),
+ getDb: (env: unknown) => (env as { DB: unknown }).DB,
+ 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,
+ currentValueDoubled: false,
+ },
+ frameworkAwards: null,
+ authority: {
+ slug: 'auth-1',
+ orderingUnit: null,
+ name: 'Some Authority',
+ displayName: 'Some Authority',
+ typeLabel: null,
+ settlement: 'Sofia',
+ eik: '000000000',
+ sector: null,
+ totalContracts: 1,
+ totalEur: 1000,
+ },
+ bidder: {
+ slug: 'bidder-1',
+ orderingUnit: null,
+ name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ',
+ displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ',
+ kind: 'company',
+ typeLabel: null,
+ settlement: 'Plovdiv',
+ eik: '123456789',
+ sector: null,
+ totalContracts: 1,
+ totalEur: 1000,
+ },
+ lots: null,
+ subcontractor: null,
+ cohort: null,
+ amendments: [],
+ 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-0039 §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-0040).
+ 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',
+ orderingUnit: null,
+ 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',
+ orderingUnit: null,
+ 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.json.test.ts b/apps/web/app/routes/contract.json.test.ts
new file mode 100644
index 000000000..3499275b7
--- /dev/null
+++ b/apps/web/app/routes/contract.json.test.ts
@@ -0,0 +1,395 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { ContractRecord } from '@sigma/api-contract';
+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,
+}));
+
+import { getContract } from '@sigma/db';
+import { loader, maskContractForPrivacy } from './contract.json';
+
+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,
+ currentValueDoubled: false,
+ },
+ frameworkAwards: null,
+ authority: {
+ slug: 'auth-1',
+ orderingUnit: null,
+ name: 'Some Authority',
+ displayName: 'Some Authority',
+ typeLabel: null,
+ settlement: 'Sofia',
+ eik: '000000000',
+ sector: null,
+ totalContracts: 1,
+ totalEur: 1000,
+ },
+ bidder: {
+ slug: 'bidder-1',
+ orderingUnit: null,
+ name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ',
+ displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ',
+ kind: 'company',
+ typeLabel: null,
+ settlement: 'Plovdiv',
+ eik: '123456789',
+ sector: null,
+ totalContracts: 1,
+ totalEur: 1000,
+ },
+ lots: null,
+ subcontractor: null,
+ cohort: null,
+ amendments: [],
+ 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];
+}
+
+describe('maskContractForPrivacy', () => {
+ it('masks bidder fields and returns a new object when legal_form identifies a sole trader', () => {
+ const record = makeRecord();
+ const masked = maskContractForPrivacy(record, record.bidder_legal_form);
+ expect(masked).not.toBe(record);
+ expect(masked.bidder.eik).toBeNull();
+ expect(masked.bidder.name).toBe(MASKED_NATURAL_PERSON_LABEL);
+ expect(masked.bidder.displayName).toBe(MASKED_NATURAL_PERSON_LABEL);
+ expect(masked.sourceNames.bidder).toBe(MASKED_NATURAL_PERSON_LABEL);
+ expect(masked.bidder.slug).toBe('bidder-1');
+ expect(masked.bidder.totalEur).toBe(1000);
+ });
+
+ it('returns the input by reference when the bidder is a legal entity', () => {
+ 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,
+ },
+ sourceNames: {
+ authority: 'Some Authority',
+ bidder: 'СОФАРМА ТРЕЙДИНГ АД',
+ },
+ });
+ record.bidder_legal_form = 'АД';
+ const masked = maskContractForPrivacy(record, record.bidder_legal_form);
+ expect(masked).toBe(record);
+ expect(masked.bidder.eik).toBe('123456789');
+ expect(masked.bidder.name).toBe('СОФАРМА ТРЕЙДИНГ АД');
+ expect(masked.sourceNames.bidder).toBe('СОФАРМА ТРЕЙДИНГ АД');
+ });
+
+ it('masks when legal_form is null but the name starts with the leading-ЕТ heuristic', () => {
+ const record = makeRecord();
+ record.bidder_legal_form = null;
+ const masked = maskContractForPrivacy(record, null);
+ expect(masked).not.toBe(record);
+ expect(masked.bidder.eik).toBeNull();
+ 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',
+ orderingUnit: null,
+ 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',
+ orderingUnit: null,
+ 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', () => {
+ beforeEach(() => {
+ vi.mocked(getContract).mockReset();
+ });
+
+ it('masks a sole trader and sets X-Robots-Tag: noindex (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-Content-Type-Options')).toBe('nosniff');
+ 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);
+ expect(body.sourceNames.bidder).toBe(MASKED_NATURAL_PERSON_LABEL);
+ expect(body.bidder.name).not.toBe('ЕТ ДРИФТ - НИКОЛАЙ КИРОВ');
+ 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: {
+ slug: 'bidder-2',
+ orderingUnit: null,
+ name: 'СОФАРМА ТРЕЙДИНГ АД',
+ displayName: 'СОФАРМА ТРЕЙДИНГ АД',
+ kind: 'company',
+ typeLabel: null,
+ settlement: 'Sofia',
+ eik: '123456789',
+ sector: null,
+ totalContracts: 1,
+ totalEur: 1000,
+ },
+ sourceNames: {
+ authority: 'Some Authority',
+ bidder: 'СОФАРМА ТРЕЙДИНГ АД',
+ },
+ });
+ record.bidder_legal_form = 'АД';
+ vi.mocked(getContract).mockResolvedValueOnce(record);
+
+ const response = await loader(loaderArgs('c-2'));
+
+ 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 };
+ };
+ expect(body.bidder.eik).toBe('123456789');
+ expect(body.bidder.name).toBe('СОФАРМА ТРЕЙДИНГ АД');
+ expect(body.sourceNames.bidder).toBe('СОФАРМА ТРЕЙДИНГ АД');
+ });
+
+ it('returns the unchanged not_found body when getContract resolves null (behavior 3)', async () => {
+ vi.mocked(getContract).mockResolvedValueOnce(null);
+
+ 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' });
+ });
+
+ 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',
+ orderingUnit: null,
+ 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'));
+ expect(masked.headers.get('Cache-Control')).toBe(
+ 'public, s-maxage=3600, stale-while-revalidate=86400',
+ );
+
+ const legal = makeRecord({
+ bidder: {
+ slug: 'bidder-2',
+ orderingUnit: null,
+ name: 'СОФАРМА ТРЕЙДИНГ АД',
+ displayName: 'СОФАРМА ТРЕЙДИНГ АД',
+ kind: 'company',
+ typeLabel: null,
+ settlement: 'Sofia',
+ eik: '123456789',
+ sector: null,
+ totalContracts: 1,
+ totalEur: 1000,
+ },
+ sourceNames: {
+ authority: 'Some Authority',
+ bidder: 'СОФАРМА ТРЕЙДИНГ АД',
+ },
+ });
+ legal.bidder_legal_form = 'АД';
+ vi.mocked(getContract).mockResolvedValueOnce(legal);
+ const unmasked = await loader(loaderArgs('c-2'));
+ expect(unmasked.headers.get('Cache-Control')).toBe(
+ 'public, s-maxage=3600, stale-while-revalidate=86400',
+ );
+ });
+});
diff --git a/apps/web/app/routes/contract.json.tsx b/apps/web/app/routes/contract.json.tsx
index f9d808b42..78e6570b1 100644
--- a/apps/web/app/routes/contract.json.tsx
+++ b/apps/web/app/routes/contract.json.tsx
@@ -1,26 +1,93 @@
+import { MASKED_NATURAL_PERSON_LABEL, isNaturalPersonBidder } from '@sigma/shared';
+import type { ContractRecord } from '@sigma/api-contract';
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 { serializeJsonForScript } from '../lib/json-ld';
+/**
+ * Pure natural-person mask for the `/contracts/:id.json` body. Returns a copy of `record` with
+ * the bidder's ЕИК cleared and the bidder name (incl. `displayName` and `sourceNames.bidder`)
+ * 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;
+ // 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 {
+ ...publicRecord,
+ bidder: {
+ ...record.bidder,
+ eik: null,
+ name: MASKED_NATURAL_PERSON_LABEL,
+ displayName: MASKED_NATURAL_PERSON_LABEL,
+ },
+ sourceNames: {
+ ...record.sourceNames,
+ bidder: MASKED_NATURAL_PERSON_LABEL,
+ },
+ };
+}
+
+/**
+ * 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
+// name with MASKED_NATURAL_PERSON_LABEL when the bidder is a sole-trader / natural person; the
+// noindex header is set in the same branch so search engines don't surface the masked identifier
+// either. Mirrors the CSV streamer's `bidder_kind !== 'consortium' && isNaturalPersonBidder(...)`
+// gate. JSON serialization uses the shared `serializeJsonForScript` (lib/json-ld.ts) so the
+// `<`/U+2028/U+2029 escaping is consistent with the JSON-LD data island in root.tsx and the two
+// can't drift. `X-Content-Type-Options: nosniff` is the MIME-sniffing guard — the worker sets it
+// globally (baseSecurityHeaders), and it is set explicitly here too so this resource route is safe
+// on its own, not only via the global layer.
export async function loader({ params, context }: Route.LoaderArgs) {
const id = (params.id ?? '').replace(/\.json$/, '');
const record = await getContract(getDb(context.cloudflare.env), contractIdFromSlug(id));
if (!record) return withDataSource(Response.json({ error: 'not_found' }, { status: 404 }));
- // Shared serializer (lib/json-ld.ts) instead of a second local copy \u2014 same `<`/U+2028/U+2029
- // escaping, JSON-equivalent, so the two can't drift. Escaping the content is defense in
- // depth; the actual MIME-sniffing guard is `X-Content-Type-Options: nosniff` \u2014 the worker sets it
- // globally (baseSecurityHeaders), and it is set explicitly here too so this resource route is safe
- // on its own, not only via the global layer.
- return withDataSource(
- new Response(serializeJsonForScript(record), {
- headers: {
- 'Content-Type': 'application/json; charset=utf-8',
- 'X-Content-Type-Options': 'nosniff',
- 'Cache-Control': publicCache(3600),
- },
- }),
- );
+ 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(publicRecord), { headers }));
}
diff --git a/apps/web/app/routes/contract.tsx b/apps/web/app/routes/contract.tsx
index e33474619..0839931f2 100644
--- a/apps/web/app/routes/contract.tsx
+++ b/apps/web/app/routes/contract.tsx
@@ -1,6 +1,7 @@
import { Link } from 'react-router';
import {
count,
+ isNaturalPersonBidder,
isNaturalPersonProfileName,
longDate,
money,
@@ -21,6 +22,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
@@ -99,7 +101,16 @@ export function meta({ data, params, matches }: Route.MetaArgs) {
return tags;
}
-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) };
}
@@ -107,6 +118,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-0039 §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 };
}
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 };
});
}
diff --git a/apps/web/app/routes/privacy.tsx b/apps/web/app/routes/privacy.tsx
index 9e1a67888..6c8dd252d 100644
--- a/apps/web/app/routes/privacy.tsx
+++ b/apps/web/app/routes/privacy.tsx
@@ -68,6 +68,44 @@ export default function Privacy({ loaderData }: Route.ComponentProps) {
+
+
Данни за физически лица и еднолични търговци
+
+ Когато даден запис в СИГМА е разпознат като физическо лице или едноличен търговец (ЕТ),
+ прилагаме допълнителна защита в машинно-четливите формати. Отговорите се предават с HTTP
+ заглавието X-Robots-Tag: noindex, за да не попадат в индексите на
+ търсачките и останалите автоматични роботи.
+
+
+ Засегнати са следните машинно-четливи формати: JSON записът на отделен договор (
+ /contracts/:id.json), машинно-четливият близнак на профила на компанията (
+ /companies/:eik.data), който се зарежда от скриптовете на страницата, и
+ трите публични CSV износа — /contracts.csv, /companies.csv и{' '}
+ /authorities.csv. В тях ЕИК и оригиналното име от източника (raw source
+ name) на физическото лице се заменят с неутрален, неидентифициращ етикет, а полето за
+ ЕИК остава празно. Записите за юридически лица не се променят — техните ЕИК и имена
+ остават непокътнати.
+
+
+ Политиката по X-Robots-Tag: noindex се прилага централизирано, на нивото на
+ инфраструктурата, която обслужва заявките — затова всички машинно-четливи формати
+ получават тази защита по един и същи начин, а бъдещите подобни повърхности ще я наследят
+ автоматично, без да е необходимо допълнително действие.
+
+
+ HTML профилът на дружеството (страницата, която посетителят отваря в браузъра) остава
+ непокътнат по съдържание — името, ЕИК и всички останали полета се показват както в
+ първичния източник. Единствената допълнителна мярка е мета-етикетът noindex
+ , който препоръчва на търсачките да не индексират страницата. Така публичният достъп за
+ хората се запазва, а публичното откриване чрез търсене — не.
+
+
+ Описаната политика е продуктово и инженерно решение за последователно прилагане на
+ защитата на личните данни в машинно-четливите формати на СИГМА. Тя не представлява
+ правен съвет и не заменя официалната консултация с юрист по конкретен случай.
+
+
+
Правно основание
diff --git a/apps/web/workers/app.nofollow.test.ts b/apps/web/workers/app.nofollow.test.ts
new file mode 100644
index 000000000..90c522168
--- /dev/null
+++ b/apps/web/workers/app.nofollow.test.ts
@@ -0,0 +1,431 @@
+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',
+ },
+ });
+ }
+
+ // 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 });
+ },
+}));
+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);
+ });
+});
+
+// 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);
+ });
+});
diff --git a/apps/web/workers/app.ts b/apps/web/workers/app.ts
index d89945876..97e58768c 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 { rateLimitConflictsRoute } from './conflicts-rate-limit';
@@ -83,6 +87,7 @@ async function hardenResponse(response: Response, cacheable: boolean): Promise Решението за маскиране на идентификаторите на едноличните търговци и физическите лица в машинно-четимия изход е продуктово-политическо и инженерно, не правно. 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-0039). Логиката имаше две слабости:
+
+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` се прилага маската.
+
+## Уточнения на решението (приети в ревюто на 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-а се реши, че трябва да се затвори.
+
+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) и отпадането му би влошило публичната достъпност на експортите.
+- **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/0040-centralized-x-robots-tag-worker.md b/docs/adr/0040-centralized-x-robots-tag-worker.md
new file mode 100644
index 000000000..de83a926f
--- /dev/null
+++ b/docs/adr/0040-centralized-x-robots-tag-worker.md
@@ -0,0 +1,45 @@
+# ADR-0040 — Централизирано авторство на `X-Robots-Tag: noindex` в worker-а
+- **Дата:** 2026-07-02
+- **Статус:** Прието
+- **Свързани:** [ADR-0039](0039-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-0039 — само нейното инженерно реализиране.
+
+## Контекст
+
+ADR-0039 задължава всяка машинно-четима повърхност да носи `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-0039).
+
+## Засегнати повърхности
+
+- [`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/adr/README.md b/docs/adr/README.md
index 9b52fc5c7..77af8314e 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -46,5 +46,7 @@
| [0036](0036-tr-rate-limit-remeasured.md) | Ограничителят на ТР, премерен наново: 5 заявки на прозорец, блок по IP, ~161 s изстиване — 429 става изстиване с продължаване, не спиране на хода; заменя контекст 4 и хигиената на решение 7 на [ADR-0033](0033-registry-evidence-replaces-name-distinctiveness.md) | Прието |
| [0037](0037-verdict-cache-crosses-the-run-boundary.md) | Обхождането издава присъди по (връзка, ЕИК), а не актове: булевите присъди се кешират между ходовете, суровият акт се изтрива веднага, гейтът за покритие става постепенен и монотонността поема защитата; заменя „всеки ход, който решава, трябва и да обхожда" от [ADR-0034](0034-registry-lookups-and-decisions-share-one-monthly-run.md) | Прието |
| [0038](0038-reseed-writes-idle-slot-gates-read-live.md) | Презасяването пише в празния слот, а гейтовете (монотонност, под за връзки, хидратация на ЕОП) четат живия — разделя „източник за четене" от „цел за запис"; разширява ADR-0005 | Предложено |
+| [0039](0039-privacy-masking.md) | `noindex` + маскиране на естествено-личностни идентификатори в машинно-четим изход (Issue #173, PR #183) | Прието |
+| [0040](0040-centralized-x-robots-tag-worker.md) | Централизирано авторство на `X-Robots-Tag: noindex` в worker-а (маркерният договор; refactor на PR #183) | Прието |
Свързан проектен документ: [spec/related-persons-foundation.md](../spec/related-persons-foundation.md).
diff --git a/docs/architecture.md b/docs/architecture.md
index ab598c091..0b80a7931 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -39,7 +39,9 @@
Виж индекса в [`adr/README.md`](adr/README.md). Ключовите за v1: рендиране/сигурност (0001),
D1 като хранилище (0002), `value_flag` стойностна база (0003), CSP `style-src` (0004), blue/green
-rollback (0005), dedup на двата източника (0006).
+rollback (0005), dedup на двата източника (0006), [`noindex` + маскиране на естествено-личностни
+идентификатори в машинно-четим изход (0039)](adr/0039-privacy-masking.md) и
+[централизирано авторство на `X-Robots-Tag: noindex` в worker-а (0040)](adr/0040-centralized-x-robots-tag-worker.md).
## Справочни документи
diff --git a/docs/privacy-masking.md b/docs/privacy-masking.md
new file mode 100644
index 000000000..c73096053
--- /dev/null
+++ b/docs/privacy-masking.md
@@ -0,0 +1,50 @@
+# Маскиране на естествени лица и `X-Robots-Tag: noindex` — ръководство за разработчици
+
+Това е оперативното ръководство за механиката зад политиката от [ADR-0039](adr/0039-privacy-masking.md) и решението за централизация от [ADR-0040](adr/0040-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)
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 cc3ff40e9..87c5cf711 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 { fakeD1, type FakeD1Call } from '@sigma/test-support';
import { listCompanies, streamCompaniesCsv, type CompanyListParams } from './companies';
import type { CompanyTotalsRow } from './rows';
@@ -19,6 +20,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,
},
];
@@ -40,6 +42,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,
},
];
@@ -119,6 +122,281 @@ 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');
+ });
+
+ // 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('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[] = [];
+ const real = db.prepare.bind(db);
+ db.prepare = ((q: string) => {
+ sql.push(q);
+ return real(q);
+ }) as typeof db.prepare;
+ return { db, sql };
+ }
+
+ 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);
+ });
+
+ 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 8f2665238..753e08407 100644
--- a/packages/db/src/queries/companies.ts
+++ b/packages/db/src/queries/companies.ts
@@ -4,6 +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 { csvCell } from './csv';
import { assertCovers } from './filter-guard';
import { filterSignature, keyset, pageCursors } from './keyset';
@@ -61,7 +62,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;
@@ -74,9 +75,38 @@ 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). 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): { from: string; params: unknown[] } {
- if (!needsBase(p)) return { from: 'company_totals', params: [] };
+function source(
+ p: CompanyListParams,
+ opts: { legalForm?: boolean } = {},
+): { from: string; params: unknown[] } {
+ // 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' : '';
+ return {
+ 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) {
@@ -91,8 +121,10 @@ 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,
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,
@@ -144,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);
+ // 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({
@@ -227,7 +262,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',
@@ -261,17 +296,16 @@ export function streamCompaniesCsv(db: D1Database, p: CompanyListParams): Respon
}
let block = '';
for (const r of results) {
+ // `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 +=
- [
- r.eik,
- r.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;
diff --git a/packages/db/src/queries/contracts.test.ts b/packages/db/src/queries/contracts.test.ts
index 0315c2000..b426aa8aa 100644
--- a/packages/db/src/queries/contracts.test.ts
+++ b/packages/db/src/queries/contracts.test.ts
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest';
+import { MASKED_NATURAL_PERSON_LABEL } from '@sigma/shared';
import { fakeD1 } from '@sigma/test-support';
-import { getContractFacets, listContracts, normalizeContractSort } from './contracts';
+import {
+ getContractFacets,
+ listContracts,
+ normalizeContractSort,
+ streamContractsCsv,
+} from './contracts';
describe('normalizeContractSort', () => {
it('passes known sort keys through', () => {
@@ -27,6 +33,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,
@@ -111,3 +118,317 @@ 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');
+ });
+
+ // 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');
+ });
+});
+
+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 4bbb70200..cd5d9d1dc 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 {
@@ -99,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;
@@ -108,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
@@ -206,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,
@@ -216,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,
@@ -426,6 +444,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. */
@@ -443,7 +462,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)
@@ -456,6 +476,17 @@ export function streamContractsCsv(db: D1Database, p: ContractListParams): Respo
}
let block = '';
for (const r of results) {
+ const bidderName = cleanName(r.bidder_name);
+ // `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);
+ const contractorEik = isNatural ? '' : r.contractor_eik;
block +=
[
// CSV carries the RAW id (no URL escaping): literal `/`, `%`, … — not the `%2F`/`%25`
@@ -466,8 +497,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 9786347ef..4a476ffe4 100644
--- a/packages/db/src/queries/details.ts
+++ b/packages/db/src/queries/details.ts
@@ -421,6 +421,7 @@ interface ContractDetailRow {
bidder_kind: 'company' | 'consortium';
bidder_eik: string | null;
bidder_settlement: string | null;
+ bidder_legal_form: string | null;
}
interface AmendmentRow {
@@ -465,7 +466,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,
@@ -483,7 +484,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
@@ -746,5 +747,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..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', () => {
@@ -33,6 +34,7 @@ describe('toCompanyListItem', () => {
eu_eur: 10000,
first_date: '2022-01-01',
last_date: '2024-06-01',
+ legal_form: 'ООД',
};
it('maps core fields', () => {
@@ -77,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 9d4a48a0e..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';
@@ -44,19 +49,36 @@ export interface CompanyTotalsRow {
eu_eur: number;
first_date: string | null;
last_date: string | null;
+ legal_form: string | null;
}
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),
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..e39a13fe6 100644
--- a/packages/shared/src/format.ts
+++ b/packages/shared/src/format.ts
@@ -198,6 +198,40 @@ 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 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 {
+ 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