diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0ff9c3b..2c57bf69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,3 +51,10 @@ jobs: - name: Test if: ${{ !cancelled() }} run: pnpm test + # Docs integrity (#102): fail on a dangling docs reference in source or an + # unindexed doc. The checker self-tests first, so the gate is itself gated. + - name: Docs integrity + if: ${{ !cancelled() }} + run: | + pnpm check:docs:test + pnpm check:docs diff --git a/README.md b/README.md index 1c3deecc..3a65a504 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,11 @@ Дизайнът и решенията живеят в [`docs/`](docs/) — започнете оттам: +- [`docs/architecture.md`](docs/architecture.md) — преглед на системата и карта към решенията. +- [`docs/adr/`](docs/adr/README.md) — Architecture Decision Records (по едно решение на файл). +- [`docs/core-scope.md`](docs/core-scope.md) — доменен модел и речник на данните. - [`docs/etl.md`](docs/etl.md) — ETL pipeline-ът и емисията от ЦАИС ЕОП. - [`docs/deploy.md`](docs/deploy.md) — деплой към Cloudflare. -- [`docs/architecture.md`](docs/architecture.md) — архитектурни решения (ADR). - [`docs/spec/ai-assistant.md`](docs/spec/ai-assistant.md) — спецификация на AI асистента (планиран). - [`docs/README.md`](docs/README.md) — **пълен индекс** на документацията, вкл. достъпност и стандартите за ревю. diff --git a/apps/web/app/routes/contract.tsx b/apps/web/app/routes/contract.tsx index 344060af..bddb9119 100644 --- a/apps/web/app/routes/contract.tsx +++ b/apps/web/app/routes/contract.tsx @@ -306,7 +306,7 @@ export default function Contract({ loaderData }: Route.ComponentProps) { не е посочен в данните ), // Break the gross count down by status/category — surfaces what „Брой оферти" actually - // means (it's the gross submitted count, including rejections — see docs/etl-pipeline.md + // means (it's the gross submitted count, including rejections — see docs/etl.md // and the staging columns at packages/db/migrations/0000_init.sql:363-365). Each clause // only appears when the source published a non-zero value, so contracts without any // rejection/SME data fall back to the original „самите оферти…" footnote. diff --git a/docs/README.md b/docs/README.md index 278e4ffe..568550ad 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,12 +2,20 @@ Дизайнът, решенията и спецификациите на платформата за прозрачност на обществените поръчки живеят тук. За преглед на продукта и бърз старт вижте [`README.md`](../README.md) в корена; работните конвенции са в [`AGENTS.md`](../AGENTS.md). -- [`architecture.md`](architecture.md) — архитектурните решения: рендериране (React Router v7 SSR на Workers), сигурност и достъп до D1. +- [`architecture.md`](architecture.md) — преглед на системата (поток на данните, двата Worker-а) и карта към решенията. +- [`adr/`](adr/README.md) — Architecture Decision Records: по едно архитектурно решение на файл, с индекс и шаблон. +- [`core-scope.md`](core-scope.md) — доменният модел и **речникът на данните**: таблици, rollup-и, `value_flag`/`date_flag`, семантиката на `amount_eur`. - [`etl.md`](etl.md) — ETL pipeline-ът и open-data емисията на ЦАИС ЕОП (`storage.eop.bg`): зареждане, опресняване и производни таблици. +- [`etl-pipeline-state.md`](etl-pipeline-state.md) — анализ на текущото състояние на ETL pipeline-а. +- [`etl-architecture.md`](etl-architecture.md) — целевата ETL архитектура (RFC): предложение за състоянието и реда на изпълнение. +- [`v1-implementation-plan.md`](v1-implementation-plan.md) — precompute слоят и пагинацията (защо rollup-и и keyset вместо per-request GROUP BY / OFFSET). +- [`integrity-gate.md`](integrity-gate.md) — reconciliation gate-ът: hard asserts върху тоталите при import/CI. +- [`anomaly-report.md`](anomaly-report.md) — cross-row аномалии при опресняване: какво `value_flag` не хваща на ниво отделен договор. - [`deploy.md`](deploy.md) — деплой към Cloudflare: двата Worker-а (`sigma`, `sigma-etl`) и споделеният D1 per environment. - [`api.md`](api.md) — публичните данни и машинно четими endpoint-и (CSV/JSON/sitemap), query грамата на филтрите и лицензът — за разработчици, които строят върху данните. - [`accessibility.md`](accessibility.md) — достъпност (WCAG 2.1 AA / EN 301 549): какво покрива платформата и наблюденията за вградената приставка за достъпност. - [`spec/ai-assistant.md`](spec/ai-assistant.md) — спецификация на разговорния аналитичен слой над СИГМА (BgGPT, текст и глас). +- [`spec/assistant-contracts.md`](spec/assistant-contracts.md) — контрактите BE↔FE за AI асистента (Фаза 1 → Фаза 2). ## Стандарти за ревю diff --git a/docs/adr/0001-rendering-and-security.md b/docs/adr/0001-rendering-and-security.md new file mode 100644 index 00000000..e251f26a --- /dev/null +++ b/docs/adr/0001-rendering-and-security.md @@ -0,0 +1,81 @@ +# ADR-0001 — Стратегия за рендиране на front-end-а и модел на сигурност + +- **Статус:** Прието +- **Дата:** 2026-05-21 +- **Обхват:** Итерация 1 — публичният, read-only explorer на обществените поръчки (АОП). Документът отбелязва и пътя напред към по-широката платформа (оценка на риска, аномалии, картелен анализ — roadmap). + +> **Актуализация (2026-05-21):** Решението за фреймуърк по-долу беше променено от SvelteKit на **React Router v7 върху Cloudflare Workers** след претегляне на екосистемата, набирането на хора и контрибутори, primitives за достъпност и екосистемата за визуализации/AI. Решенията за рендиране (§2) и за сигурност (§3) са независими от фреймуърка и остават непроменени; обновени бяха само техните специфични за фреймуърка механики. + +## Контекст + +Итерация 1 е **публичен, read-only** отчетен и визуализационен слой върху ~190 хил. реда договори от АОП в Cloudflare D1, насочен към граждани, журналисти и НПО, с интерфейс на български. Уеб приложението първоначално беше скелетирано върху **SvelteKit**; този ADR преразглежда този избор. Сървърната среда е **Cloudflare Workers**, които четат D1. Данните пристигат като **периодични bulk зареждания** (`scripts/import.mjs`), а не като жива емисия; в тази итерация **няма публичен write път и няма автентикация**. + +Три въпроса стояха в основата на решението: + +1. Трябва ли front-end-ът да премине от Svelte към React? +2. Трябва ли страниците да се рендират на сървъра (SSR), да се пре-рендират (статично), или да са класическо клиентско SPA срещу API? +3. Променят ли отговора атаките през SSR / „hydration“? + +## Решение + +### 1. React с React Router v7 (framework mode) върху Cloudflare Workers + +Избран пред първоначалния SvelteKit скелет след претегляне на дълготрайните фактори: + +- **Екосистема и преизползване** — най-голямата екосистема от компоненти/библиотеки и най-богатите опции точно за повърхностите, върху които СИГМА стъпва: мрежови графи на връзки (`@xyflow/react`), таблици за големи данни (TanStack Table / AG Grid), графики (visx / Recharts), а за по-късния асистент — AI SDK (`@ai-sdk/react`) и зрели UI-та за асистенти. +- **Набиране на хора и open-source контрибутори** — значително по-голям talent pool; релевантно за вероятно OSS гражданска платформа („Лицензът ще бъде определен преди публичното пускане“). +- **Primitives за достъпност** — React Aria / Radix са сред най-добрите в класа си за изричната цел **WCAG 2.2 AA**. +- **Дълготрайност** — най-защитимият дългосрочен избор за дълголетна платформа от обществена полза. + +**React Router v7 (framework mode), а не Next.js** — той върви чисто върху **Cloudflare Workers** (през `@cloudflare/vite-plugin`) и запазва модела SSR-на-edge и хибридното рендиране от §2. Приети компромиси спрямо SvelteKit: по-голям клиентски runtime (смекчен от SSR + кеширане) и липса на first-party CF *Pages* адаптер — RR7 се деплойва като Worker, което освен това уеднаквява `apps/web` с останалите `apps/*`. + +### 2. Рендиране: хибридно, избрано според повърхността + +По подразбиране **SSR + edge кеширане**; пре-рендиране само за реално статичните страници; клиентско рендиране само за интерактивните „острови“. **Не** чисто SPA за публично съдържание (SEO/споделяемост + цена на първото изрисуване); **не** build-time пре-рендиране на целия корпус (десетки хиляди страници за компании/институции, а търсенето изобщо не може да се пре-рендира). + +| Повърхност | Стратегия | Защо | +| --- | --- | --- | +| Начало, За проекта, **методология / „как работят червените флагове“**, документация за отворените данни | Пре-рендиране (статично) | Рядко се променят → надеждно + безплатно | +| Профил на компания (по ЕИК), страница на институция, детайл на поръчка/обособена позиция | **SSR + edge кеш** (`s-maxage` + `stale-while-revalidate`), purge при презареждане на набора данни | Голям обем, променя се само при bulk презареждане → скорост като при статика + устойчивост на DDoS, актуалност след презареждане | +| Explorer: търсене / филтри / класации („най-големи бенефициенти“) | SSR, кратък кеш, варира според заявката | Безкрайно URL пространство; задвижван от SQL | +| Интерактивни визуализации (граф на мрежа/потоци, карта на България, сортируеми таблици) | Клиентски рендиран **остров**, хидриран върху SSR-ната страница + първоначални данни | Нуждае се от клиентска интерактивност; SSR на обвивката заради SEO/първо изрисуване | + +Тъй като наборът данни е периодична снимка (а не жив), кеш TTL-ите може да са дълги, а едно презареждане просто **purge-ва** кеша — почти всички предимства на статиката, без да се изброява корпусът при build. + +### 3. Модел на сигурност + +Приоритети за публичен сайт за прозрачност: **интегритет ≈ наличност ≫ конфиденциалност** — данните са публични по дизайн; тяхната *достоверност* и *uptime* са продуктът. + +**Контроли в итерация 1:** + +- **Read път да остане read-only.** Ingestion-ът е офлайн (`scripts/import.mjs`); няма публичен write endpoint. Това се запазва — без публична мутация, без админ в итерация 1. +- **Edge модел + кеширане като поглъщане на DDoS.** Cloudflare поглъща L3/4; кешираният HTML означава, че атакуващ / scrape трафик удря кеша, а не D1. +- **WAF + rate limiting** върху задвижвания от SQL explorer/търсене и върху всеки export на отворени данни; ограничаване на `limit` / пагинация / размер на export-а, така че нито една заявка да не сканира цялата таблица. +- **Строг CSP + security headers** — генериране на per-request nonce в `entry.server.tsx`, подаван към `` / `` на React Router и към header-а `Content-Security-Policy` (за да са разрешени hydration скриптовете на фреймуърка без `unsafe-inline`). Добавяне на HSTS, `X-Content-Type-Options`, `Referrer-Policy`. +- **D1 prepared statements само с bound параметри**; никога конкатениране на SQL чрез низове. + +**По въпроса за „hydration атаките“** — реалните класове и как този дизайн ги адресира: + +- **Изтичане между потребители през кеширан персонализиран SSR** — неприложимо в итерация 1 (без автентикация; всяка страница е анонимна и идентична → безопасна за кеширане). Остава предотвратено и по-късно, като се кешират *само* анонимни публични страници и никога автентикираните. +- **Свръх-сериализиране на тайни в hydration payload-а** — смекчено чрез типизираните response DTO-та в `packages/api-contract` (отдават проекции на същностите, никога сурови редове) и чрез държане на код, носещ credentials, извън уеб/read пътя. DTO-тата да остават без вътрешни-само полета. +- **XSS през сериализирано състояние / рендирани данни** — разчита се на авто-escaping-а на React/JSX; **никога `dangerouslySetInnerHTML`** върху текст от АОП (институция / компания / предмет са от външен източник); строгият CSP е резервната защита. + +**Отложено за по-късни фази** (повърхностите на по-широката платформа): + +- **Workflow-и за възложители/участници + админ** → Cloudflare Access (SSO + MFA) за админ; строго разделяне на публичен/персонализиран кеш; изолация на write пътя в отделен worker. +- **AI асистент за обществени поръчки** → маршрутизиране през **AI Gateway** (лимити за rate/разход, кеширане, логване); read-only, параметризирани инструменти; заземяване на всяко твърдение в изчислени данни (риск от клевета); никакво кеширане; рендиране на изхода като текст. Спецификация — [`spec/ai-assistant.md`](../spec/ai-assistant.md). + +## Последствия / scaffold follow-up-и + +- Конфигуриране на пре-рендирането в `react-router.config.ts` (`prerender` пътища) за статичните информационни route-ове. +- Задаване на `Cache-Control` (`s-maxage` + `stale-while-revalidate`) през `headers` export-ите на route-овете при SSR data route-овете; иницииране на cache purge (по URL/таг) в края на скрипта за зареждане на данните. +- Задаване на CSP + security headers в `entry.server.tsx` (или Worker middleware) с per-request nonce върху ``. +- Добавяне на Cloudflare rate-limit правило + WAF managed ruleset за explorer/export endpoint-ите (infra конфигурация, проследявана отделно). +- Премахване или auth-gate на отворения `POST /etl/run` от скелета в `apps/etl` преди какъвто и да е деплой. + +## Свързани документи + +- [`etl.md`](../etl.md) — ETL pipeline-ът и емисията от ЦАИС ЕОП. +- [`deploy.md`](../deploy.md) — деплой към Cloudflare и оперативна сигурност. +- [`spec/ai-assistant.md`](../spec/ai-assistant.md) — спецификация на планирания AI асистент. +- [`README.md`](../../README.md) на хранилището и [`AGENTS.md`](../../AGENTS.md) — общ преглед и работни конвенции. diff --git a/docs/adr/0002-d1-as-datastore.md b/docs/adr/0002-d1-as-datastore.md new file mode 100644 index 00000000..1678c308 --- /dev/null +++ b/docs/adr/0002-d1-as-datastore.md @@ -0,0 +1,29 @@ +# ADR-0002 — Cloudflare D1 като обслужващо хранилище + +- **Статус:** Прието +- **Дата:** 2026-06-30 (записано със задна дата) +- **Обхват:** Итерация 1 — обслужваната база за explorer-а и ETL-а. + +## Контекст + +Данните пристигат като **периодични bulk зареждания** от ЦАИС ЕОП, не като жива емисия; v1 е +публичен, read-only слой без write път. Изпълнението е на **Cloudflare Workers** на edge. +Търси се хранилище, което: е колокирано с Workers (нисък latency без отделен DB слой), има +релационни/SQL семантики за класациите и join-овете, и е евтино за read-heavy публичен трафик. + +## Решение + +Ползваме **Cloudflare D1** (SQLite на edge) като единствено обслужвано хранилище — **един D1 на +среда**, споделян от двата Worker-а: `sigma` чете, `sigma-etl` пише (виж [`deploy.md`](../deploy.md)). +Схемата е **консолидиран baseline** ([`0000_init.sql`](../../packages/db/migrations/0000_init.sql)) плюс +тънки добавъчни миграции (напр. `0001_flow_pairs_bidder_index.sql`), а не верига от самото начало — +v1 е pre-production и всеки импорт стартира от свежа база; пълна верига инкрементални миграции се +въвежда чак когато има деплойнати данни, които не може да се загубят. + +## Последствия + +- **D1 таксува прочетените редове, не върнатите** — това директно мотивира precompute слоя и + keyset пагинацията (виж [`v1-implementation-plan.md`](../v1-implementation-plan.md)). +- FK се налагат от D1 → пълните ребилди трият в child→parent ред; `normalize-raw.sql` се изпълнява + като един атомарен batch (явен `BEGIN/COMMIT` се отхвърля), така че провален run се отменя. +- Rollback на refresh се прави на ниво слот, не на ниво ред (виж [ADR-0005](0005-blue-green-d1-rollback.md)). diff --git a/docs/adr/0003-value-flag-data-quality.md b/docs/adr/0003-value-flag-data-quality.md new file mode 100644 index 00000000..0c98b1f8 --- /dev/null +++ b/docs/adr/0003-value-flag-data-quality.md @@ -0,0 +1,34 @@ +# ADR-0003 — Verdict за качество на данните (`value_flag` / `date_flag`) и единна стойностна база + +- **Статус:** Прието +- **Дата:** 2026-06-30 (записано със задна дата) +- **Обхват:** Итерация 1 — `contracts` и всички сумирани повърхности. + +## Контекст + +Изходните стойности от регистъра са шумни: подозрителни анекси, нереалистични суми, договори, +подписани преди публикуване. На **министерски-видим** сайт мълчаливото сумиране на боклук е +недопустимо, но и мълчаливото изтриване на редове крие данни. Нужен е **недеструктивен verdict**: +редовете остават видими, но проблемните се изключват от тоталите по едно ясно правило. + +## Решение + +Всеки договор носи `value_flag` (`ok` | `review` | `value_low` | `value_suspect` | `annex_suspect`) +и `date_flag` (`ok` | `signed_after_publication`). Каноничната сумирана колона е **`amount_eur`** — +попълнена за **всичките пет** флага (`value_suspect` се repair-ва до процедурната оценка, +`annex_suspect` пада към `signing`/`current`). Оттук — едно правило за всички суми: + +> Сумирай само `amount_eur` с предиката `amount_eur IS NOT NULL` — единната стойностна база за +> rollup-ите и за страниците. `amount_eur` е `NULL` (и така изключен) само когато няма надеждна EUR +> стойност: чуждовалутен ред без покрит ECB курс, `value_suspect` без процедурна оценка, или ред без +> `signing`/`current` стойност. + +`home_totals.suspect` е отделен KPI — брой на `value_suspect` редовете, които сами се сумират — а не +множеството, изключено от сумите. Пълната семантика е в [`core-scope.md`](../core-scope.md). + +## Последствия + +- Всяка нова заявка/страница, която сумира, трябва да ползва същия предикат — иначе rollup ↔ страница + се разминават. Това е merge-блокер по точност. +- [`integrity-gate.md`](../integrity-gate.md) налага инварианта като hard assert на import/CI. +- `annex_suspect` е частичен случай: брои се в `amount_eur`, но `current_value_eur` се потиска. diff --git a/docs/adr/0004-style-src-unsafe-inline.md b/docs/adr/0004-style-src-unsafe-inline.md new file mode 100644 index 00000000..002dd6a8 --- /dev/null +++ b/docs/adr/0004-style-src-unsafe-inline.md @@ -0,0 +1,26 @@ +# ADR-0004 — `style-src` запазва `'unsafe-inline'` (CSP) + +- **Статус:** Прието +- **Дата:** 2026-06-30 (записано със задна дата) +- **Обхват:** Итерация 1 — CSP в [`apps/web/app/lib/security.ts`](../../apps/web/app/lib/security.ts). Свързано с #11 (follow-up от security-audit PR #6). + +## Контекст + +`script-src` вече е строг: per-request nonce за некешираната SSR и per-script SHA-256 hash-ове за +edge-кеширания HTML (`workers/app.ts`) — без `'unsafe-inline'`. `style-src` обаче пази +`'unsafe-inline'`, защото приложението слага inline `style={…}` атрибути за динамични стойности. + +Ключов факт: **CSP nonce се прилага към `