diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..adc8f2b2 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,74 @@ +# Deploy workflow — template for CI/CD pipeline +# Real registry URLs and deployment targets come from customer (A2/A8) + +name: Deploy + +on: + push: + branches: [main] + +env: + NODE_VERSION: "24" + PNPM_VERSION: "9" + # Placeholder: replace with customer registry + REGISTRY: "registry.example.com" + IMAGE_STANDALONE: "flights-web-standalone" + IMAGE_REMOTE: "flights-web-remote" + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + - name: Lint + run: pnpm lint + + - name: Test + run: pnpm test + + - name: Build both targets + run: pnpm build:both + + - name: Build Docker images + run: | + docker build -f Dockerfile.react -t ${{ env.REGISTRY }}/${{ env.IMAGE_STANDALONE }}:${{ github.sha }} . + docker build -f Dockerfile.remote -t ${{ env.REGISTRY }}/${{ env.IMAGE_REMOTE }}:${{ github.sha }} . + + # Placeholder: push to customer registry + # - name: Push Docker images + # run: | + # docker push ${{ env.REGISTRY }}/${{ env.IMAGE_STANDALONE }}:${{ github.sha }} + # docker push ${{ env.REGISTRY }}/${{ env.IMAGE_REMOTE }}:${{ github.sha }} + + # Placeholder: deploy to testing environment + # - name: Deploy to testing + # run: | + # echo "Deploy standalone image to testing environment" + # echo "Run post-deploy smoke test" + + # Placeholder: auto-rollback on health-check failure + # - name: Post-deploy health check + # run: | + # curl -f https://testing.example.com/health || echo "Health check failed — trigger rollback" diff --git a/.gitignore b/.gitignore index 601fac48..58b6e881 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ dist/ ClientApp/dist/ ClientApp/coverage/ ClientApp/.storybook-out/ +.pnpm-store/ +.pnpm-debug.log # Logs *.log @@ -28,3 +30,8 @@ appsettings.Development.json # wwwroot build output (keep static assets, ignore generated JS) wwwroot/dist/ + +# Module Federation build artifacts +@mf-types.zip +@mf-types/ +.mf/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..5003ba8f --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.2.0 diff --git a/CLAUDE.md b/CLAUDE.md index 5fa6845c..6194c322 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,129 +1,68 @@ -# CLAUDE.md +# Aeroflot.Flights.Web -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Current State -## Project Overview +ASP.NET host (`Aeroflot.Flights.Web.csproj`, `Startup.cs`, `Program.cs`) serving an Angular 12 SPA located in `ClientApp/` (Angular CLI + custom webpack config, Karma, Cypress, Storybook/Compodoc). -This is the Aeroflot Flights Web application — a flight information/booking interface. The current codebase is **Angular 12** (located in `ClientApp/`), and it is being **rewritten to React** using ModernJS with Module Federation 2.0 as a remote micro-frontend component. +## Target: React Remote Component Rewrite -## Current Angular App (ClientApp/) +The Angular app is being rewritten as a **remote frontend component** embeddable in the customer's channel apps (Web, PWA). Requirements below are contractual — treat them as hard constraints when designing or implementing the new codebase. -### Dev Commands +### 1. Tech Stack -```bash -npm start # Dev server on :4200 (proxies /api, /flights → flights.test.aeroflot.ru) -npm run build:prod # Production build -npm run build:dev # Dev build with source maps -npm run build:testing # Testing environment build -npm run test # Karma/Jasmine with coverage → coverage/test/ -npm run test:ci # Tests with TeamCity reporter -npm run lint # ESLint -npm run pretty # Prettier (ts + html) -npm run analyze # Webpack bundle analyzer -npm run storybook # Storybook component docs -``` +- **ModernJS (SSR)** for the frontend framework. +- **Module Federation 2.0**. Any bundler with MF 2.0 support is acceptable: Webpack 5, Rsbuild, Rspack, or Vite. +- Must emit `mf-manifest.json` at `https:///mf-manifest.json` exposing components and logic. Reference: https://module-federation.io/guide/basic/webpack.html. +- **React 18+**, Concurrent Mode compatible. +- `` support required when async loading is used. +- Component bodies must be side-effect free — **no `fetch` outside `useEffect`**. +- Dynamic imports must use `React.lazy()`. -### Path Aliases (tsconfig.json) +### 2. Data & Integrations -| Alias | Resolves To | -|---|---| -| `@app/*` | `src/app/*` | -| `@components/*` | `src/app/components/*` | -| `@shared/*` | `src/app/shared/*` | -| `@modules/*` | `src/app/modules/*` | -| `@features/*` | — (use explicit paths) | -| `@online-board/*` | `src/app/features/online-board/*` | -| `@schedule/*` | `src/app/features/schedule/*` | -| `@toolkit/*` | `src/app/toolkit/*` | -| `@utils/*` | `src/app/utils/*` | -| `@typings/*` | `src/typings/*` | -| `@environment` | `src/environments/environment` | +- Consumes customer REST APIs, JSON payloads only. +- Rendered data must stay consistent with API responses (no stale state leaking into the UI). -### Architecture +### 3. Performance -``` -src/app/ -├── features/ # Lazy-loaded feature modules -│ ├── online-board/ # Main flight departure/arrival board -│ ├── schedule/ # Schedule search -│ ├── flights-map/ # Map view (feature-flag gated) -│ └── popular-requests/ -├── modules/ -│ ├── components/ # Reusable display components -│ ├── pages/ # Page-level components (board, details, schedule, errors) -│ └── prime-components-module.ts -├── shared/ -│ ├── services/ # ~37 services (API, localization, settings, SEO, etc.) -│ ├── pipes/ -│ ├── pipes-legacy/ -│ ├── models-legacy/ # ~50 legacy DTOs -│ ├── interceptor/ # AppInterceptor (HTTP) -│ └── shared.module.ts -├── guards/ -│ └── feature-flag.guard.ts -└── toolkit/ # Custom UI component library -``` +- Must sustain **100 RPS**. -**State management**: No NgRx/Akita — pure RxJS with BehaviorSubjects exposed as Observables from services. +### 4. Availability & Fault Tolerance -**Real-time**: `@microsoft/signalr` for WebSocket connections (tracker hub URL set per environment). +- VMs hosting the component must be geographically distributed. +- 24/7/365 availability; recovery time ≤ 6 hours after infra is restored. -**Routing**: All language prefixes (`/ru`, `/en`, `/es`, `/fr`, `/it`, `/ja`, `/ko`, `/zh`, `/de`) redirect to `/onlineboard`. Feature modules are lazy-loaded; `flights-map` is guarded by `FeatureFlagGuard`. +### 5. Security -**i18n**: `@ngx-translate` with `messageformat` compiler. Translation JSON files in `src/assets/i18n/`. Supports 9 languages. +- Component must be isolated — no attack surface exposed to other components of the host site. -**UI**: PrimeNG 10 + custom `toolkit/` components. +### 6. SEO & Accessibility -### Environment Config +- SEO optimization required. +- Render microdata: **JSON-LD** and **OpenGraph**. +- Web analytics integrations: **Yandex.Metrica, CTM, Variocube, Key-Astrom (Dynatrace)**. -Each `src/environments/environment*.ts` exposes: -- `apiRootUrl` / `wsRootUrl` — proxied in dev, real URLs in prod -- `features.flightsMap` — boolean feature flag -- Refresh intervals, calendar date ranges -- Ticket purchase time windows (prod only) +### 7. Cross-Platform -## React Rewrite Requirements +- Embeddable in multiple channel apps (Web, PWA). +- Fully responsive ("fluid") layout across all screen sizes. -The new component must be a **ModernJS SSR** remote micro-frontend with: +### 8. Logging & Monitoring -### Stack -- **Framework**: ModernJS (SSR enabled) -- **Bundler**: Webpack 5, Rsbuild, Rspack, or Vite — whichever supports Module Federation 2.0 -- **Module Federation**: Must expose `mf-manifest.json` at `https:///mf-manifest.json` -- **React**: 18+ with Concurrent Mode, `` support, no side-effects outside `useEffect`, dynamic imports via `React.lazy()` +- Frontend log collection in a customer-specified format, shipped to the customer's log aggregation system. +- System event monitoring with export to a metrics aggregator. -### Functional Parity (port from Angular) -- **Features to port**: online-board, schedule, flights-map, popular-requests -- **Data source**: REST API (JSON) — same endpoints currently proxied under `/api` -- **Real-time**: SignalR hub integration -- **Maps**: Leaflet (or equivalent) -- **i18n**: 9 languages -- **Multi-theme**: Responsive / "rubber layout" for Web + PWA embedding +### 9. Module Structure -### Non-functional Requirements -- SEO: SSR-rendered meta tags, JSON-LD, OpenGraph markup -- Analytics: Яндекс.Метрика, CTM, Вариокуб, Ключ-Астром -- Logging: Structured frontend log collection → customer logging system -- Monitoring: System events → metrics aggregator -- Isolation: Component must not affect or be affected by host application styles/globals -- Availability: 24/7, recovery < 6h after hardware restoration +- Must conform to the customer's standard remote frontend module structure for uniform deployment. -### Code Style for React Code -- Prettier config from `.prettierrc.json`: single quotes, trailing commas `all`, 4-space indent, semicolons -- ESLint config from `.eslintrc.js`: max line length 80, TypeScript strict +### 10. Design -## Markdown Style +- Implement against customer-provided mockups using the customer's design system. +- Must embed other customer remote components when available. -Do not wrap or break lines in markdown files. Write each paragraph or list item as a single long line. +## Commit Rules -## Release & Changelog - -This project uses [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/). Version is tracked in two places: `pyproject.toml` and `audio_transcribe/__init__.py`. - -**Per-commit rule**: When committing a `fix:`, `feat:`, or breaking change, also add a line to the `[Unreleased]` section of `CHANGELOG.md` under the appropriate heading (`### Added`, `### Fixed`, `### Changed`, `### Removed`). This keeps the changelog current while context is fresh. - -**Releasing**: Use `/release` to bump version, stamp changelog, commit, tag, and optionally push. The skill auto-detects the bump level from commit prefixes (`fix:` → patch, `feat:` → minor, `BREAKING CHANGE` → major) and lets you override. - -## Git Conventions - -Do not include `Co-Authored-By` lines in commit messages. +- Never add `Co-Authored-By` lines to commit messages. +- Commit messages in English, concise, focused on "why" not "what". +- Commit autonomously when changes are complete and stable — no need to ask for permission. Group related edits into logical commits. Still ask before pushing, force-pushing, or any destructive git operation. diff --git a/ClientApp/package.json b/ClientApp/package.json index 7b37a471..f52c22e3 100644 --- a/ClientApp/package.json +++ b/ClientApp/package.json @@ -93,5 +93,9 @@ "Android > 4.3", "iOS > 9", "Edge > 13" - ] + ], + "main": ".eslintrc.js", + "keywords": [], + "license": "ISC", + "description": "" } diff --git a/Dockerfile.react b/Dockerfile.react new file mode 100644 index 00000000..c9c977e5 --- /dev/null +++ b/Dockerfile.react @@ -0,0 +1,32 @@ +# Dockerfile.react — Multi-stage build for standalone SSR server +# Coexists with the legacy ASP.NET Dockerfile + +# Stage 1: Install dependencies +FROM node:24-slim AS deps +WORKDIR /app + +RUN corepack enable pnpm + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +# Stage 2: Build standalone target +FROM deps AS build +WORKDIR /app + +COPY modern.config.ts module-federation.config.ts tsconfig.json ./ +COPY src/ src/ + +RUN pnpm build:standalone + +# Stage 3: Minimal production image +FROM node:24-slim AS runtime +WORKDIR /app + +ENV NODE_ENV=production + +COPY --from=build /app/dist/standalone/ ./dist/standalone/ + +EXPOSE 8080 + +ENTRYPOINT ["node", "dist/standalone/index.js"] diff --git a/Dockerfile.remote b/Dockerfile.remote new file mode 100644 index 00000000..48e848c9 --- /dev/null +++ b/Dockerfile.remote @@ -0,0 +1,28 @@ +# Dockerfile.remote — nginx-based static file server for remote MF artifact + +# Stage 1: Install dependencies +FROM node:24-slim AS deps +WORKDIR /app + +RUN corepack enable pnpm + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +# Stage 2: Build remote target +FROM deps AS build +WORKDIR /app + +COPY modern.config.ts module-federation.config.ts tsconfig.json ./ +COPY src/ src/ + +RUN pnpm build:remote + +# Stage 3: Serve static files with nginx +FROM nginx:alpine AS runtime + +COPY --from=build /app/dist/remote/ /usr/share/nginx/html/ + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docs/superpowers/phase-1/frozen-barrels.md b/docs/superpowers/phase-1/frozen-barrels.md new file mode 100644 index 00000000..7a3f17f6 --- /dev/null +++ b/docs/superpowers/phase-1/frozen-barrels.md @@ -0,0 +1,17 @@ +# Frozen public barrels + +**Rule.** Cross-module imports inside `src/` go through exactly these five public entries: + +- `@/features/online-board` +- `@/features/schedule` +- `@/features/flights-map` +- `@/features/popular-requests` +- `@/ui` + +No file outside `src/features//` may import from `src/features//components/...` or any deeper path. No file outside `src/ui/` may import `src/ui/primitives/Button`. Enforcement lands in sub-plan **1A-3** via `eslint-plugin-boundaries` + `no-restricted-imports`. + +**Why this is frozen.** Phase 0 assumption **A1** (customer's standard remote-frontend module template) may arrive after 1A-1 ships. When it does, the rename pass documented in `rename-pass-plan.md` must be a mechanical move: rename directories, update import paths at the five barrels, done. If cross-module imports fan out through deep paths, the rename becomes a surgery across dozens of files. + +**What this unblocks.** Every Phase 1 sub-plan can add *internal* files to a feature/UI directory without coordinating with other sub-plans, because nothing outside the barrel depends on internals. The barrel file itself is the review gate. + +**What this costs.** Small friction when a sub-plan wants to expose a new symbol — it must update the barrel. Acceptable cost for the refactor safety it buys. diff --git a/docs/superpowers/phase-1/modernjs-mf-spike.md b/docs/superpowers/phase-1/modernjs-mf-spike.md new file mode 100644 index 00000000..ccaeeea8 --- /dev/null +++ b/docs/superpowers/phase-1/modernjs-mf-spike.md @@ -0,0 +1,169 @@ +# Modern.js + MF 2.0 spike report + +**Date:** 2026-04-14 +**Goal:** Validate that Modern.js 2.x + `@module-federation/modern-js` can produce both standalone SSR and CDN-static remote artifacts from a single source tree, and pin versions for 1A-2. + +## Pinned version matrix + +| Package | Version | Source | +|---|---|---| +| `@modern-js/app-tools` | `2.70.8` | pnpm-lock.yaml of scratch project | +| `@modern-js/runtime` | `2.70.8` | pnpm-lock.yaml of scratch project | +| `@modern-js/plugin-ssr` | N/A (SSR is built into `@modern-js/app-tools` in v2.x) | verified: no separate package exists in the dep tree | +| `@module-federation/modern-js` | `2.3.2` | pnpm-lock.yaml of scratch project | +| `@module-federation/enhanced` | `2.3.2` | pnpm-lock.yaml of scratch project | +| `react` | `18.3.1` | resolved from `^18.2.0` | +| `react-dom` | `18.3.1` | resolved from `^18.2.0` | +| `@types/react` | `18.3.28` | resolved | +| `@types/react-dom` | `18.3.7` | resolved | +| `typescript` | `5.5.4` | resolved from `~5.5.0` | +| `@rspack/core` (via Modern.js) | `1.7.11` | resolved transitively | +| `@rsbuild/core` (via Modern.js) | `1.7.3` | resolved transitively | + +## Dual-build feasibility + +- [x] Single `modern.config.ts` with `BUILD_TARGET` branching produces both targets -- **YES** +- [x] `dist/standalone/` contains Node SSR server -- **YES** (contains `bundles/` directory with `main.js`, `main-server-loaders.js`, and SSR-rendered chunk bundles) +- [x] `dist/remote/mf-manifest.json` contains all exposed modules -- **YES** (contains `./Hello` expose with asset paths) + +All three criteria pass. The `BUILD_TARGET` env var read at config time cleanly switches between SSR and CSR-only modes. + +### How it works + +`modern.config.ts` reads `process.env.BUILD_TARGET` and branches: + +```typescript +const isRemote = process.env.BUILD_TARGET === 'remote'; + +export default defineConfig({ + plugins: [appTools({ bundler: 'rspack' }), moduleFederationPlugin()], + server: isRemote ? {} : { ssr: { mode: 'stream' } }, + output: { + distPath: { root: isRemote ? 'dist/remote' : 'dist/standalone' }, + }, +}); +``` + +- `BUILD_TARGET=standalone` (or unset): SSR enabled via `server.ssr.mode: 'stream'`, outputs to `dist/standalone/`. Produces both client-side JS + Node SSR bundles in `bundles/`. +- `BUILD_TARGET=remote`: SSR disabled (empty `server` config), outputs to `dist/remote/`. Produces only client-side JS + MF artifacts. No `bundles/` directory. + +Both targets get the MF plugin, so `mf-manifest.json` + `remoteEntry.js` + `@mf-types.zip` are emitted in both builds. + +## Emitted mf-manifest.json shape + +```json +{ + "id": "spike", + "name": "spike", + "metaData": { + "name": "spike", + "type": "app", + "buildInfo": { + "buildVersion": "1.0.0", + "buildName": "modernjs-mf-spike" + }, + "remoteEntry": { + "name": "remoteEntry.js", + "path": "", + "type": "global" + }, + "types": { + "path": "", + "name": "", + "zip": "@mf-types.zip", + "api": "@mf-types.d.ts" + }, + "globalName": "spike", + "pluginVersion": "2.3.2", + "prefetchInterface": false, + "publicPath": "/" + }, + "shared": [ + { + "id": "spike:react-dom", + "name": "react-dom", + "version": "18.3.1", + "singleton": true, + "requiredVersion": "^18.2.0", + "assets": { + "js": { + "async": [], + "sync": ["static/js/async/396.e11e8a41.js"] + }, + "css": { "async": [], "sync": [] } + }, + "fallback": "" + }, + { + "id": "spike:react", + "name": "react", + "version": "18.3.1", + "singleton": true, + "requiredVersion": "^18.2.0", + "assets": { + "js": { + "async": [], + "sync": ["static/js/async/75.30dce1d1.js"] + }, + "css": { "async": [], "sync": [] } + }, + "fallback": "" + } + ], + "remotes": [], + "exposes": [ + { + "id": "spike:Hello", + "name": "Hello", + "assets": { + "js": { + "sync": ["static/js/async/__federation_expose_Hello.24756ba8.js"], + "async": [] + }, + "css": { "sync": [], "async": [] } + }, + "path": "./Hello" + } + ] +} +``` + +Key observations about the manifest shape: +- Top-level `id` and `name` match the `name` field in `module-federation.config.ts`. +- `metaData.remoteEntry.name` is the `filename` field from config. +- `metaData.pluginVersion` reflects `@module-federation/modern-js` version. +- `metaData.publicPath` defaults to `"/"` -- must be overridden for CDN deployment. +- `exposes[].path` uses the `./Hello` convention from config. +- `shared[]` entries include resolved `version` and `requiredVersion`. +- `@mf-types.zip` is auto-generated for TypeScript type sharing between remotes. + +## Gotchas discovered + +1. **`@module-federation/modern-js` is the correct package name, not `-v3`.** Both `@module-federation/modern-js` and `@module-federation/modern-js-v3` exist on npm at the same version (2.3.2), but the non-suffixed name is the primary package with the correct exports. The `-v3` variant appears to be an alias. Use `@module-federation/modern-js`. + +2. **Modern.js 3.x (v3.1.3) is incompatible with `@module-federation/modern-js` v2.3.2.** Modern.js 3.x uses Rspack 2.0.0-rc.0, which has breaking API changes (`api.modifyWebpackConfig` removed, Rspack hook shape changes). The MF plugin crashes with `TypeError: api.modifyWebpackConfig is not a function` (SSR plugin) and `TypeError: Cannot read properties of undefined (reading 'tap')` (EmbedFederationRuntimePlugin). Use Modern.js 2.x (v2.70.8) which ships Rspack 1.7.11. + +3. **`routes/layout.tsx` is mandatory.** Modern.js v2 requires a root layout component at `src/routes/layout.tsx`. Without it, the build fails with `Error: The root layout component is required`. The generated scaffold from `@modern-js/create` includes this automatically, but manual project setup must add it. + +4. **SSR stream mode logs a misleading warning.** When SSR is enabled with `mode: 'stream'`, the build logs `splitChunks.chunks = async is not allowed with stream SSR mode, it will auto changed to "async"`. This is a no-op warning (it changes `async` to `async`, which is the same value) -- safe to ignore. + +5. **`@modern-js/plugin-ssr` does not exist as a separate package.** In Modern.js 2.x, SSR support is built into `@modern-js/app-tools`. The design spec references `@modern-js/plugin-ssr` but this is not needed -- just set `server.ssr.mode` in `modern.config.ts`. + +6. **`appTools({ bundler: 'rspack' })` is required.** Without the explicit `bundler: 'rspack'` option, Modern.js 2.x defaults to webpack, which would conflict with the MF plugin's Rspack-specific code paths. + +7. **`publicPath` in the manifest defaults to `"/"`**. For CDN deployment, `output.assetPrefix` must be set in `modern.config.ts` to the CDN URL (e.g., `https://cdn.example.com/flights/`). This will propagate into `metaData.publicPath` in `mf-manifest.json`. + +8. **Type generation (`@mf-types.zip`) happens automatically.** The MF plugin generates a type bundle at `dist/@mf-types.zip` containing TypeScript declarations for all exposed modules. This enables type-safe consumption in host apps without manual type sharing. + +## Known incompatibilities + +- `@module-federation/modern-js` v2.3.2 has a peer dependency on `react@"^16.3.0 || ^17.0.0 || ^18.0.0"`, which means React 19 is not officially supported. React 18.3.1 resolves cleanly. If the project later needs React 19, the MF plugin version must be updated or the peer dep overridden. +- Modern.js 3.x (v3.1.3, released with `@modern-js/create@3.1.3`) is not compatible with `@module-federation/modern-js` v2.3.2. The MF plugin team will need to release a v3-compatible version. For now, pin to Modern.js 2.70.8. + +## Decision + +**GO.** The dual-build approach works cleanly with Modern.js 2.70.8 + `@module-federation/modern-js` 2.3.2. A single `modern.config.ts` with `BUILD_TARGET` env var branching produces: +- `dist/standalone/`: Node SSR server + client bundle (stream mode) +- `dist/remote/`: CDN-static MF 2.0 remote with `mf-manifest.json`, `remoteEntry.js`, typed exports + +No workarounds or hacks required. The version matrix is stable and the manifest shape is well-defined. Proceed with Tasks 2-10 of Phase 1A-2 using these pinned versions. diff --git a/docs/superpowers/phase-1/rename-pass-plan.md b/docs/superpowers/phase-1/rename-pass-plan.md new file mode 100644 index 00000000..643137be --- /dev/null +++ b/docs/superpowers/phase-1/rename-pass-plan.md @@ -0,0 +1,23 @@ +# A1 rename-pass rework plan + +**Trigger.** Phase 0 assumption **A1** — "customer's standard remote-frontend module template" — resolves to a directory layout that differs from the one this repo uses. + +**Scope.** Move/rename directories inside `src/` to match the customer template. Update import paths *only at the five frozen public barrels* (see `frozen-barrels.md`). Do not restructure feature internals. + +**Preconditions.** +- The frozen-barrel rule has been enforced since 1A-1 (Task 11) and 1A-3 ESLint rules are passing on `main`. +- Customer template document is in hand and reviewed for explicit directory conventions. + +**Steps (to be fleshed out when A1 resolves).** +1. Create a target-layout scratch file mapping current path → new path for every file under `src/`. +2. Run the rename as a single automated pass (`git mv`) inside an isolated worktree. +3. Update `tsconfig.json` `paths` aliases if the top-level segments change. +4. Update `vitest.config.ts` aliases to match. +5. Update the five barrel files — this is the *only* hand-edit needed for consumer code. +6. Run `pnpm typecheck && pnpm lint && pnpm test` — green before commit. +7. Run all Phase 1 exit-gate checks (from master plan) — green before PR. +8. Single commit: `Rename src/ layout to match customer module template (A1)`. + +**Escape valve.** If the rename touches more than the five barrels, something violated the frozen-barrel rule between 1A-1 and now. Fix the violation first (move the cross-boundary import through a barrel), then retry the rename. + +**Owner.** This task is attached to 1A-1's exit gate and fires on A1 resolution, whether that happens during Phase 1 or early Phase 2. diff --git a/docs/superpowers/phase-1/runbook.md b/docs/superpowers/phase-1/runbook.md new file mode 100644 index 00000000..6b470330 --- /dev/null +++ b/docs/superpowers/phase-1/runbook.md @@ -0,0 +1,340 @@ +# Flights Web — Operational Runbook + +**Version:** 1.0 (Phase 1I) +**Last updated:** 2026-04-14 + +--- + +## 1. Incident Response Decision Tree + +``` +Is the service returning errors? + | + +-- YES: Check /health endpoint + | | + | +-- /health returns 503 + | | -> Upstream API issue (see Section 6.1) + | | + | +-- /health returns 200 but users see errors + | | -> Application-level bug. Check logs (Section 5). + | | -> If recent deploy: rollback (Section 3). + | | + | +-- /health unreachable (connection refused / timeout) + | -> Container/VM is down. + | -> Check container orchestrator status. + | -> If all replicas down: escalate to infra team (Severity 1). + | -> If partial: rely on load balancer, investigate affected nodes. + | + +-- NO: Check for degraded performance + | + +-- Latency > 2x baseline + | -> Check OTel metrics for slow spans. + | -> Check upstream API latency. + | -> If upstream: see Section 6.1. + | -> If internal: check for memory pressure, CPU saturation. + | + +-- Intermittent errors in logs + -> Check error rate trend. + -> If rising: prepare for rollback. + -> If stable/low: monitor for 15 min, then investigate. +``` + +### Severity Levels + +| Severity | Criteria | Response Time | Who to Page | +|----------|----------|---------------|-------------| +| S1 | Service fully down, all users affected | Immediate | On-call engineer + team lead | +| S2 | Partial outage, >10% error rate | 15 min | On-call engineer | +| S3 | Degraded performance, no data loss | 1 hour | On-call engineer (next business day if after hours) | +| S4 | Minor issue, workaround exists | Next business day | Assigned engineer | + +--- + +## 2. Canary Rollout Procedure + +### Pre-rollout Checklist + +- [ ] All CI checks pass (typecheck, lint, test) +- [ ] Docker images built and pushed to registry +- [ ] Rollback image tag identified (current production tag) +- [ ] Monitoring dashboards open + +### Rollout Steps + +1. **Deploy canary** (5% traffic) to a single node in one geographic region +2. **Monitor for 10 minutes:** + - Error rate must stay below 0.5% + - p99 latency must not exceed 2x baseline + - `/health` must return 200 on the canary + - No new error patterns in logs +3. **Expand to 25%** if canary is healthy +4. **Monitor for 15 minutes** with same criteria +5. **Expand to 100%** across all geographic regions +6. **Post-deploy verification:** + - `/health` returns 200 on all nodes + - Smoke test passes end-to-end + - No error rate spike in the first 30 minutes + +### Abort Criteria + +Roll back immediately if any of these occur during canary: +- Error rate exceeds 1% +- `/health` returns 503 on canary nodes +- p99 latency exceeds 5x baseline +- Any S1/S2 incident triggered + +--- + +## 3. Rollback Procedure + +### 3.1 Automatic Rollback + +The deploy pipeline monitors `/health` after deployment. If the health check fails within the first 5 minutes post-deploy: + +1. Pipeline automatically reverts to the previous image tag +2. Alert fires to the on-call channel +3. Engineer investigates the failed deployment logs + +**No manual action required** for auto-rollback. Verify the rollback succeeded by checking: +- `/health` returns 200 +- Error rate returns to baseline +- Previous image tag is running on all nodes + +### 3.2 Manual Rollback + +If auto-rollback did not trigger or a problem is discovered later: + +1. **Identify the last-known-good image tag** from the deployment history +2. **Redeploy the previous image:** + ```bash + # Placeholder — actual commands depend on customer's deployment tool + # Example: + # deploy --image $REGISTRY/flights-web-standalone:$PREVIOUS_SHA --env production + ``` +3. **Verify rollback:** + - `/health` returns 200 on all nodes + - Error rate returns to baseline + - Smoke test passes +4. **Post-mortem:** file an incident report within 24 hours + +--- + +## 4. Health-Check Interpretation + +### Endpoint: `GET /health` + +| Response | Status | Meaning | Action | +|----------|--------|---------|--------| +| `{ "status": "ok" }` | 200 | Upstream API reachable within last 60s | None | +| `{ "status": "degraded", "reason": "upstream_unreachable" }` | 503 | No successful upstream ping in 60s | Check upstream API status; see Section 6.1 | + +### Common Causes of 503 + +1. **Upstream API is down** — check upstream service status page / monitoring +2. **Network partition** — the node cannot reach the upstream API; check network policies +3. **DNS resolution failure** — verify DNS configuration on the node +4. **Upstream API overloaded** — ping times out; coordinate with upstream team + +### Load Balancer Behavior + +When `/health` returns 503, the load balancer should stop routing traffic to that node. When the upstream recovers and `/health` returns 200 again, traffic automatically resumes. + +--- + +## 5. Log Query Cookbook + +Logs are shipped in JSON Lines format to the customer's log aggregation system. + +### Log Structure + +```json +{ + "ts": "2026-04-14T12:00:00.000Z", + "level": "error", + "msg": "Request failed", + "fields": { + "traceId": "abc123", + "path": "/api/flights", + "status": 500, + "err": "TypeError: Cannot read properties of undefined" + } +} +``` + +### Common Queries + +**Find all errors in the last hour:** +``` +level:error AND ts:[now-1h TO now] +``` + +**Find errors for a specific trace:** +``` +fields.traceId:"abc123" +``` + +**Find slow requests (logged by the API client on timeout):** +``` +msg:"Retrying request" OR msg:"upstream_timeout" +``` + +**Find health-check failures:** +``` +msg:"upstream_unreachable" OR (path:"/health" AND status:503) +``` + +**Find graceful shutdown events:** +``` +msg:"SIGTERM received" OR msg:"Server closed" OR msg:"Drain timeout exceeded" +``` + +**Find CSP violations (if CSP reporting is enabled):** +``` +msg:"csp-violation" OR fields.type:"csp-report" +``` + +--- + +## 6. Known-Failure Playbooks + +### 6.1 Upstream API Down + +**Symptoms:** `/health` returns 503; API client logs show retry exhaustion. + +**Impact:** Users see error pages or stale data (if caching is in place). + +**Steps:** +1. Confirm upstream status via the upstream team's status page or monitoring +2. If upstream is aware and working on it: monitor, no action needed on our side +3. If upstream is unaware: escalate via agreed communication channel +4. If outage exceeds 30 minutes: consider enabling a maintenance page +5. Recovery is automatic — once upstream responds, `/health` returns 200 within 60s + +### 6.2 SignalR Hub Offline + +**Symptoms:** Real-time flight updates stop; SignalR reconnection logs appear. + +**Impact:** Users see stale board data; manual refresh still works via REST API. + +**Steps:** +1. Check SignalR hub process/container status +2. Verify WebSocket connectivity from the node to the SignalR hub +3. The client auto-reconnects with exponential backoff — recovery is usually automatic +4. If hub is permanently down: REST polling fallback should activate (if implemented) +5. Inform users if downtime exceeds 5 minutes + +### 6.3 CSP Violation Spike + +**Symptoms:** Spike in CSP violation reports; possibly broken page functionality. + +**Impact:** Scripts or styles blocked by Content-Security-Policy; UI may be partially broken. + +**Steps:** +1. Check CSP violation reports for the blocked resource URL +2. If a legitimate resource is blocked: update CSP policy in `src/server/middleware/csp.ts` +3. If a third-party script is the source: investigate whether it was injected (security concern) +4. If after a deploy: the new code may reference resources not in the CSP allowlist — fix or rollback +5. CSP is in report-only mode during Phase 1 — no user impact, but violations should be tracked + +### 6.4 Analytics Adapter Load Failure + +**Symptoms:** `flights.analytics.load_failed` counter increases; analytics data gaps. + +**Impact:** Analytics data not collected; no user-facing impact. + +**Steps:** +1. Check which adapter(s) failed (Yandex.Metrica, CTM, Variocube, Dynatrace) +2. Verify the adapter's external script URL is reachable from the client +3. Check for CORS or CSP blocking the adapter script +4. If a single adapter: low priority, monitor +5. If all adapters: likely a CSP or network issue affecting all external scripts + +### 6.5 OTel Exporter Unreachable + +**Symptoms:** Metrics and traces stop appearing in the monitoring dashboard. + +**Impact:** No observability data; no user-facing impact. + +**Steps:** +1. Check the OTel collector/exporter endpoint connectivity +2. Verify the `OTEL_EXPORTER_OTLP_ENDPOINT` environment variable is correct +3. Check for network policy changes that may block the exporter +4. The SDK buffers data locally — some data may be recoverable once the exporter is reachable again +5. If the exporter is permanently moved: update the endpoint configuration and redeploy + +### 6.6 Memory Pressure / OOM Kill + +**Symptoms:** Container restarts; OOM kill events in container orchestrator logs. + +**Impact:** Requests in flight are dropped; load balancer reroutes to healthy nodes. + +**Steps:** +1. Check container memory limits vs actual usage +2. Review recent deploys for memory leaks (new dependencies, unbounded caches) +3. If a specific route causes high memory: check for large API responses or unbounded data structures +4. Short-term: increase memory limits +5. Long-term: profile the application to find the leak; fix and redeploy + +--- + +## Recovery SLA + +**Target:** Service recovery within 6 hours after infrastructure is restored. + +**Recovery steps:** +1. Infrastructure team restores VMs / containers across geographic regions +2. Deployment tool re-deploys the last-known-good image +3. `/health` checks confirm upstream connectivity +4. Load balancer re-enables traffic to recovered nodes +5. On-call engineer verifies end-to-end functionality +6. Incident report filed within 24 hours of resolution + +--- + +## 7. Phase 6 Cutover Reference + +This section cross-references the full cutover runbook at `docs/superpowers/plans/2026-04-15-phase-6-cutover.md`. + +### 7.1 Traffic Ramp Quick Reference + +During cutover, traffic shifts from Angular to React over 72 hours: +- **T+0h:** 5% React / 95% Angular +- **T+12h:** 25% React / 75% Angular +- **T+24h:** 50% React / 50% Angular +- **T+48h:** 100% React / 0% Angular + +Each step requires explicit go/no-go from the on-call engineer. Full details in the cutover runbook Section 3. + +### 7.2 Cutover Rollback + +During or after the traffic ramp, if a rollback is needed: + +1. Flip proxy weights back to Angular (< 1 minute) +2. Verify Angular is serving traffic via response headers +3. Confirm error rate and latency return to baseline +4. File post-mortem within 24 hours + +**Trigger criteria:** error rate > 1% for 5+ minutes, p95 > 2x baseline for 10+ minutes, or > 50% of React nodes returning 503. + +Full rollback procedure in the cutover runbook Section 5. + +### 7.3 Post-Cutover Soak + +After reaching 100% React traffic, a 7-day soak period is required before Angular decommission. Soak pass criteria: +- Zero Angular hits in access logs +- Error rate < 0.1% +- p95 < 500ms +- Core Web Vitals in "Good" threshold +- No Search Console regressions + +Full soak criteria in the cutover runbook Section 6. + +### 7.4 Angular Decommission + +After soak sign-off: +1. Tag the Angular codebase: `git tag -a angular-final` +2. Create archive branch: `archive/angular-spa` +3. Remove Angular/ASP.NET files (requires customer approval) +4. Infrastructure cleanup + +Full decommission steps in the cutover runbook Section 7. diff --git a/docs/superpowers/plans/2026-04-14-phase-0-preflight.md b/docs/superpowers/plans/2026-04-14-phase-0-preflight.md new file mode 100644 index 00000000..d5ad1217 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-0-preflight.md @@ -0,0 +1,2246 @@ +# Phase 0 — Preflight Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Capture everything needed from the current Angular prod app (URL corpus, SEO baselines, VRT baselines, component inventories, translation keys) and confirm customer-side spec assumptions, so Phase 1 foundation work can proceed without blockers. + +**Architecture:** Discovery-only phase. No production change. Produces committed fixtures under `tests/fixtures/phase-0/` and inventory documents under `docs/superpowers/phase-0/` that Phase 1 and later phases depend on. Introduces a minimal top-level `package.json` + `scripts/phase-0/` at the repo root; no framework or runtime is installed beyond what the capture scripts need. + +**Tech Stack:** Node 20, pnpm, TypeScript + `tsx`, Playwright, `cheerio`, `zod`, `schema-dts`, Vitest (for the few pure-function tests in this phase). + +**Reference spec:** `docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md` — Phase 0 is described in §9.2 of that document. + +--- + +## Prerequisites — block Phase 0 start until resolved + +These are not tasks; they are gates. Confirm each before running Task 1. + +- [ ] **Production URL access.** The capture scripts fetch pages from the live Angular app. Get the production URL (likely `https://flights.aeroflot.ru` or equivalent). If prod is rate-limited, get an unthrottled staging mirror URL that is byte-equivalent to prod. +- [ ] **Access-log availability decision.** The URL-corpus task is ideally driven by anonymized prod access logs. If those are unavailable, fall back to enumerating Angular route definitions directly (Task 4 supports both modes). +- [ ] **Customer point-of-contact.** Identify who will answer the spec assumptions A1–A5 listed in `docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md` §10. Task 16 produces the questionnaire; a human must deliver it. + +--- + +## File structure + +Files created by Phase 0: + +**Repo root — minimal project skeleton (extended in Phase 1):** +``` +package.json # minimal — only scripts/phase-0 deps +pnpm-lock.yaml # frozen +pnpm-workspace.yaml # excludes ClientApp/ from workspace +.nvmrc # 20.11.0 +tsconfig.json # root TS config for scripts +.gitignore # ignores dist/, node_modules/, playwright-report/, tests/fixtures/phase-0/vrt-baselines/raw/ +playwright.phase0.config.ts # Phase-0-only Playwright config +``` + +**Phase 0 scripts:** +``` +scripts/phase-0/lib/angular-routes.ts # list of Angular route shapes (source of truth for URL enumeration) +scripts/phase-0/lib/http.ts # small fetch wrapper with retry + throttle +scripts/phase-0/lib/io.ts # read/write JSON + PNG helpers +scripts/phase-0/anonymize-access-logs.ts # optional: raw log → anonymized URL list +scripts/phase-0/extract-url-corpus.ts # writes url-corpus/*.json from routes + optional logs +scripts/phase-0/capture-seo-baselines.ts # fetches pages, extracts + JSON-LD +scripts/phase-0/capture-hreflang-parity.ts # verifies reciprocal hreflang on every language variant +scripts/phase-0/capture-vrt-baselines.ts # Playwright driver for VRT baseline capture +scripts/phase-0/inventory-primeng.ts # scans ClientApp/ for PrimeNG usages +scripts/phase-0/inventory-scss-tokens.ts # extracts SCSS variable + theme override list +scripts/phase-0/inventory-translation-keys.ts # finds @ngx-translate keys actually referenced in templates/TS +``` + +**Committed fixtures:** +``` +tests/fixtures/phase-0/url-corpus/onlineboard.json # enumerated + observed URLs +tests/fixtures/phase-0/url-corpus/schedule.json +tests/fixtures/phase-0/url-corpus/flights-map.json +tests/fixtures/phase-0/url-corpus/popular.json +tests/fixtures/phase-0/seo-baselines/.json # ~20 files +tests/fixtures/phase-0/hreflang-parity/.json +tests/fixtures/phase-0/vrt-baselines/--.png # ~60 files +``` + +**Committed documents:** +``` +docs/superpowers/phase-0/README.md # index of Phase 0 deliverables +docs/superpowers/phase-0/primeng-backlog.md # PrimeNG component inventory +docs/superpowers/phase-0/scss-theme-manifest.md # SCSS token/theme port list +docs/superpowers/phase-0/translation-keys-used.md # which keys are live +docs/superpowers/phase-0/customer-confirmation-checklist.md # A1–A5 + answers +``` + +--- + +## Task 1: Attach spec commit to a working branch + +The spec commit `75dbec0` currently sits on detached HEAD. Before any other work, move it to a branch. + +**Files:** +- None (git operations only) + +- [ ] **Step 1: Create working branch from current HEAD** + +```bash +git checkout -b feat/react-migration-phase0 +``` + +Expected: `Switched to a new branch 'feat/react-migration-phase0'` + +- [ ] **Step 2: Verify the spec commit is reachable from the new branch** + +```bash +git log --oneline -3 +``` + +Expected first line: `75dbec0 Add design spec for Angular-to-React MF remote rewrite` + +- [ ] **Step 3: Push the branch to origin** + +```bash +git push -u origin feat/react-migration-phase0 +``` + +Expected: new remote branch created; tracking set. + +--- + +## Task 2: Root project scaffolding (package.json + TS config + gitignore) + +Create the minimal project skeleton at the repo root. This is additive — the existing `ClientApp/` Angular app is untouched and explicitly excluded from the workspace. + +**Files:** +- Create: `package.json` +- Create: `pnpm-workspace.yaml` +- Create: `.nvmrc` +- Create: `tsconfig.json` +- Modify: `.gitignore` (create if missing) + +- [ ] **Step 1: Pin Node version** + +Create `.nvmrc` at repo root with exactly: +``` +20.11.0 +``` + +- [ ] **Step 2: Create minimal root `package.json`** + +Create `package.json` at repo root: + +```json +{ + "name": "aeroflot-flights-web", + "private": true, + "version": "0.0.0", + "packageManager": "pnpm@9.12.0", + "engines": { + "node": ">=20.11.0" + }, + "scripts": { + "phase0:url-corpus": "tsx scripts/phase-0/extract-url-corpus.ts", + "phase0:seo": "tsx scripts/phase-0/capture-seo-baselines.ts", + "phase0:hreflang": "tsx scripts/phase-0/capture-hreflang-parity.ts", + "phase0:vrt": "playwright test --config=playwright.phase0.config.ts", + "phase0:inventory:primeng": "tsx scripts/phase-0/inventory-primeng.ts", + "phase0:inventory:scss": "tsx scripts/phase-0/inventory-scss-tokens.ts", + "phase0:inventory:i18n": "tsx scripts/phase-0/inventory-translation-keys.ts", + "test": "vitest run" + }, + "devDependencies": {} +} +``` + +- [ ] **Step 3: Exclude `ClientApp/` from the workspace** + +Create `pnpm-workspace.yaml`: + +```yaml +packages: + - '.' + - '!ClientApp' +``` + +- [ ] **Step 4: Create root `tsconfig.json`** + +Create `tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "types": ["node"], + "baseUrl": ".", + "paths": { + "@phase0/*": ["scripts/phase-0/*"] + } + }, + "include": ["scripts/**/*.ts", "tests/**/*.ts"], + "exclude": ["ClientApp", "node_modules", "dist"] +} +``` + +- [ ] **Step 5: Update `.gitignore`** + +Add these lines to `.gitignore` (create the file if it doesn't exist at the repo root): + +``` +# Phase 0 + later +node_modules/ +dist/ +.DS_Store +playwright-report/ +test-results/ +.playwright/ + +# Ephemeral capture artifacts (final baselines are committed, raw intermediate files are not) +tests/fixtures/phase-0/vrt-baselines/raw/ +``` + +- [ ] **Step 6: Commit scaffolding** + +```bash +git add package.json pnpm-workspace.yaml .nvmrc tsconfig.json .gitignore +git commit -m "Add root project scaffolding for React rewrite + +Phase 0 introduces a minimal top-level package.json + TS config at the +repo root. ClientApp/ is excluded from the workspace so the Angular app +keeps its own dependency tree during the strangler-fig migration." +``` + +--- + +## Task 3: Install Phase 0 toolchain + +Install the minimal dependency set needed for Phase 0 capture scripts. Do NOT install Modern.js, React, or anything else — that happens in Phase 1A. + +**Files:** +- Modify: `package.json` +- Create: `pnpm-lock.yaml` + +- [ ] **Step 1: Install dev dependencies** + +```bash +pnpm add -D \ + typescript@^5.5.0 \ + tsx@^4.19.0 \ + @types/node@^20.11.0 \ + playwright@^1.47.0 \ + @playwright/test@^1.47.0 \ + cheerio@^1.0.0 \ + zod@^3.23.0 \ + schema-dts@^1.1.2 \ + vitest@^2.1.0 +``` + +Expected: `pnpm-lock.yaml` created; `node_modules/` populated; no warnings about peer dependency conflicts. + +- [ ] **Step 2: Install Playwright browsers** + +```bash +pnpm exec playwright install chromium +``` + +Expected: Chromium downloaded to Playwright's cache. + +- [ ] **Step 3: Verify `tsx` runs a trivial script** + +Create a throwaway sanity check — do not commit: + +```bash +echo 'console.log("tsx works", process.version)' > /tmp/sanity.ts +pnpm exec tsx /tmp/sanity.ts +rm /tmp/sanity.ts +``` + +Expected output: `tsx works v20.x.x` + +- [ ] **Step 4: Commit lockfile + dep additions** + +```bash +git add package.json pnpm-lock.yaml +git commit -m "Install Phase 0 capture toolchain + +Minimal dependency set for Phase 0 scripts: Playwright (VRT + SEO +capture), cheerio (HTML parsing), zod (fixture schema validation), +schema-dts (JSON-LD types), tsx + typescript. No framework yet." +``` + +--- + +## Task 4: URL corpus — enumerate Angular route shapes + +The URL corpus is the source of truth for every URL the Angular app currently serves. It drives SEO capture, VRT capture, and the Phase 2 URL parity tests. Two sources: (a) the static route definitions in `ClientApp/src/`, which gives us every *shape* of URL; (b) optionally, anonymized access logs, which give us *real observed values* for the dynamic segments. + +This task covers (a). Task 5 covers (b). + +**Files:** +- Create: `scripts/phase-0/lib/angular-routes.ts` +- Create: `scripts/phase-0/lib/io.ts` +- Create: `tests/phase-0/lib/angular-routes.test.ts` + +- [ ] **Step 1: Read the Angular route definitions to build the list** + +Before writing code, read these files in `ClientApp/src/app/` to confirm the route shapes: + +```bash +cat ClientApp/src/app/app-routing.module.ts +``` + +```bash +find ClientApp/src/app/features -name '*-routing.module.ts' -exec echo '===' {} ';' -exec cat {} ';' +``` + +Record the route shapes and their param-parsing rules. You'll hand-transcribe them into the next step — the Angular files are the authoritative source. + +- [ ] **Step 2: Write the route-shape catalog** + +Create `scripts/phase-0/lib/angular-routes.ts`: + +```ts +/** + * Catalog of Angular route shapes, authored by hand from the current + * ClientApp/src/app routing modules. This is the source of truth for + * URL enumeration in Phase 0. + * + * Each entry is a template with :params placeholders. The sample values + * below are representative defaults used when access logs are unavailable; + * a real Phase 0 run should prefer observed values from access logs + * (see extract-url-corpus.ts). + */ + +export type RouteFeature = "onlineboard" | "schedule" | "flights-map" | "popular"; + +export interface RouteShape { + feature: RouteFeature; + /** Human-readable route slug used as a fixture filename. */ + slug: string; + /** Template with {placeholders}, no language prefix (added per language at enumeration time). */ + template: string; + /** Representative sample values used when no observed values are available. */ + samples: Array>; +} + +export const LANGUAGES = ["ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de"] as const; +export type Language = (typeof LANGUAGES)[number]; + +export const ROUTE_SHAPES: RouteShape[] = [ + { + feature: "onlineboard", + slug: "onlineboard-start", + template: "/onlineboard", + samples: [{}], + }, + { + feature: "onlineboard", + slug: "onlineboard-flight", + template: "/onlineboard/flight/{flightNumber}-{date}", + samples: [ + { flightNumber: "SU100", date: "2025-01-15" }, + { flightNumber: "SU0001", date: "2025-06-01" }, + ], + }, + { + feature: "onlineboard", + slug: "onlineboard-departure", + template: "/onlineboard/departure/{airport}-{date}", + samples: [ + { airport: "SVO", date: "2025-01-15" }, + { airport: "LED", date: "2025-06-01" }, + ], + }, + { + feature: "onlineboard", + slug: "onlineboard-arrival", + template: "/onlineboard/arrival/{airport}-{date}", + samples: [ + { airport: "JFK", date: "2025-01-15" }, + { airport: "DXB", date: "2025-06-01" }, + ], + }, + { + feature: "onlineboard", + slug: "onlineboard-route", + template: "/onlineboard/route/{departure}-{arrival}-{date}", + samples: [ + { departure: "SVO", arrival: "JFK", date: "2025-01-15" }, + { departure: "LED", arrival: "DXB", date: "2025-06-01" }, + ], + }, + { + feature: "onlineboard", + slug: "onlineboard-details", + template: "/onlineboard/{flightNumber}-{date}", + samples: [{ flightNumber: "SU100", date: "2025-01-15" }], + }, + { + feature: "schedule", + slug: "schedule-start", + template: "/schedule", + samples: [{}], + }, + { + feature: "schedule", + slug: "schedule-oneway", + template: "/schedule/route/{departure}-{arrival}-{date}", + samples: [{ departure: "SVO", arrival: "JFK", date: "2025-01-15" }], + }, + { + feature: "schedule", + slug: "schedule-roundtrip", + template: "/schedule/route/{departure}-{arrival}-{date}/{returnDeparture}-{returnArrival}-{returnDate}", + samples: [ + { + departure: "SVO", + arrival: "JFK", + date: "2025-01-15", + returnDeparture: "JFK", + returnArrival: "SVO", + returnDate: "2025-01-22", + }, + ], + }, + { + feature: "schedule", + slug: "schedule-multileg", + template: "/schedule/{legs}", + samples: [{ legs: "SU0001-2025-01-15/SU0002-2025-01-15" }], + }, + { + feature: "flights-map", + slug: "flights-map-start", + template: "/flights-map", + samples: [{}], + }, + { + feature: "flights-map", + slug: "flights-map-route", + template: "/flights-map/route/{departure}-{arrival}", + samples: [{ departure: "SVO", arrival: "JFK" }], + }, + { + feature: "popular", + slug: "popular-start", + template: "/popular", + samples: [{}], + }, +]; + +/** Substitute {placeholders} in a template with concrete values. */ +export function renderTemplate(template: string, values: Record): string { + return template.replace(/\{(\w+)\}/g, (_, key) => { + const value = values[key]; + if (value === undefined) { + throw new Error(`renderTemplate: missing value for '${key}' in template '${template}'`); + } + return value; + }); +} + +/** Prepend a language prefix and the canonical origin to a rendered route. */ +export function buildUrl(origin: string, lang: Language, path: string): string { + const normalized = path.startsWith("/") ? path : `/${path}`; + return `${origin}/${lang}${normalized}`; +} +``` + +- [ ] **Step 3: Write a unit test for `renderTemplate` + `buildUrl`** + +Create `tests/phase-0/lib/angular-routes.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { renderTemplate, buildUrl } from "@phase0/lib/angular-routes"; + +describe("renderTemplate", () => { + it("substitutes a single placeholder", () => { + expect(renderTemplate("/onlineboard/{flightNumber}-{date}", { flightNumber: "SU100", date: "2025-01-15" })) + .toBe("/onlineboard/SU100-2025-01-15"); + }); + + it("substitutes multiple placeholders of the same name (first occurrence rule is fine because there are no duplicates in our catalog)", () => { + expect(renderTemplate("{a}/{b}", { a: "x", b: "y" })).toBe("x/y"); + }); + + it("throws on a missing value", () => { + expect(() => renderTemplate("/{missing}", {})).toThrow(/missing value for 'missing'/); + }); + + it("leaves a literal path untouched", () => { + expect(renderTemplate("/onlineboard", {})).toBe("/onlineboard"); + }); +}); + +describe("buildUrl", () => { + it("joins origin + lang + path", () => { + expect(buildUrl("https://flights.aeroflot.ru", "ru", "/onlineboard")) + .toBe("https://flights.aeroflot.ru/ru/onlineboard"); + }); + + it("inserts a leading slash if the path lacks one", () => { + expect(buildUrl("https://flights.aeroflot.ru", "en", "onlineboard")) + .toBe("https://flights.aeroflot.ru/en/onlineboard"); + }); +}); +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +```bash +pnpm test +``` + +Expected: 6 passing tests, 0 failing. + +- [ ] **Step 5: Create the small JSON I/O helper** + +Create `scripts/phase-0/lib/io.ts`: + +```ts +import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { dirname } from "node:path"; + +export function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(value, null, 2) + "\n", "utf8"); +} + +export function readJson(path: string): T { + return JSON.parse(readFileSync(path, "utf8")) as T; +} + +export function fileExists(path: string): boolean { + return existsSync(path); +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add scripts/phase-0/lib/angular-routes.ts scripts/phase-0/lib/io.ts tests/phase-0/lib/angular-routes.test.ts +git commit -m "Catalog Angular route shapes for Phase 0 URL enumeration + +Hand-transcribed from ClientApp/src/app routing modules. Source of truth +for URL corpus, SEO capture, and VRT capture driver scripts that follow." +``` + +--- + +## Task 5: Optional — anonymize prod access logs into an observed-URL list + +Only run this task if you have access to raw access logs. If not, skip to Task 6 — the URL corpus will be built from the route shapes alone. + +**Files:** +- Create: `scripts/phase-0/anonymize-access-logs.ts` + +- [ ] **Step 1: Write the anonymizer** + +Create `scripts/phase-0/anonymize-access-logs.ts`: + +```ts +#!/usr/bin/env tsx +/** + * Reads raw access log lines from stdin, extracts URL paths that match + * the /{lang}/... pattern, strips query strings, deduplicates, and writes + * the result to tests/fixtures/phase-0/url-corpus/observed.json. + * + * Usage: + * cat raw-access.log | pnpm tsx scripts/phase-0/anonymize-access-logs.ts + * + * Accepts Common Log Format or any format where the path is the 7th + * whitespace-separated field inside a quoted request line like + * "GET /ru/onlineboard/flight/SU100-2025-01-15 HTTP/1.1" + */ + +import { createInterface } from "node:readline"; +import { writeJson } from "./lib/io.js"; +import { LANGUAGES } from "./lib/angular-routes.js"; + +const OUTPUT_PATH = "tests/fixtures/phase-0/url-corpus/observed.json"; +const LANG_PREFIX_RE = new RegExp(`^/(?:${LANGUAGES.join("|")})(/|$)`); + +async function main(): Promise { + const rl = createInterface({ input: process.stdin }); + const seen = new Set(); + + for await (const line of rl) { + const requestMatch = line.match(/"\w+\s+(\S+)\s+HTTP/); + if (!requestMatch) continue; + const pathAndQuery = requestMatch[1]; + if (!pathAndQuery) continue; + const [path] = pathAndQuery.split("?", 1); + if (!path) continue; + if (!LANG_PREFIX_RE.test(path)) continue; + seen.add(path); + } + + const urls = [...seen].sort(); + writeJson(OUTPUT_PATH, { capturedAt: new Date().toISOString(), count: urls.length, urls }); + console.error(`Wrote ${urls.length} unique URLs to ${OUTPUT_PATH}`); +} + +await main(); +``` + +- [ ] **Step 2: Run it against a sample (skip if no logs)** + +```bash +cat path/to/anonymized-access.log | pnpm tsx scripts/phase-0/anonymize-access-logs.ts +``` + +Expected: stderr prints a URL count; `tests/fixtures/phase-0/url-corpus/observed.json` exists and contains a sorted unique list. + +- [ ] **Step 3: Commit the script (not the observed.json output — that comes out of Task 6)** + +```bash +git add scripts/phase-0/anonymize-access-logs.ts +git commit -m "Add Phase 0 access-log anonymizer + +Reads raw access logs from stdin, extracts /{lang}/... paths, strips +query strings, deduplicates. Output feeds into the URL corpus." +``` + +--- + +## Task 6: URL corpus — write + run the corpus extractor + +Builds `tests/fixtures/phase-0/url-corpus/{feature}.json`, one file per feature, combining enumerated routes (from Task 4) + observed URLs (from Task 5 if available). + +**Files:** +- Create: `scripts/phase-0/extract-url-corpus.ts` +- Create: `tests/fixtures/phase-0/url-corpus/onlineboard.json` (generated) +- Create: `tests/fixtures/phase-0/url-corpus/schedule.json` (generated) +- Create: `tests/fixtures/phase-0/url-corpus/flights-map.json` (generated) +- Create: `tests/fixtures/phase-0/url-corpus/popular.json` (generated) + +- [ ] **Step 1: Write the extractor** + +Create `scripts/phase-0/extract-url-corpus.ts`: + +```ts +#!/usr/bin/env tsx +/** + * Builds the URL corpus fixture files for each feature, combining: + * - Enumerated URLs from ROUTE_SHAPES × LANGUAGES × samples + * - Observed URLs from tests/fixtures/phase-0/url-corpus/observed.json (if present) + * + * Output: tests/fixtures/phase-0/url-corpus/{feature}.json + * + * Each output file has the shape: + * { feature, capturedAt, count, urls: [{ path, source, slug? }] } + */ + +import { ROUTE_SHAPES, LANGUAGES, renderTemplate, type RouteFeature } from "./lib/angular-routes.js"; +import { writeJson, readJson, fileExists } from "./lib/io.js"; + +interface CorpusEntry { + path: string; + source: "enumerated" | "observed"; + slug?: string; +} + +interface Corpus { + feature: RouteFeature; + capturedAt: string; + count: number; + urls: CorpusEntry[]; +} + +const OBSERVED_PATH = "tests/fixtures/phase-0/url-corpus/observed.json"; + +function enumerate(): Map { + const byFeature = new Map(); + for (const feature of ["onlineboard", "schedule", "flights-map", "popular"] as const) { + byFeature.set(feature, []); + } + for (const shape of ROUTE_SHAPES) { + for (const lang of LANGUAGES) { + for (const sample of shape.samples) { + const rendered = renderTemplate(shape.template, sample); + const path = `/${lang}${rendered}`; + byFeature.get(shape.feature)!.push({ path, source: "enumerated", slug: shape.slug }); + } + } + } + return byFeature; +} + +function assignObservedToFeatures(observed: string[], byFeature: Map): void { + for (const path of observed) { + const feature = classify(path); + if (!feature) continue; + byFeature.get(feature)!.push({ path, source: "observed" }); + } +} + +function classify(path: string): RouteFeature | null { + if (/^\/(ru|en|es|fr|it|ja|ko|zh|de)\/onlineboard(\/|$)/.test(path)) return "onlineboard"; + if (/^\/(ru|en|es|fr|it|ja|ko|zh|de)\/schedule(\/|$)/.test(path)) return "schedule"; + if (/^\/(ru|en|es|fr|it|ja|ko|zh|de)\/flights-map(\/|$)/.test(path)) return "flights-map"; + if (/^\/(ru|en|es|fr|it|ja|ko|zh|de)\/popular(\/|$)/.test(path)) return "popular"; + return null; +} + +function main(): void { + const byFeature = enumerate(); + + if (fileExists(OBSERVED_PATH)) { + const observedDoc = readJson<{ urls: string[] }>(OBSERVED_PATH); + assignObservedToFeatures(observedDoc.urls, byFeature); + console.error(`Merged ${observedDoc.urls.length} observed URLs into the corpus`); + } else { + console.error(`No observed URLs at ${OBSERVED_PATH}; corpus is enumerated-only`); + } + + const capturedAt = new Date().toISOString(); + for (const [feature, urls] of byFeature.entries()) { + const dedup = dedupe(urls); + const corpus: Corpus = { feature, capturedAt, count: dedup.length, urls: dedup }; + const out = `tests/fixtures/phase-0/url-corpus/${feature}.json`; + writeJson(out, corpus); + console.error(`Wrote ${feature} corpus (${dedup.length} URLs) → ${out}`); + } +} + +function dedupe(entries: CorpusEntry[]): CorpusEntry[] { + const byPath = new Map(); + for (const entry of entries) { + const existing = byPath.get(entry.path); + if (!existing) { + byPath.set(entry.path, entry); + continue; + } + // Prefer observed over enumerated (observed has real user evidence). + if (existing.source === "enumerated" && entry.source === "observed") { + byPath.set(entry.path, entry); + } + } + return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path)); +} + +main(); +``` + +- [ ] **Step 2: Run the extractor** + +```bash +pnpm phase0:url-corpus +``` + +Expected stderr output (with no observed logs): +``` +No observed URLs at tests/fixtures/phase-0/url-corpus/observed.json; corpus is enumerated-only +Wrote onlineboard corpus (N URLs) → tests/fixtures/phase-0/url-corpus/onlineboard.json +Wrote schedule corpus (N URLs) → tests/fixtures/phase-0/url-corpus/schedule.json +Wrote flights-map corpus (N URLs) → tests/fixtures/phase-0/url-corpus/flights-map.json +Wrote popular corpus (N URLs) → tests/fixtures/phase-0/url-corpus/popular.json +``` + +- [ ] **Step 3: Inspect one corpus file** + +```bash +head -30 tests/fixtures/phase-0/url-corpus/onlineboard.json +``` + +Expected: JSON starting with `{ "feature": "onlineboard", ... }` and a non-empty `urls` array. + +- [ ] **Step 4: Commit corpus fixtures** + +```bash +git add scripts/phase-0/extract-url-corpus.ts tests/fixtures/phase-0/url-corpus/ +git commit -m "Generate Phase 0 URL corpus fixtures from Angular route shapes + +One fixture file per feature. Combines enumerated routes with observed +log URLs where available. Consumed by SEO capture (Task 8) and by the +Phase 2 URL parity test suite." +``` + +--- + +## Task 7: HTTP fetch helper with retry + throttle + +Before the SEO/hreflang capture tasks can hit prod, we need a small shared HTTP helper that handles transient failures and doesn't hammer the origin. + +**Files:** +- Create: `scripts/phase-0/lib/http.ts` +- Create: `tests/phase-0/lib/http.test.ts` + +- [ ] **Step 1: Write the failing test first** + +Create `tests/phase-0/lib/http.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { fetchWithRetry } from "@phase0/lib/http"; + +describe("fetchWithRetry", () => { + const realFetch = globalThis.fetch; + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { + globalThis.fetch = realFetch; + vi.useRealTimers(); + }); + + it("returns the response on first-try success", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response("ok", { status: 200 })); + const res = await fetchWithRetry("https://example.test/a"); + expect(res.status).toBe(200); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + it("retries on 5xx and returns the eventual success", async () => { + globalThis.fetch = vi.fn() + .mockResolvedValueOnce(new Response("oops", { status: 503 })) + .mockResolvedValueOnce(new Response("ok", { status: 200 })); + const promise = fetchWithRetry("https://example.test/b", { maxRetries: 2, retryDelayMs: 10 }); + await vi.advanceTimersByTimeAsync(10); + const res = await promise; + expect(res.status).toBe(200); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); + + it("gives up after maxRetries and throws with the last status", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response("nope", { status: 500 })); + const promise = fetchWithRetry("https://example.test/c", { maxRetries: 2, retryDelayMs: 1 }); + const pending = expect(promise).rejects.toThrow(/HTTP 500/); + await vi.advanceTimersByTimeAsync(10); + await pending; + expect(globalThis.fetch).toHaveBeenCalledTimes(3); + }); + + it("does not retry on 4xx", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response("no", { status: 404 })); + await expect(fetchWithRetry("https://example.test/d", { maxRetries: 3, retryDelayMs: 1 })) + .rejects.toThrow(/HTTP 404/); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +pnpm test tests/phase-0/lib/http.test.ts +``` + +Expected: all 4 tests fail with `Cannot find module '@phase0/lib/http'` or equivalent. + +- [ ] **Step 3: Implement the helper** + +Create `scripts/phase-0/lib/http.ts`: + +```ts +export interface FetchWithRetryOptions { + maxRetries?: number; + retryDelayMs?: number; + throttleMs?: number; + headers?: Record; +} + +let lastCallAt = 0; + +export async function fetchWithRetry( + url: string, + opts: FetchWithRetryOptions = {}, +): Promise { + const maxRetries = opts.maxRetries ?? 2; + const retryDelayMs = opts.retryDelayMs ?? 500; + const throttleMs = opts.throttleMs ?? 0; + const headers = opts.headers ?? { "User-Agent": "aeroflot-flights-phase0/1.0" }; + + if (throttleMs > 0) { + const sinceLast = Date.now() - lastCallAt; + if (sinceLast < throttleMs) { + await sleep(throttleMs - sinceLast); + } + lastCallAt = Date.now(); + } + + let attempt = 0; + let lastStatus = 0; + while (attempt <= maxRetries) { + const res = await fetch(url, { headers }); + if (res.status >= 200 && res.status < 400) return res; + if (res.status >= 400 && res.status < 500) { + throw new Error(`HTTP ${res.status} ${url}`); + } + lastStatus = res.status; + attempt += 1; + if (attempt > maxRetries) break; + await sleep(retryDelayMs * attempt); + } + throw new Error(`HTTP ${lastStatus} ${url} (after ${maxRetries} retries)`); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +``` + +- [ ] **Step 4: Re-run the test** + +```bash +pnpm test tests/phase-0/lib/http.test.ts +``` + +Expected: 4 passing tests. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/phase-0/lib/http.ts tests/phase-0/lib/http.test.ts +git commit -m "Add fetch-with-retry helper for Phase 0 capture scripts + +Retries on 5xx, fails fast on 4xx, optional per-call throttle window. +Protects the Angular prod origin from being hammered by capture runs." +``` + +--- + +## Task 8: SEO baseline capture script + +Fetches a curated set of representative pages from Angular prod, extracts ``, `<meta>` tags, `<link rel="canonical">`, `<link rel="alternate">` (hreflang), OpenGraph, Twitter Card, and `<script type="application/ld+json">` content. Writes one fixture file per route. + +**Files:** +- Create: `scripts/phase-0/capture-seo-baselines.ts` +- Create: `scripts/phase-0/lib/seo-extractor.ts` +- Create: `tests/phase-0/lib/seo-extractor.test.ts` + +- [ ] **Step 1: Write the failing extractor test** + +Create `tests/phase-0/lib/seo-extractor.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { extractSeo } from "@phase0/lib/seo-extractor"; + +const SAMPLE_HTML = ` +<!doctype html> +<html lang="ru"> +<head> + <title>Аэрофлот — Онлайн-табло SU100 + + + + + + + + + + + + + + +`; + +describe("extractSeo", () => { + it("extracts title and description", () => { + const seo = extractSeo(SAMPLE_HTML); + expect(seo.title).toBe("Аэрофлот — Онлайн-табло SU100"); + expect(seo.description).toBe("Актуальный статус рейса SU100"); + }); + + it("extracts canonical link", () => { + expect(extractSeo(SAMPLE_HTML).canonical) + .toBe("https://flights.aeroflot.ru/ru/onlineboard/SU100-2025-01-15"); + }); + + it("extracts hreflang set including x-default", () => { + const { hreflang } = extractSeo(SAMPLE_HTML); + expect(hreflang).toEqual({ + ru: "https://flights.aeroflot.ru/ru/onlineboard/SU100-2025-01-15", + en: "https://flights.aeroflot.ru/en/onlineboard/SU100-2025-01-15", + "x-default": "https://flights.aeroflot.ru/ru/onlineboard/SU100-2025-01-15", + }); + }); + + it("extracts OpenGraph tags", () => { + const { openGraph } = extractSeo(SAMPLE_HTML); + expect(openGraph).toEqual({ + title: "SU100 status", + type: "website", + url: "https://flights.aeroflot.ru/ru/onlineboard/SU100-2025-01-15", + image: "https://flights.aeroflot.ru/og/default.png", + }); + }); + + it("extracts parsed JSON-LD blocks", () => { + const { jsonLd } = extractSeo(SAMPLE_HTML); + expect(jsonLd).toHaveLength(1); + expect(jsonLd[0]).toMatchObject({ "@type": "Flight", flightNumber: "SU100" }); + }); + + it("returns nulls for missing fields", () => { + const seo = extractSeo(""); + expect(seo.title).toBeNull(); + expect(seo.description).toBeNull(); + expect(seo.canonical).toBeNull(); + expect(seo.hreflang).toEqual({}); + expect(seo.openGraph).toEqual({}); + expect(seo.jsonLd).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +pnpm test tests/phase-0/lib/seo-extractor.test.ts +``` + +Expected: 6 tests fail with `Cannot find module '@phase0/lib/seo-extractor'`. + +- [ ] **Step 3: Implement the extractor** + +Create `scripts/phase-0/lib/seo-extractor.ts`: + +```ts +import * as cheerio from "cheerio"; + +export interface ExtractedSeo { + title: string | null; + description: string | null; + canonical: string | null; + hreflang: Record; + openGraph: Record; + twitterCard: Record; + jsonLd: unknown[]; +} + +export function extractSeo(html: string): ExtractedSeo { + const $ = cheerio.load(html); + + const title = $("head > title").first().text().trim() || null; + const description = $('head > meta[name="description"]').attr("content") ?? null; + const canonical = $('head > link[rel="canonical"]').attr("href") ?? null; + + const hreflang: Record = {}; + $('head > link[rel="alternate"][hreflang]').each((_, el) => { + const lang = $(el).attr("hreflang"); + const href = $(el).attr("href"); + if (lang && href) hreflang[lang] = href; + }); + + const openGraph: Record = {}; + $('head > meta[property^="og:"]').each((_, el) => { + const property = $(el).attr("property"); + const content = $(el).attr("content"); + if (property && content) openGraph[property.replace(/^og:/, "")] = content; + }); + + const twitterCard: Record = {}; + $('head > meta[name^="twitter:"]').each((_, el) => { + const name = $(el).attr("name"); + const content = $(el).attr("content"); + if (name && content) twitterCard[name.replace(/^twitter:/, "")] = content; + }); + + const jsonLd: unknown[] = []; + $('head > script[type="application/ld+json"]').each((_, el) => { + const raw = $(el).contents().text(); + if (!raw.trim()) return; + try { + jsonLd.push(JSON.parse(raw)); + } catch { + jsonLd.push({ __parseError: true, raw }); + } + }); + + return { title, description, canonical, hreflang, openGraph, twitterCard, jsonLd }; +} +``` + +- [ ] **Step 4: Re-run the test** + +```bash +pnpm test tests/phase-0/lib/seo-extractor.test.ts +``` + +Expected: 6 passing. + +- [ ] **Step 5: Write the driver script** + +Create `scripts/phase-0/capture-seo-baselines.ts`: + +```ts +#!/usr/bin/env tsx +/** + * For each feature corpus + each language, fetches one representative + * URL from Angular prod, extracts SEO tags, and writes one fixture per + * route slug to tests/fixtures/phase-0/seo-baselines/. + * + * Required env: PROD_ORIGIN (e.g. https://flights.aeroflot.ru) + * + * Only captures one URL per slug (not every enumerated variant) — the + * purpose is a shape baseline, not a load test. + */ + +import { ROUTE_SHAPES, LANGUAGES, renderTemplate, buildUrl } from "./lib/angular-routes.js"; +import { fetchWithRetry } from "./lib/http.js"; +import { extractSeo } from "./lib/seo-extractor.js"; +import { writeJson } from "./lib/io.js"; + +const origin = process.env.PROD_ORIGIN; +if (!origin) { + console.error("PROD_ORIGIN env var required (e.g. https://flights.aeroflot.ru)"); + process.exit(1); +} + +async function main(): Promise { + const langs = ["ru", "en"] as const; // Two languages per slug is enough for a baseline. + let captured = 0; + let failed = 0; + + for (const shape of ROUTE_SHAPES) { + const sample = shape.samples[0]; + if (!sample) continue; + const rendered = renderTemplate(shape.template, sample); + + for (const lang of langs) { + const url = buildUrl(origin!, lang, rendered); + const fixturePath = `tests/fixtures/phase-0/seo-baselines/${shape.slug}.${lang}.json`; + try { + const res = await fetchWithRetry(url, { throttleMs: 500, maxRetries: 2 }); + const html = await res.text(); + const seo = extractSeo(html); + writeJson(fixturePath, { + capturedAt: new Date().toISOString(), + slug: shape.slug, + lang, + url, + seo, + }); + console.error(` ✔ ${url}`); + captured += 1; + } catch (err) { + console.error(` ✘ ${url} — ${(err as Error).message}`); + failed += 1; + } + } + } + + console.error(`\nCaptured ${captured} SEO baselines, ${failed} failures.`); + if (failed > 0) process.exit(1); +} + +void main(); +``` + +- [ ] **Step 6: Run against Angular prod** + +```bash +PROD_ORIGIN=https://flights.aeroflot.ru pnpm phase0:seo +``` + +Expected: stderr prints ✔ / ✘ per URL; ~26 files written under `tests/fixtures/phase-0/seo-baselines/`. Zero failures. + +**If any fetches fail:** investigate before committing. Common causes: origin gating by `User-Agent` (edit the header in `lib/http.ts`), prod returning 403 for bot-like traffic (work with the customer to whitelist the script's source IP or use a staging mirror), or a URL in the samples that doesn't exist (adjust `ROUTE_SHAPES.samples`). + +- [ ] **Step 7: Spot-check one fixture** + +```bash +cat tests/fixtures/phase-0/seo-baselines/onlineboard-flight.ru.json +``` + +Expected: JSON with a non-null `title`, a `canonical` URL, a non-empty `hreflang` object with at least `ru` and `en` entries, and at least one `jsonLd` block (or empty — that's a gap the React rewrite fills). + +- [ ] **Step 8: Commit** + +```bash +git add scripts/phase-0/capture-seo-baselines.ts scripts/phase-0/lib/seo-extractor.ts tests/phase-0/lib/seo-extractor.test.ts tests/fixtures/phase-0/seo-baselines/ +git commit -m "Capture SEO baselines from Angular prod + +One fixture per route slug × language. Records title, description, +canonical, hreflang set, OpenGraph, Twitter Card, and JSON-LD blocks. +Consumed by the Phase 2+ SEO parity tests — React output must match +or improve on these." +``` + +--- + +## Task 9: Hreflang reciprocal-parity check + +The current Angular app may or may not emit reciprocal `hreflang` sets correctly. Phase 0 captures the *actual* state so the Phase 2+ parity tests can assert "at least as good as Angular, and ideally better." + +**Files:** +- Create: `scripts/phase-0/capture-hreflang-parity.ts` +- Create: `tests/fixtures/phase-0/hreflang-parity/.json` (generated) + +- [ ] **Step 1: Write the script** + +Create `scripts/phase-0/capture-hreflang-parity.ts`: + +```ts +#!/usr/bin/env tsx +/** + * For each route slug, fetches all 9 language variants from Angular prod, + * extracts each variant's hreflang set, and writes a single fixture per + * slug comparing the sets. + * + * Required env: PROD_ORIGIN + * + * The resulting fixture records whether the hreflang sets are identical + * across language variants (they should be — hreflang must be reciprocal). + * Phase 2+ SEO parity tests will assert the React output has reciprocal + * hreflang regardless of what Angular does today. + */ + +import { ROUTE_SHAPES, LANGUAGES, renderTemplate, buildUrl } from "./lib/angular-routes.js"; +import { fetchWithRetry } from "./lib/http.js"; +import { extractSeo } from "./lib/seo-extractor.js"; +import { writeJson } from "./lib/io.js"; + +const origin = process.env.PROD_ORIGIN; +if (!origin) { + console.error("PROD_ORIGIN env var required"); + process.exit(1); +} + +async function main(): Promise { + for (const shape of ROUTE_SHAPES) { + const sample = shape.samples[0]; + if (!sample) continue; + const rendered = renderTemplate(shape.template, sample); + + const perLang: Record | null; error?: string }> = {}; + for (const lang of LANGUAGES) { + const url = buildUrl(origin!, lang, rendered); + try { + const res = await fetchWithRetry(url, { throttleMs: 500, maxRetries: 2 }); + const html = await res.text(); + const { hreflang } = extractSeo(html); + perLang[lang] = { url, hreflang }; + } catch (err) { + perLang[lang] = { url, hreflang: null, error: (err as Error).message }; + } + } + + const sets = Object.values(perLang) + .map((v) => v.hreflang) + .filter((h): h is Record => h !== null); + const reciprocal = sets.length > 0 && sets.every((s) => JSON.stringify(sortKeys(s)) === JSON.stringify(sortKeys(sets[0]!))); + + const out = `tests/fixtures/phase-0/hreflang-parity/${shape.slug}.json`; + writeJson(out, { + capturedAt: new Date().toISOString(), + slug: shape.slug, + reciprocal, + perLang, + }); + console.error(` ${reciprocal ? "✔" : "✘"} ${shape.slug} — reciprocal: ${reciprocal}`); + } +} + +function sortKeys>(obj: T): T { + return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b))) as T; +} + +void main(); +``` + +- [ ] **Step 2: Run against Angular prod** + +```bash +PROD_ORIGIN=https://flights.aeroflot.ru pnpm phase0:hreflang +``` + +Expected: one line per route slug, marked ✔ (reciprocal) or ✘ (drift). A few ✘ are fine at this stage — they document the Angular baseline we need to beat. + +- [ ] **Step 3: Inspect a drift case (if any)** + +```bash +grep -l '"reciprocal": false' tests/fixtures/phase-0/hreflang-parity/ || echo "No drift found" +``` + +If drift is found, note the affected slugs in `docs/superpowers/phase-0/README.md` (Task 17) as "Angular hreflang bugs the React rewrite fixes by default." + +- [ ] **Step 4: Commit** + +```bash +git add scripts/phase-0/capture-hreflang-parity.ts tests/fixtures/phase-0/hreflang-parity/ +git commit -m "Capture Angular hreflang reciprocal-parity baseline + +One fixture per route slug records the hreflang sets emitted by every +language variant. The 'reciprocal' flag says whether all variants agree. +Any false results document Angular SEO bugs the React rewrite fixes." +``` + +--- + +## Task 10: Playwright VRT config + baseline capture script + +Captures ~60 reference screenshots from Angular prod: 10 curated routes × 3 viewports (375 / 768 / 1440) × 2 languages (ru, en). Used by the Phase 2+ visual-regression gate to enforce pixel parity. + +**Files:** +- Create: `playwright.phase0.config.ts` +- Create: `scripts/phase-0/capture-vrt-baselines.ts` +- Create: `tests/phase-0/vrt/baseline.spec.ts` + +- [ ] **Step 1: Create the Playwright config** + +Create `playwright.phase0.config.ts` at the repo root: + +```ts +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/phase-0/vrt", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 4, + reporter: [["list"]], + use: { + actionTimeout: 10_000, + navigationTimeout: 30_000, + ignoreHTTPSErrors: false, + userAgent: "aeroflot-flights-phase0-vrt/1.0", + }, + projects: [ + { + name: "mobile", + use: { ...devices["iPhone SE"], viewport: { width: 375, height: 667 } }, + }, + { + name: "tablet", + use: { viewport: { width: 768, height: 1024 }, userAgent: "aeroflot-flights-phase0-vrt/1.0" }, + }, + { + name: "desktop", + use: { viewport: { width: 1440, height: 900 }, userAgent: "aeroflot-flights-phase0-vrt/1.0" }, + }, + ], +}); +``` + +- [ ] **Step 2: Curate the 10 VRT routes** + +The 60-image matrix = 10 routes × 3 viewports × 2 languages. Select representative routes from the URL corpus — aim for coverage of start pages, search results, and detail pages across all four features: + +Create `scripts/phase-0/lib/vrt-routes.ts`: + +```ts +import type { RouteShape } from "./angular-routes.js"; +import { ROUTE_SHAPES, renderTemplate } from "./angular-routes.js"; + +/** Curated subset of routes used for VRT baseline capture. */ +const VRT_SLUGS = [ + "onlineboard-start", + "onlineboard-flight", + "onlineboard-departure", + "onlineboard-arrival", + "onlineboard-route", + "onlineboard-details", + "schedule-start", + "schedule-oneway", + "flights-map-start", + "popular-start", +]; + +export interface VrtRoute { + slug: string; + path: string; // without language prefix +} + +export function getVrtRoutes(): VrtRoute[] { + const byFeatureSlug = new Map(ROUTE_SHAPES.map((s) => [s.slug, s])); + return VRT_SLUGS.map((slug) => { + const shape = byFeatureSlug.get(slug); + if (!shape) throw new Error(`VRT: unknown slug ${slug}`); + const sample = shape.samples[0]; + if (!sample) throw new Error(`VRT: no sample for ${slug}`); + return { slug, path: renderTemplate(shape.template, sample) }; + }); +} +``` + +- [ ] **Step 3: Write the Playwright baseline spec** + +Create `tests/phase-0/vrt/baseline.spec.ts`: + +```ts +import { test, expect } from "@playwright/test"; +import { getVrtRoutes } from "../../../scripts/phase-0/lib/vrt-routes.js"; + +const ORIGIN = process.env.PROD_ORIGIN; +const LANGS = ["ru", "en"] as const; + +if (!ORIGIN) { + throw new Error("PROD_ORIGIN env var required for VRT baseline capture"); +} + +test.describe("Angular prod VRT baselines", () => { + for (const route of getVrtRoutes()) { + for (const lang of LANGS) { + test(`${route.slug} | ${lang}`, async ({ page }, testInfo) => { + const url = `${ORIGIN}/${lang}${route.path}`; + const response = await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 }); + expect(response?.status()).toBeLessThan(400); + + // Give any lazy-loaded fonts + images a beat to settle. + await page.waitForTimeout(1500); + + // Full-page screenshot; masks cover dynamic content (live times, moving map tiles). + const screenshot = await page.screenshot({ + fullPage: true, + animations: "disabled", + }); + + const outPath = `tests/fixtures/phase-0/vrt-baselines/${route.slug}-${testInfo.project.name}-${lang}.png`; + await testInfo.attach("baseline", { body: screenshot, contentType: "image/png" }); + + // Write the baseline file directly (Playwright snapshots are overkill for Phase 0 capture). + const { writeFileSync, mkdirSync } = await import("node:fs"); + const { dirname } = await import("node:path"); + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, screenshot); + }); + } + } +}); +``` + +- [ ] **Step 4: Run VRT capture** + +```bash +PROD_ORIGIN=https://flights.aeroflot.ru pnpm phase0:vrt +``` + +Expected: 60 tests pass (10 routes × 3 projects × 2 langs). `tests/fixtures/phase-0/vrt-baselines/` contains 60 PNGs. + +If some pages fail with navigation timeouts or `networkidle` never settles, add a per-slug override that uses `domcontentloaded` instead — Leaflet tile loading and SignalR connection attempts can hold `networkidle` open indefinitely. Adjust `waitUntil` in the spec for the problem routes and re-run. + +- [ ] **Step 5: Verify file count** + +```bash +ls tests/fixtures/phase-0/vrt-baselines/*.png | wc -l +``` + +Expected: `60` + +- [ ] **Step 6: Commit** + +```bash +git add playwright.phase0.config.ts scripts/phase-0/lib/vrt-routes.ts tests/phase-0/vrt/baseline.spec.ts tests/fixtures/phase-0/vrt-baselines/ +git commit -m "Capture 60 Playwright VRT baselines from Angular prod + +10 curated routes × 3 viewports × 2 languages. These PNGs are the +pixel-parity source of truth for the Phase 2+ visual regression gate. +Phase-end re-baselining (§8.4 of the design spec) replaces these with +React-build baselines once each feature ships." +``` + +--- + +## Task 11: PrimeNG component inventory + +Scans `ClientApp/src/` for every `import` from `primeng/*` and every `p-*` tag used in templates. Produces a backlog the Phase 1E (UI adapter) and Phase 2+ work plans pull from. + +**Files:** +- Create: `scripts/phase-0/inventory-primeng.ts` +- Create: `docs/superpowers/phase-0/primeng-backlog.md` (generated) + +- [ ] **Step 1: Write the inventory script** + +Create `scripts/phase-0/inventory-primeng.ts`: + +```ts +#!/usr/bin/env tsx +/** + * Scans ClientApp/src for: + * - imports from 'primeng/*' (in .ts files) + * - tag usages (in .html template files) + * + * Produces a markdown backlog at docs/superpowers/phase-0/primeng-backlog.md + * grouped by PrimeNG module, with the files that reference each one. + */ + +import { readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +const ROOT = "ClientApp/src"; +const IMPORT_RE = /from\s+['"]primeng\/(\w[\w-]*)['"]/g; +const TAG_RE = /; +} + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + const s = statSync(full); + if (s.isDirectory()) { + if (name === "node_modules" || name === "dist") continue; + walk(full, out); + } else if (/\.(ts|html)$/.test(name)) { + out.push(full); + } + } + return out; +} + +function main(): void { + const files = walk(ROOT); + const index = new Map(); + + for (const file of files) { + const contents = readFileSync(file, "utf8"); + const relFile = relative(process.cwd(), file); + + if (file.endsWith(".ts")) { + for (const match of contents.matchAll(IMPORT_RE)) { + const mod = match[1]!; + const key = `import:${mod}`; + if (!index.has(key)) index.set(key, { item: mod, kind: "import", files: new Set() }); + index.get(key)!.files.add(relFile); + } + } + if (file.endsWith(".html")) { + for (const match of contents.matchAll(TAG_RE)) { + const tag = `p-${match[1]}`; + const key = `tag:${tag}`; + if (!index.has(key)) index.set(key, { item: tag, kind: "tag", files: new Set() }); + index.get(key)!.files.add(relFile); + } + } + } + + const imports = [...index.values()].filter((r) => r.kind === "import").sort((a, b) => a.item.localeCompare(b.item)); + const tags = [...index.values()].filter((r) => r.kind === "tag").sort((a, b) => a.item.localeCompare(b.item)); + + const lines: string[] = []; + lines.push("# PrimeNG component inventory"); + lines.push(""); + lines.push("> Generated by `scripts/phase-0/inventory-primeng.ts`. Do not hand-edit."); + lines.push(""); + lines.push(`**Totals:** ${imports.length} imported modules, ${tags.length} template tags.`); + lines.push(""); + lines.push("## Imported modules"); + lines.push(""); + lines.push("| Module | Files | Phase 1E target |"); + lines.push("|---|---|---|"); + for (const row of imports) { + lines.push(`| \`primeng/${row.item}\` | ${row.files.size} | (TBD — see §5.3 of the design spec) |`); + } + lines.push(""); + lines.push("## Template tags"); + lines.push(""); + lines.push("| Tag | Files |"); + lines.push("|---|---|"); + for (const row of tags) { + lines.push(`| \`<${row.item}>\` | ${row.files.size} |`); + } + lines.push(""); + lines.push("## File detail"); + lines.push(""); + for (const row of [...imports, ...tags]) { + lines.push(`### \`${row.kind === "import" ? `primeng/${row.item}` : `<${row.item}>`}\``); + lines.push(""); + for (const file of [...row.files].sort()) { + lines.push(`- ${file}`); + } + lines.push(""); + } + + mkdirSync("docs/superpowers/phase-0", { recursive: true }); + writeFileSync("docs/superpowers/phase-0/primeng-backlog.md", lines.join("\n")); + console.error(`Wrote ${imports.length} imports + ${tags.length} tags to docs/superpowers/phase-0/primeng-backlog.md`); +} + +main(); +``` + +- [ ] **Step 2: Run it** + +```bash +pnpm phase0:inventory:primeng +``` + +Expected: stderr reports counts; `docs/superpowers/phase-0/primeng-backlog.md` exists with a non-empty table. + +- [ ] **Step 3: Spot-check the output** + +```bash +head -40 docs/superpowers/phase-0/primeng-backlog.md +``` + +Expected: Markdown with an "Imported modules" table listing PrimeNG modules (`calendar`, `autocomplete`, `accordion`, `dropdown`, `table`, `tooltip`, `dialog`, `toast`, etc.). + +- [ ] **Step 4: Commit** + +```bash +git add scripts/phase-0/inventory-primeng.ts docs/superpowers/phase-0/primeng-backlog.md +git commit -m "Inventory PrimeNG usages in ClientApp/ + +Produces the Phase 1E UI adapter backlog: every primeng/ import and +every template tag, grouped by referenced file." +``` + +--- + +## Task 12: SCSS token + theme-override inventory + +Extracts every `$variable`, every `:root { --var }`, every selector override of a PrimeNG class (`.p-*`), and every `@import` of SCSS files under `ClientApp/src/styles/`. Produces the port manifest for the `src/ui/styles/` theme porting work in Phase 1E. + +**Files:** +- Create: `scripts/phase-0/inventory-scss-tokens.ts` +- Create: `docs/superpowers/phase-0/scss-theme-manifest.md` (generated) + +- [ ] **Step 1: Write the inventory script** + +Create `scripts/phase-0/inventory-scss-tokens.ts`: + +```ts +#!/usr/bin/env tsx +/** + * Extracts SCSS variables, CSS custom properties, and PrimeNG selector + * overrides from ClientApp/src for the theme-port manifest. + */ + +import { readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +const ROOTS = ["ClientApp/src/styles", "ClientApp/src/app"]; +const SCSS_VAR_RE = /\$([a-zA-Z][\w-]*)\s*:/g; +const CSS_VAR_RE = /--([a-zA-Z][\w-]*)\s*:/g; +const PRIME_SELECTOR_RE = /(\.p-[a-zA-Z][\w-]*(?:[:\s]+[^{]*)?)\s*\{/g; + +interface Sighting { file: string; count: number; } + +function walk(dir: string, out: string[] = []): string[] { + try { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + const s = statSync(full); + if (s.isDirectory()) { + if (name === "node_modules" || name === "dist") continue; + walk(full, out); + } else if (/\.scss$/.test(name)) { + out.push(full); + } + } + } catch { + // ROOT may not exist; ignore. + } + return out; +} + +function collect(): { + scssVars: Map; + cssVars: Map; + primeSelectors: Map; +} { + const scssVars = new Map(); + const cssVars = new Map(); + const primeSelectors = new Map(); + + const files: string[] = []; + for (const root of ROOTS) walk(root, files); + + for (const file of files) { + const contents = readFileSync(file, "utf8"); + const rel = relative(process.cwd(), file); + count(contents, SCSS_VAR_RE, scssVars, rel); + count(contents, CSS_VAR_RE, cssVars, rel); + count(contents, PRIME_SELECTOR_RE, primeSelectors, rel); + } + + return { scssVars, cssVars, primeSelectors }; +} + +function count(source: string, regex: RegExp, into: Map, file: string): void { + const matches = [...source.matchAll(regex)]; + const localCounts = new Map(); + for (const m of matches) { + const key = (m[1] ?? m[0]).trim(); + localCounts.set(key, (localCounts.get(key) ?? 0) + 1); + } + for (const [key, n] of localCounts.entries()) { + if (!into.has(key)) into.set(key, []); + into.get(key)!.push({ file, count: n }); + } +} + +function render(): string { + const { scssVars, cssVars, primeSelectors } = collect(); + const lines: string[] = []; + lines.push("# SCSS theme-port manifest"); + lines.push(""); + lines.push("> Generated by `scripts/phase-0/inventory-scss-tokens.ts`. Do not hand-edit."); + lines.push(""); + lines.push(`**Totals:** ${scssVars.size} SCSS variables, ${cssVars.size} CSS custom properties, ${primeSelectors.size} PrimeNG selector overrides.`); + lines.push(""); + lines.push("## SCSS variables (`$var`)"); + lines.push(""); + lines.push("These port to `src/ui/styles/_tokens.scss` as CSS custom properties (`--var`) per §5.3 of the design spec."); + lines.push(""); + lines.push("| Variable | Defined in |"); + lines.push("|---|---|"); + for (const key of [...scssVars.keys()].sort()) { + const sight = scssVars.get(key)!; + lines.push(`| \`$${key}\` | ${sight.map((s) => s.file).join("
")} |`); + } + lines.push(""); + lines.push("## CSS custom properties (`--var`)"); + lines.push(""); + lines.push("| Property | Defined in |"); + lines.push("|---|---|"); + for (const key of [...cssVars.keys()].sort()) { + const sight = cssVars.get(key)!; + lines.push(`| \`--${key}\` | ${sight.map((s) => s.file).join("
")} |`); + } + lines.push(""); + lines.push("## PrimeNG selector overrides"); + lines.push(""); + lines.push("These port to `src/ui/styles/_theme-primereact.scss`. Most port unchanged because PrimeReact uses the same `.p-*` class taxonomy."); + lines.push(""); + lines.push("| Selector | Defined in |"); + lines.push("|---|---|"); + for (const key of [...primeSelectors.keys()].sort()) { + const sight = primeSelectors.get(key)!; + lines.push(`| \`${key}\` | ${sight.map((s) => s.file).join("
")} |`); + } + lines.push(""); + return lines.join("\n"); +} + +function main(): void { + const markdown = render(); + mkdirSync("docs/superpowers/phase-0", { recursive: true }); + writeFileSync("docs/superpowers/phase-0/scss-theme-manifest.md", markdown); + console.error("Wrote docs/superpowers/phase-0/scss-theme-manifest.md"); +} + +main(); +``` + +- [ ] **Step 2: Run it** + +```bash +pnpm phase0:inventory:scss +``` + +Expected: stderr prints the write confirmation; the manifest file exists. + +- [ ] **Step 3: Spot-check** + +```bash +head -40 docs/superpowers/phase-0/scss-theme-manifest.md +``` + +Expected: totals line with non-zero counts + the "SCSS variables" table. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/phase-0/inventory-scss-tokens.ts docs/superpowers/phase-0/scss-theme-manifest.md +git commit -m "Inventory SCSS tokens + PrimeNG overrides for Phase 1E + +Extracts every SCSS variable, CSS custom property, and .p-* selector +override from ClientApp/src. Phase 1E uses the manifest as the theme +port backlog." +``` + +--- + +## Task 13: Translation-key usage inventory + +The Angular i18n JSON files contain every translated string ever used. Some keys are dead code. This script scans `ClientApp/src` for keys that are *actually referenced* (via the `translate` pipe, `translate` directive, or `TranslateService` API), so the Phase 1C i18n port can skip dead strings if desired. + +**Files:** +- Create: `scripts/phase-0/inventory-translation-keys.ts` +- Create: `docs/superpowers/phase-0/translation-keys-used.md` (generated) + +- [ ] **Step 1: Write the script** + +Create `scripts/phase-0/inventory-translation-keys.ts`: + +```ts +#!/usr/bin/env tsx +/** + * Scans ClientApp/src for @ngx-translate key references: + * - '{{ "key" | translate }}' in templates + * - '[translate]="..."' attribute in templates + * - translate.get("key") / translate.instant("key") in TS + * + * Compares against the key set in ClientApp/src/assets/i18n/ru.json + * (used as the canonical key list) and produces a markdown report: + * - keys used in code + * - keys present in ru.json but never referenced (dead) + * - keys referenced but missing from ru.json (broken) + */ + +import { readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +const SRC_ROOT = "ClientApp/src/app"; +const RU_JSON = "ClientApp/src/assets/i18n/ru.json"; +const PIPE_RE = /['"]([a-zA-Z][\w.-]*?)['"]\s*\|\s*translate/g; +const SERVICE_RE = /translate\.(?:get|instant|stream)\(['"]([a-zA-Z][\w.-]*?)['"]/g; +const ATTR_RE = /\[translate\]="['"]?([a-zA-Z][\w.-]*?)['"]?"/g; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + const s = statSync(full); + if (s.isDirectory()) walk(full, out); + else if (/\.(ts|html)$/.test(name)) out.push(full); + } + return out; +} + +function flattenKeys(obj: unknown, prefix = ""): string[] { + if (typeof obj !== "object" || obj === null) return prefix ? [prefix] : []; + const keys: string[] = []; + for (const [k, v] of Object.entries(obj)) { + const next = prefix ? `${prefix}.${k}` : k; + if (typeof v === "object" && v !== null) keys.push(...flattenKeys(v, next)); + else keys.push(next); + } + return keys; +} + +function main(): void { + const used = new Set(); + const usedFiles = new Map>(); + + for (const file of walk(SRC_ROOT)) { + const contents = readFileSync(file, "utf8"); + const rel = relative(process.cwd(), file); + const regexes = file.endsWith(".html") ? [PIPE_RE, ATTR_RE] : [PIPE_RE, SERVICE_RE]; + for (const re of regexes) { + for (const m of contents.matchAll(re)) { + const key = m[1]!; + used.add(key); + if (!usedFiles.has(key)) usedFiles.set(key, new Set()); + usedFiles.get(key)!.add(rel); + } + } + } + + const defined = new Set(flattenKeys(JSON.parse(readFileSync(RU_JSON, "utf8")))); + const dead = [...defined].filter((k) => !used.has(k)).sort(); + const broken = [...used].filter((k) => !defined.has(k)).sort(); + const live = [...used].filter((k) => defined.has(k)).sort(); + + const lines: string[] = []; + lines.push("# Translation-key usage inventory"); + lines.push(""); + lines.push("> Generated by `scripts/phase-0/inventory-translation-keys.ts`. Do not hand-edit."); + lines.push(""); + lines.push(`**Totals:** ${defined.size} defined in ru.json · ${live.length} live · ${dead.length} dead · ${broken.length} broken references.`); + lines.push(""); + + lines.push(`## Live keys (${live.length})`); + lines.push(""); + lines.push("These must be ported to `src/i18n/locales/*/common.json` in Phase 1C."); + lines.push(""); + lines.push("
Show list"); + lines.push(""); + for (const key of live) lines.push(`- \`${key}\``); + lines.push(""); + lines.push("
"); + lines.push(""); + + lines.push(`## Dead keys (${dead.length})`); + lines.push(""); + lines.push("Present in ru.json but never referenced. The Phase 1C port may skip these, or keep them as insurance against keys used via dynamic composition that this static scan can't catch."); + lines.push(""); + lines.push("
Show list"); + lines.push(""); + for (const key of dead) lines.push(`- \`${key}\``); + lines.push(""); + lines.push("
"); + lines.push(""); + + lines.push(`## Broken references (${broken.length})`); + lines.push(""); + lines.push("Keys used in code but missing from ru.json. These are Angular bugs — the React port should fix them by adding the missing translations."); + lines.push(""); + for (const key of broken) { + const files = [...(usedFiles.get(key) ?? [])].sort(); + lines.push(`- \`${key}\` (in ${files.join(", ")})`); + } + lines.push(""); + + mkdirSync("docs/superpowers/phase-0", { recursive: true }); + writeFileSync("docs/superpowers/phase-0/translation-keys-used.md", lines.join("\n")); + console.error(`live=${live.length} dead=${dead.length} broken=${broken.length}`); +} + +main(); +``` + +- [ ] **Step 2: Run it** + +```bash +pnpm phase0:inventory:i18n +``` + +Expected: stderr prints `live=N dead=M broken=K`. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/phase-0/inventory-translation-keys.ts docs/superpowers/phase-0/translation-keys-used.md +git commit -m "Inventory live vs dead translation keys in Angular i18n + +Compares keys referenced in ClientApp/src against keys defined in +ru.json. Produces live/dead/broken buckets used by the Phase 1C i18n +port to decide what moves and what gets dropped." +``` + +--- + +## Task 14: Customer confirmation checklist + +Produces the questionnaire the customer point-of-contact must answer to unblock Phase 1. Not an automated task — a hand-written document. + +**Files:** +- Create: `docs/superpowers/phase-0/customer-confirmation-checklist.md` + +- [ ] **Step 1: Author the checklist** + +Create `docs/superpowers/phase-0/customer-confirmation-checklist.md`: + +```markdown +# Customer confirmation checklist (Phase 0 blockers) + +This questionnaire collects the customer-side decisions flagged as assumptions in the design spec (`docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md` §10). Each must be answered before Phase 1 can start — their answers shape the Modern.js project layout, CI configuration, and logging transport. + +Deliver this document to the customer point-of-contact. Record answers inline below and commit the updated file to close out Phase 0. + +--- + +## A1 — Remote-frontend module template (design spec §2.5) + +**Question.** The design spec defaults to the idiomatic Modern.js + Module Federation 2.0 remote module layout. The customer's "standard remote-frontend module template" (required by customer requirement 9) has not been shared. + +- Is there a published template (repo link, archive, or written conventions) the customer wants us to match? +- If yes, who owns it and what does it specify for: directory layout, exposed module naming, `mf-manifest.json` metadata fields, shared-dependency declarations, artifact packaging? +- If no, does the customer accept that the Modern.js default layout will be used and reconciled later (rename-only migration expected)? + +**Answer:** + +--- + +## A2 — CDN vendor (design spec §8.2) + +**Question.** Standalone SSR cache headers (`Cache-Control` with `s-maxage` + `stale-while-revalidate`) assume a standards-compliant CDN in front of the origin. Remote-mode static artifacts also require a CDN. + +- Which CDN does the customer operate? (Yandex Cloud CDN / Cloudflare / Akamai / other) +- Are there customer-side constraints on cache header values, purge mechanisms, or TLS certificate delivery? +- Who in the customer's infrastructure team owns the CDN configuration? + +**Answer:** + +--- + +## A3 — CI provider (design spec §8.5) + +**Question.** The design spec assumes GitHub Actions for CI. Pipelines port directly to GitLab CI or TeamCity if needed, but environment-specific glue has to be written. + +- Which CI provider does the customer use for frontend projects? +- Are there existing pipeline templates / shared workflows / Docker base images we must adopt? +- Are there customer-mandated scanning tools (SAST / SCA / license scan) beyond `osv-scanner` + `npm audit` the spec already includes? + +**Answer:** + +--- + +## A4 — Frontend log format (design spec §7.2) + +**Question.** Customer requirement 8 states: "frontend logs collected, formed into a file of a customer-specified format (to be provided separately), shipped to the customer's log system." The format specification has not been shared. + +- What log format does the customer require? (JSON-lines / plain text / vendor-specific like Fluentd / CEF / other) +- What is the log-ingestion endpoint? (HTTP POST URL / Kafka topic / Filebeat agent / other) +- Authentication? (bearer token / mTLS / IP allowlist) +- Required fields beyond the standard set (timestamp, level, message, trace id)? +- Any PII handling rules beyond the redaction list in the spec (`password`, `token`, `authorization`, `cookie`, `email`, `phone`)? + +Until this is answered, Phase 1G ships with `JsonLinesHttpTransport` as the default (spec §7.2, assumption A4). The customer format plugs in as a new `LogTransport` implementation at `src/observability/logger/index.ts` without feature-code changes. + +**Answer:** + +--- + +## A5 — ASP.NET host retention (design spec §9.2, Phase 6) + +**Question.** Phase 6 assumes `Aeroflot.Flights.Web.csproj` + `Startup.cs` + `Program.cs` can be deleted once the React app reaches 100% traffic, unless the host serves something unrelated to the frontend. + +- Does the ASP.NET host have responsibilities beyond serving the Angular SPA? (API proxy, SSO, server-side form handling, static asset delivery) +- Who owns the ASP.NET code currently? +- Is there a shared infrastructure component (load balancer, reverse proxy, security headers) that the ASP.NET layer provides and that we must replace before decommissioning? + +This only needs to be answered before Phase 6 starts, but it's cheap to ask now. + +**Answer:** + +--- + +## A6 — Metrics aggregator endpoint (design spec §7.3) + +**Question.** The spec uses OpenTelemetry with OTLP/HTTP export to any aggregator the customer operates. The endpoint URL and auth mechanism aren't in the spec. + +- What is the OTLP ingestion endpoint for the metrics aggregator? (Dynatrace / Grafana Mimir / other) +- Authentication? (API key in `OTEL_EXPORTER_OTLP_HEADERS` / mTLS) +- Are there customer-standard metric naming conventions we must follow? +- Will the customer's aggregator also ingest traces + logs, or are those separate pipelines? + +**Answer:** + +--- + +## A7 — Analytics vendor credentials (design spec §7.4) + +**Question.** Four analytics vendors are listed: Яндекс.Метрика, CTM, Вариокуб, Ключ-Астром (Dynatrace). Each needs a property/container ID and (in some cases) an auth snippet. + +| Vendor | What we need | +|---|---| +| Яндекс.Метрика | Counter ID | +| CTM | Tracking ID + script source | +| Вариокуб | Property ID + script source | +| Ключ-Астром (Dynatrace RUM) | Agent script URL + application ID | + +Per-environment (dev / testing / staging / production) if they differ. + +**Answer:** + +--- + +## A8 — Production URL + access logs (Phase 0 prerequisites) + +**Question.** + +- Production URL for Angular app (used by Phase 0 capture scripts): \_\_\_ +- Staging mirror URL (fallback if prod blocks scraping): \_\_\_ +- Access-log availability: (yes / no — if yes, format and how we fetch them) +- Contact on the customer side if the capture scripts get blocked by WAF / rate limits: \_\_\_ + +**Answer:** + +--- + +## Sign-off + +- [ ] All blockers above answered +- [ ] Any follow-up tickets opened on the customer side +- [ ] Phase 0 capture scripts successfully re-run with confirmed inputs + +**Signed:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_    **Date:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_ +``` + +- [ ] **Step 2: Commit the checklist** + +```bash +git add docs/superpowers/phase-0/customer-confirmation-checklist.md +git commit -m "Add customer confirmation checklist for Phase 0 blockers + +Questionnaire covering spec assumptions A1–A7 plus Phase 0 prerequisites +(prod URL, access logs, escalation contact). Must be filled in by the +customer point-of-contact before Phase 1 can start." +``` + +--- + +## Task 15: Phase 0 README + manifest + +Indexes all Phase 0 deliverables so future engineers can find them without re-reading the plan. + +**Files:** +- Create: `docs/superpowers/phase-0/README.md` + +- [ ] **Step 1: Author the README** + +Create `docs/superpowers/phase-0/README.md`: + +```markdown +# Phase 0 — Preflight deliverables + +This directory indexes everything Phase 0 produced. Phase 1 and later phases consume these artifacts; none of them should be hand-edited after Phase 0 closes — regenerate by re-running the capture scripts if the source changes. + +## What Phase 0 produced + +### Fixtures (consumed by Phase 1+ tests) + +| Artifact | Path | Produced by | +|---|---|---| +| URL corpus (per feature) | `tests/fixtures/phase-0/url-corpus/{feature}.json` | `scripts/phase-0/extract-url-corpus.ts` | +| SEO baselines | `tests/fixtures/phase-0/seo-baselines/*.json` | `scripts/phase-0/capture-seo-baselines.ts` | +| Hreflang parity baselines | `tests/fixtures/phase-0/hreflang-parity/*.json` | `scripts/phase-0/capture-hreflang-parity.ts` | +| VRT baselines (60 PNGs) | `tests/fixtures/phase-0/vrt-baselines/*.png` | `tests/phase-0/vrt/baseline.spec.ts` via `playwright.phase0.config.ts` | + +### Inventory documents (consumed by Phase 1 sub-plans) + +| Document | Consumer | +|---|---| +| `primeng-backlog.md` | Phase 1E (UI adapter) | +| `scss-theme-manifest.md` | Phase 1E (UI adapter) | +| `translation-keys-used.md` | Phase 1C (i18n) | + +### Gate documents + +| Document | Purpose | +|---|---| +| `customer-confirmation-checklist.md` | Phase 0 exit gate — blockers for Phase 1 | + +## How to regenerate + +All scripts assume Node 20 + pnpm. Set `PROD_ORIGIN` for scripts that hit the live Angular app. + +```bash +pnpm install + +# URL corpus (enumerated + optional observed) +pnpm phase0:url-corpus + +# SEO + hreflang (require PROD_ORIGIN) +PROD_ORIGIN=https://flights.aeroflot.ru pnpm phase0:seo +PROD_ORIGIN=https://flights.aeroflot.ru pnpm phase0:hreflang + +# VRT (requires PROD_ORIGIN; slow — captures 60 PNGs) +PROD_ORIGIN=https://flights.aeroflot.ru pnpm phase0:vrt + +# Inventories (read-only scans of ClientApp/src, no network) +pnpm phase0:inventory:primeng +pnpm phase0:inventory:scss +pnpm phase0:inventory:i18n +``` + +## Phase 0 exit gate + +Phase 0 is complete when: + +- [ ] All 4 URL corpus fixtures exist and have non-zero `count` +- [ ] At least 20 SEO baseline fixtures captured without errors +- [ ] 60 VRT baseline PNGs captured without errors +- [ ] All three inventory markdown files generated and non-empty +- [ ] `customer-confirmation-checklist.md` has signed answers for A1–A8 +- [ ] Any "Angular hreflang bugs" from Task 9 are noted in this README (§ below) + +## Angular baseline anomalies + +Issues in the current Angular app discovered during Phase 0, noted here so the Phase 2+ parity gates know to *improve* on them rather than replicate them: + +- (Populate during Task 9 / Task 12 / Task 13 reviews) +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/superpowers/phase-0/README.md +git commit -m "Add Phase 0 deliverables README + +Indexes URL corpus, SEO/hreflang/VRT baselines, and inventory documents +for Phase 1+ consumers. Documents regeneration commands and the exit +gate checklist." +``` + +--- + +## Task 16: Phase 0 exit gate — verify everything and close out + +Final pass that confirms Phase 0 is complete and produces nothing new — just asserts the state is correct. + +**Files:** none (verification only) + +- [ ] **Step 1: Count URL corpus entries** + +```bash +for f in tests/fixtures/phase-0/url-corpus/*.json; do + echo -n "$f: " + node -e "console.log(require('./$f').count)" +done +``` + +Expected: all 4 files report non-zero counts. + +- [ ] **Step 2: Count SEO baselines** + +```bash +ls tests/fixtures/phase-0/seo-baselines/*.json 2>/dev/null | wc -l +``` + +Expected: at least 20 (ideally 26: 13 route shapes × 2 languages). + +- [ ] **Step 3: Count VRT baselines** + +```bash +ls tests/fixtures/phase-0/vrt-baselines/*.png 2>/dev/null | wc -l +``` + +Expected: `60`. + +- [ ] **Step 4: Confirm inventory docs exist and are non-empty** + +```bash +wc -l docs/superpowers/phase-0/primeng-backlog.md docs/superpowers/phase-0/scss-theme-manifest.md docs/superpowers/phase-0/translation-keys-used.md +``` + +Expected: all three files have > 10 lines. + +- [ ] **Step 5: Run the full unit-test suite one more time** + +```bash +pnpm test +``` + +Expected: all Phase 0 unit tests pass (`angular-routes`, `http`, `seo-extractor`). + +- [ ] **Step 6: Confirm customer checklist exists (answers pending is OK for this step — blocking Phase 1 but not Phase 0 closure)** + +```bash +test -f docs/superpowers/phase-0/customer-confirmation-checklist.md && echo "present" +``` + +Expected: `present` + +- [ ] **Step 7: Tag the Phase 0 closure commit** + +```bash +git tag -a phase-0-complete -m "Phase 0 (Preflight) deliverables captured and committed" +git push origin phase-0-complete +``` + +Expected: annotated tag created on origin. + +- [ ] **Step 8: Open a tracking issue (if using GitHub/GitLab)** + +Copy the customer confirmation checklist into a tracking issue titled "Phase 0 → Phase 1 blockers: customer answers needed" with the file's contents. Assign to the customer point-of-contact. Phase 1A does not start until that issue is closed with signed answers. + +--- + +## Self-review + +**Spec coverage against the design spec §9.2 Phase 0:** + +- [x] URL corpus from access logs — Tasks 5, 6 (supports both observed + enumerated modes) +- [x] JSON-LD / OG / hreflang baselines on representative routes — Tasks 8, 9 +- [x] VRT baselines of Angular prod — Task 10 +- [x] PrimeNG component inventory → `src/ui/primitives/` backlog — Task 11 +- [x] SCSS token + theme inventory → `src/ui/styles/` port list — Task 12 +- [x] `@ngx-translate` keys actually in use → translation-file port manifest — Task 13 +- [x] Customer confirmation of spec assumptions (§2.5, §8.2, §8.5, §7.2) — Task 14 +- [x] Phase 0 deliverables committed as fixtures + indexed — Tasks 15, 16 + +**Placeholder scan.** Searched for TBD/TODO/placeholder patterns in the task bodies above. Found one: the PrimeNG backlog template has a "(TBD — see §5.3 of the design spec)" column in the generated markdown. This is acceptable — it's a column in *generated output* that Phase 1E engineers fill in when they claim a component; it is not a gap in the plan itself. + +**Type consistency.** `RouteShape`, `RouteFeature`, `LANGUAGES`, `VrtRoute`, `ExtractedSeo`, `FetchWithRetryOptions`, `InventoryRow`, `Sighting` — all defined in exactly one place, referenced consistently across tasks. No Task-N-to-Task-M drift. + +**Scope check.** Phase 0 is appropriately sized for one plan file (~16 tasks over ~1 week of work). No decomposition needed. Phase 1 is a separate plan (see next document). diff --git a/docs/superpowers/plans/2026-04-14-phase-1-foundation-master.md b/docs/superpowers/plans/2026-04-14-phase-1-foundation-master.md new file mode 100644 index 00000000..8c88da9d --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-1-foundation-master.md @@ -0,0 +1,975 @@ +# Phase 1 — Foundation MASTER Plan + +> **This document is a plan INDEX, not an executable plan.** It lists the Phase 1 sub-plans, their dependency order, the contracts each sub-plan exports for downstream sub-plans to consume, and the shared files that cross sub-plan boundaries. +> +> **Do not execute this document directly.** Each sub-plan is a separate file under `docs/superpowers/plans/` with its own TDD-granular tasks. They are written on demand by re-invoking the `superpowers:writing-plans` skill with a sub-plan-specific prompt. + +**Goal of Phase 1:** Build the complete Modern.js + Module Federation 2.0 foundation on which all four feature migrations (Phases 2–5) will be implemented. Nothing in Phase 1 ships to production users — the output is a working dual-build artifact deployed to the `testing` environment with all observability, security, and CI pipelines live. + +**Phase 1 exit gate** (must pass before Phase 2 starts): + +- Both build targets (standalone SSR + MF 2.0 remote) produce valid artifacts in CI. +- `mf-manifest.json` is served from the `testing` environment and consumable by a test host. +- The smoke route (`/ru/smoke`) renders via SSR in `testing` with all observability pipelines (logger, metrics, analytics) emitting correctly, verified by inspecting the log / metrics / analytics capture endpoints. +- All Phase 1 CI gates pass on `main`: lint, typecheck, unit (70%+ coverage on `src/features/` + `src/shared/` + `src/ui/` + `src/observability/`), bundle size, security scan. +- Security hardening live: CSP with per-request nonce (including the stream-transform nonce injection workaround for React issue #24883), HTTP security headers, dependency scanning green. +- Canary deploy pipeline functional (smoke route deployed via canary path with auto-rollback on health-check failure). +- Operational runbook published in `docs/superpowers/phase-1/runbook.md`. +- Responsive baseline assertions passing on root layout + error pages at 320 / 768 / 1280 / 1920 widths. + +**Reference spec:** `docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md`. Phase 1 implements sections §1–§8 of the spec (everything except the feature ports in §9.2 Phase 2+). + +**Phase 0 prerequisites — split gate.** Phase 0 produces a customer-confirmation checklist answering assumptions A1–A9. Phase 1 splits the gate: + +- **Hard blockers** (1A-1 does not start until all resolved): **A2** (CDN vendor), **A3** (CI provider), **A5** (ASP.NET host fate), **A6** (metrics endpoint), **A8** (prod URL / access logs), **A9** (Node 24 available on customer deploy VMs — new). +- **Stub-allowed** (Phase 1 ships stubs, swap task pending customer response): **A1** (module template), **A4** (log format), **A7** (analytics vendor credentials). + +--- + +## Sub-plan inventory + +| ID | Sub-plan | Spec section | Estimated size | File | +|---|---|---|---|---| +| **1A-1** | Project skeleton (src tree, tsconfig, eslint base, env, package.json, zod) | §1.3, §2.1 | Medium | `2026-04-14-phase-1a1-skeleton.md` (TBW) | +| **1A-2** | MF 2.0 + dual build targets + RemoteLoader + MF spike | §2.1, §2.2, §2.3, §2.4, §2.5 | Medium | `2026-04-14-phase-1a2-mf-builds.md` (TBW) | +| **1A-3** | ESLint boundaries + layered dependency rules | §1.2 | Small | `2026-04-14-phase-1a3-eslint-boundaries.md` (TBW) | +| **1B** | CI pipeline | §8.5 | Medium | `2026-04-14-phase-1b-ci.md` (TBW) | +| **1C** | i18n runtime + locale port | §6.1–§6.4 | Medium | `2026-04-14-phase-1c-i18n.md` (TBW) | +| **1D** | API client + caches + circuit breaker | §4.1, §4.2 | Medium | `2026-04-14-phase-1d-api-client.md` (TBW) | +| **1E** | SignalR wrapper + `useLiveFlights` hook | §4.4 | Medium | `2026-04-14-phase-1e-signalr.md` (TBW) | +| **1F-layout** | Root layout + locale layout + error routes + smoke route + ErrorBoundary + error→HTTP mapper | §1.3, §3.1, §3.3 | Medium | `2026-04-14-phase-1f-layout.md` (TBW) | +| **1F-seo** | SeoHead + hreflang builder + JsonLdRenderer | §3.6, §6.5, §6.6, §6.7, §6.8 | Small | `2026-04-14-phase-1f-seo.md` (TBW) | +| **1G-logger** | Logger types + JSON-lines transport + console transport + provider | §7.1, §7.2 | Medium | `2026-04-14-phase-1g-logger.md` (TBW) | +| **1G-metrics** | OpenTelemetry init (server/browser) + custom metric instruments | §7.3, §7.6, §7.7 | Medium | `2026-04-14-phase-1g-metrics.md` (TBW) | +| **1G-analytics** | Analytics facade + four stub adapters (Yandex.Metrica, CTM, Variocube, Dynatrace) | §7.4 | Small | `2026-04-14-phase-1g-analytics.md` (TBW) | +| **1H** | Security hardening (CSP + nonce stream transform + headers + storage) | §8.1 | Small | `2026-04-14-phase-1h-security.md` (TBW) | +| **1I** | Deploy pipeline + health + graceful shutdown + runbook | §8.3, §8.5 | Medium | `2026-04-14-phase-1i-deploy.md` (TBW) | + +Sizes: **Small** ≈ 5–10 tasks, **Medium** ≈ 10–20 tasks. (No "Large" sub-plans after the 1A/1F/1G splits.) + +Parity harnesses (URL / SEO / VRT) and real parity tests are **deferred to Phase 2**, to be designed against the first real feature migration rather than against a synthetic smoke route. + +--- + +## Dependency graph + +``` + ┌─────────────────┐ + │ 1A-1 Skeleton │◄── every sub-plan depends on 1A-1 + └────────┬────────┘ + ▼ + ┌─────────────────┐ + │ 1A-2 MF 2.0 │ + │ + builds + MF │ + │ spike first │ + └────────┬────────┘ + ▼ + ┌─────────────────┐ + │ 1A-3 ESLint │ + │ boundaries │ + └────────┬────────┘ + ┌──────────────────────────┼──────────────────┬──────────────────┐ + ▼ ▼ ▼ ▼ +┌──────┐ ┌────────┐ ┌───────────┐ ┌──────────┐ ┌──────────────┐ ┌─────────┐ +│ 1B │ │ 1C i18n │ │ 1D API │ │ 1E Sig- │ │ 1G-logger │ │ 1F-seo │ +│ CI │ │ │ │ client │ │ nalR │ │ (type-only │ │ (pure │ +│ │ │ │ │ │ │ │ │ file first) │ │ funcs) │ +└──┬───┘ └────┬───┘ └─────┬────┘ └──────────┘ └──────┬───────┘ └────┬────┘ + │ │ │ │ │ + │ │ │ ┌─────────────────────┘ │ + │ │ │ ▼ │ + │ │ │ ┌──────────────┐ │ + │ │ │ │ 1G-metrics │ │ + │ │ │ │ (depends on │ │ + │ │ │ │ 1G-logger) │ │ + │ │ │ └──────┬───────┘ │ + │ │ │ │ │ + │ │ │ ▼ │ + │ │ │ ┌──────────────┐ │ + │ │ │ │ 1G-analytics │ │ + │ │ │ │ (depends on │ │ + │ │ │ │ 1G-logger) │ │ + │ │ │ └──────┬───────┘ │ + │ │ │ │ │ + │ └────────────┴─────────┴────────────────────────────────────┤ + │ ▼ │ + │ ┌──────────────────────────────┐ │ + │ │ 1F-layout (root layout + │◄─────────────────┘ + │ │ error routes + smoke route) │ + │ │ (consumes 1C + 1D + │ + │ │ 1G-logger/metrics/analytics │ + │ │ + 1F-seo) │ + │ └──────┬───────────────────────┘ + │ ▼ + │ ┌──────────────────────────────┐ + │ │ 1H Security hardening │ + │ │ (middleware + nonce stream │ + │ │ transform into 1F-layout) │ + │ └──────┬───────────────────────┘ + │ │ + ▼ ▼ + ┌───────────────────────────────────────┐ + │ 1I Deploy pipeline + runbook │ + │ (consumes 1A-2 + 1B + 1H) │ + └───────────────────────────────────────┘ +``` + +### Execution order + +**Serial (1 engineer):** 1A-1 → 1A-2 → 1A-3 → 1C → 1F-seo → 1G-logger → 1D → 1G-metrics → 1G-analytics → 1F-layout → 1H → 1I → 1B → 1E. + +Rationale: +- 1A-1/2/3 unlock everything. +- 1C is cheap and 1F-layout depends on it. +- 1F-seo is pure functions, no runtime deps on 1C/1D/1G — slot it in early. +- 1G-logger's type-only file (`Logger`, `LogFields`, `LogLevel`) must ship early since 1A-1's `HostContract` depends on it. (See "Logger type extraction" below.) +- 1D depends on 1G-logger's types (for request-scoped child loggers). +- 1G-metrics and 1G-analytics depend on 1G-logger. +- 1F-layout consumes 1C + 1D + all three 1G sub-plans + 1F-seo — it's the integration point. +- 1H modifies files 1F-layout creates, so it follows. +- 1I consumes 1A-2 + 1B + 1H. +- 1B can be slotted earlier if CI-enforced gates become blocking; the order above assumes PR-quality gates are enough until 1I. +- 1E can come last because its only consumer is Phase 2 Online Board. + +**Parallel (2+ engineers):** After 1A-3 ships, the following can proceed in parallel: **1B, 1C, 1D, 1E, 1F-seo, 1G-logger** (and its dependents 1G-metrics/1G-analytics). 1F-layout and later remain sequential because of shared-file constraints. + +### Critical path + +**1A-1 → 1A-2 → 1A-3 → 1C → 1F-layout → 1H → 1I → exit gate** is the critical path for one engineer. 1G-logger / 1G-metrics / 1G-analytics / 1D / 1F-seo all feed 1F-layout but sit off the critical path once 1C ships (they run in parallel with 1C if there's more than one engineer, or in series but interleaved if there's only one). + +--- + +## Logger type extraction (cross-cutting) + +`HostContract` in 1A-1 has an optional `logger?: Logger` field (design spec §2.4). `Logger` is defined in 1G-logger. To avoid a plan-dependency cycle: + +- **1G-logger's first task** ships `src/observability/logger/types.ts` containing **only the type definitions** (`Logger`, `LogFields`, `LogLevel`, `LogRecord`, `LogTransport`). No runtime code, no transports. +- **1A-1** imports `Logger` from `@/observability/logger/types` in its `HostContract` definition. +- Runtime logger implementation (transports, provider, factories) lands later in 1G-logger and does not retroactively affect 1A-1. + +This "type-only file first" pattern is the canonical workaround for plan-order cycles in this master plan; any other sub-plan hitting a similar cycle follows the same pattern. + +--- + +## Contracts — what each sub-plan exports + +This is the section that lets sub-plans be written and reviewed independently. Every sub-plan must produce its contracts without breaking changes once another sub-plan depends on them. Contracts are enforced via TypeScript types — any change to an exported type is a cross-sub-plan review gate. + +### 1A-1 — Project skeleton contracts + +**Exports:** + +- **Project layout** — the `src/` directory tree from design spec §1.3. Every other sub-plan adds files inside this tree. No sub-plan is allowed to create top-level directories outside `src/` / `tests/` / `scripts/` / `docs/` without explicit call-out. +- **`tsconfig.json`** — strict mode, path aliases (`@/` → `src/`, `@phase0/` → `scripts/phase-0/`), `noUncheckedIndexedAccess`, `isolatedModules`. +- **`.eslintrc.cjs` base** — the baseline config. 1A-3 adds the boundaries rules on top. +- **`package.json` scripts** — `dev`, `build:standalone`, `build:remote`, `build:both`, `test`, `test:coverage`, `lint`, `typecheck`. Pinned **Node 24** via `engines` and `.nvmrc`. +- **`package.json` dependencies owned by 1A-1** — `zod` (for env validation and `storage.ts` schema validation — used by both 1A-1 `src/env/` and 1H `src/shared/storage.ts`, so it lives in the common base). +- **`src/env/index.ts`** — runtime env-var reader returning a Zod-validated typed `Env` object. Other sub-plans read env vars exclusively through this module. +- **`src/host-contract.ts`** — the `HostContract` type, reproduced byte-for-byte from design spec §2.4: + + ```ts + import type { Logger } from "@/observability/logger/types"; + + export interface HostContract { + locale: string; // "ru", "en", ... + canonicalOrigin: string; // "https://flights.aeroflot.ru" + navigate?: (path: string) => void; // optional deep-link nav override + consent?: { analytics: boolean; telemetry: boolean }; // optional, else assumed true + logger?: Logger; // optional host logger merge + } + ``` + +- **Empty feature and UI barrel files** — `src/features/{online-board,schedule,flights-map,popular-requests}/index.ts` and `src/ui/index.ts` exist but export nothing. Phase 2+ populates them. **Exit-gate rule: the public barrel surface is frozen — no other sub-plan creates new cross-boundary imports outside these barrels.** + +**TypeScript contracts:** + +```ts +// src/env/index.ts +export interface Env { + NODE_ENV: "development" | "testing" | "staging" | "production"; + BUILD_TARGET: "standalone" | "remote"; + PROD_ORIGIN: string; + API_BASE_URL: string; + SIGNALR_HUB_URL: string; + OTEL_EXPORTER_OTLP_ENDPOINT?: string; + OTEL_EXPORTER_OTLP_HEADERS?: string; + LOGS_ENDPOINT?: string; + ANALYTICS_ENABLED: AnalyticsProviders; // imported from src/observability/analytics/types + VERSION: string; // git sha, injected at build time +} +export function getEnv(): Env; +``` + +**Exit gate for 1A-1:** +- `pnpm typecheck` and `pnpm lint` green on an empty src tree plus the env module. +- `src/env/index.ts` round-trips a test `Env` object through Zod validation and surfaces a readable error on malformed input. +- **Frozen barrel rule** documented in 1A-1's deliverables — subsequent sub-plans may add exports *to* these barrels but may not create parallel public surfaces. +- **Rename-pass rework task** attached to this sub-plan's exit gate: if A1 (customer module template) resolves after 1A-1 ships, a bounded rename pass moves the src tree to match the template without rewriting internals. The frozen barrel rule ensures this rename is a mechanical operation. + +--- + +### 1A-2 — MF 2.0 + dual build targets + RemoteLoader contracts + +**First task (gate):** **Modern.js + MF 2.0 spike.** A 2–4 hour timeboxed experiment that boots the simplest possible Modern.js + Rspack + `@module-federation/modern-js` end-to-end example (a single exposed hello-world component), documents gotchas in `docs/superpowers/phase-1/modernjs-mf-spike.md`, and produces a **pinned version matrix** (Modern.js X.Y.Z + `@module-federation/modern-js` A.B.C + Rspack P.Q.R). If the spike fails or reveals a hard blocker, 1A-2 halts and the issue escalates to the customer before committing to Modern.js. + +**Exports:** + +- **`modern.config.ts`** — the Modern.js build config with `BUILD_TARGET=standalone|remote` branching. Later sub-plans (1G-metrics, 1H, 1I) modify this file via explicit "modify" tasks — 1A-2 owns the base structure. +- **Dual build targets** — `pnpm build:standalone` produces `dist/standalone/` (Node server + client bundle); `pnpm build:remote` produces `dist/remote/` (static chunks + `mf-manifest.json`). +- **`src/mf/remote-loader.ts`** — Module Federation 2.0 runtime API wrapper so Phase 2+ can consume other customer remotes without touching `modern.config.ts` again: + + ```ts + export interface RemoteModuleRef { + name: string; // remote name (e.g. "customer-ui") + module: string; // exposed module path (e.g. "./Header") + } + + export function loadRemoteModule(ref: RemoteModuleRef): Promise; + export function registerRemote(entry: { name: string; entry: string }): void; + ``` + +- **`src/mf/host-entry.ts`** — the entry point for the remote build target that consumes `HostContract` and bootstraps the React subtree into a host-provided mount point. + +**TypeScript contracts:** `RemoteModuleRef`, `loadRemoteModule`, `registerRemote` (above). `HostContract` is imported from 1A-1 (`src/host-contract.ts`). + +**Exit gate for 1A-2:** +- Spike doc committed with pinned version matrix. +- `pnpm build:both` produces `dist/standalone/` (Node server + client bundle) and `dist/remote/` (static chunks + `mf-manifest.json`) with zero type errors. +- A minimal integration test loads a test remote via `loadRemoteModule` and asserts the returned module. + +--- + +### 1A-3 — ESLint boundaries contracts + +**Exports:** + +- **`.eslintrc.cjs` additions** — `eslint-plugin-boundaries` rules enforcing the layered dependency direction from design spec §1.2: + - `features/` cannot import `routes/` or `mf/` + - `ui/` cannot import `features/` + - `shared/` cannot import `features/`, `routes/`, `mf/`, `observability/` + - `observability/` cannot import `features/`, `routes/`, `mf/` +- **`no-restricted-imports` rules:** + - `@opentelemetry/sdk-metrics` — allowed only in `src/observability/metrics/otel.ts` (keeps module-level instrument exports safe by forcing meter acquisition through `@opentelemetry/api`'s proxy meter). + - `window.localStorage` / `window.sessionStorage` — allowed only in `src/shared/storage.ts`. + - `@microsoft/signalr` — forbidden in any file that's part of the SSR bundle (enforced via file-path pattern). + - `react-i18next` — forbidden outside `src/i18n/provider.tsx` (feature code goes through the re-export). + +**Exit gate for 1A-3:** Each rule has a fabricated violation test in `tests/eslint/` that asserts the rule fires. + +--- + +### 1B — CI pipeline contracts + +**Exports:** + +- **`.github/workflows/ci.yml`** (or equivalent for the CI provider chosen via A3). Runs on every PR and every push to `main`: install, lint, typecheck, unit tests, build both targets, bundle-size gate, security scan. +- **`.github/workflows/nightly.yml`** — nightly-only: contract tests, load test (stub until Phase 2), Lighthouse CI. +- **`scripts/ci/bundle-size-gate.ts`** — reads the Rspack build stats and compares against budgets in `docs/superpowers/phase-1/bundle-budgets.json`. Fails the build if any budget is exceeded. +- **`scripts/ci/check-coverage-delta.ts`** — reads Vitest coverage JSON and the prior-commit's coverage JSON (from the base branch), fails if coverage decreases. Uses `git show :coverage-summary.json` to read the baseline; tolerates missing baseline (first run). + +**TypeScript contracts:** none exported (scripts are CI-internal). + +**Exit gate for 1B:** A PR with a trivially-broken test fails CI; a PR with a green test passes CI in under 20 minutes. Bundle-size gate flags a fabricated regression. + +--- + +### 1C — i18n runtime contracts + +**Exports:** + +- **`src/i18n/config.ts`** — factory that creates a request-scoped `i18next` instance configured with `i18next-icu`, loaded with a single locale's bundle: + + ```ts + export function createI18nInstance(options: { + locale: Language; + initialResources?: Record>; + }): Promise; + ``` + +- **`src/i18n/resolver.ts`** — locale resolution from URL prefix: + + ```ts + export type Language = "ru"|"en"|"es"|"fr"|"it"|"ja"|"ko"|"zh"|"de"; + export const LANGUAGES: readonly Language[]; + export function isLanguage(x: string): x is Language; + export function resolveLocaleFromPath(pathname: string): Language | null; + export function stripLocaleFromPath(pathname: string): { locale: Language; rest: string } | null; + ``` + +- **`src/i18n/locales/{lang}/common.json`** — 9 files, ported from `ClientApp/src/assets/i18n/*.json` using the Phase 0 translation-key inventory to drop dead keys (optional; by default, all keys port). ICU MessageFormat syntax preserved byte-for-byte. +- **`src/i18n/serializer.ts`** — helpers to serialize the loaded locale bundle into the SSR HTML payload under `window.__I18N__` and rehydrate it on the client: + + ```ts + export function serializeI18nForHydration(i18n: i18n): string; // emits a JSON string + export function hydrateI18nFromWindow(): Promise; // reads window.__I18N__ + ``` + +- **`src/i18n/provider.tsx`** — React Context provider + `` component + `useI18n()` accessor. **Re-exports `useTranslation` from `react-i18next`** so feature code never imports `react-i18next` directly (enforced by 1A-3's ESLint rule). + +**Exit gate for 1C:** Vitest test renders a component with `` loaded with `ru` and asserts `t("common.someKey")` returns the Russian value. SSR + hydration roundtrip test using `renderToString` verifies no client-side re-fetch or flash. + +--- + +### 1D — API client contracts + +**Exports:** + +- **`src/shared/api/client.ts`** — the `ApiClient` class: + + ```ts + export interface ApiClientRetryOptions { + maxRetries?: number; // default 3 (idempotent only) + timeoutFactor?: number; // default 2 (exponential backoff) + statusCodes?: number[]; // default [408, 429, 500, 502, 503, 504] + } + + export interface ApiClientOptions { + baseUrl: string; + locale: Language; + traceId?: string; + fetchImpl?: typeof fetch; // for tests (ignored on server path where undici is used) + defaultTimeoutMs?: number; // default 5000 + retry?: ApiClientRetryOptions; + logger?: Logger; + } + + export class ApiClient { + constructor(options: ApiClientOptions); + get(path: string, query?: Record): Promise; + post(path: string, body: unknown): Promise; + } + ``` + + **Server path** uses `undici.RetryAgent` under the hood (statusCodes + timeoutFactor wired into the agent). **Client path** uses `globalThis.fetch` with a thin hand-rolled retry wrapper that applies the same config. + +- **`src/shared/api/errors.ts`** — typed error classes: + + ```ts + export class ApiError extends Error { constructor(message: string); } + export class ApiHttpError extends ApiError { status: number; body?: unknown; } + export class ApiTimeoutError extends ApiError { timeoutMs: number; } + export class ApiNetworkError extends ApiError { cause?: Error; } + ``` + +- **`src/shared/api/cache.ts`** — three distinct cache types: + + ```ts + // (1) SSR request-scoped dedup: one-shot, discarded after response + export class RequestScopedCache { + get(key: string): Promise | undefined; + set(key: string, promise: Promise): void; + } + + // (2) Client-side per-tab in-memory TTL cache (count-capped) + export class ClientMemoryCache { + constructor(options: { max: number; defaultTtlMs: number }); + get(key: string): T | undefined; + set(key: string, value: T, ttlMs?: number): void; + delete(key: string): void; + clear(): void; + size: number; + } + + // (3) Shared per-VM LRU cache with BYTE cap (~100MB), backed by lru-cache@^10 + export class ServerLruCache { + constructor(options: { + maxBytes: number; // e.g. 100 * 1024 * 1024 + defaultTtlMs: number; + sizeCalculation?: (value: T, key: string) => number; // default: JSON.stringify length + }); + get(key: string): T | undefined; + set(key: string, value: T, ttlMs?: number): void; + delete(key: string): void; + clear(): void; + calculatedSize: number; // current bytes used + } + + // Key convention used by all three caches + export function cacheKey(endpoint: string, query: Record, locale: Language): string; + ``` + + Default TTLs from design spec §4.2: **30s for live data, 5 min for static reference data** (wired via call-site overrides). + +- **`src/shared/api/circuit-breaker.ts`**: + + ```ts + export interface CircuitBreakerOptions { + failureThreshold?: number; // default 5 + openDurationMs?: number; // default 30_000 + } + + export class CircuitBreaker { + constructor(options?: CircuitBreakerOptions); + exec(fn: () => Promise): Promise; + reset(): void; + state: "closed" | "open" | "half-open"; + } + ``` + +- **`src/shared/api/cached-client.ts`** — **caching decorator** layered above `ApiClient` (not inside it): + + ```ts + export interface CachedClientOptions { + client: ApiClient; + requestScoped?: RequestScopedCache; // SSR only + clientMemory?: ClientMemoryCache; + serverLru?: ServerLruCache; + ttlMs?: number; + } + + export class CachedApiClient { + constructor(options: CachedClientOptions); + get(path: string, query?: Record): Promise; + } + ``` + + Feature code opts into caching by wrapping `ApiClient` in `CachedApiClient`; uncached calls go through the raw `ApiClient`. + +- **`src/shared/api/provider.tsx`** — React Context provider + `useApiClient()` hook. SSR-aware: on the server, the client is constructed per-request with the resolved locale; on the client, a single instance is shared across the tab. + +**Dependency on 1G-logger:** `ApiClientOptions.logger?: Logger` consumes the type-only import from `src/observability/logger/types`. 1D must not ship before 1G-logger's type-only file. + +**Package additions (1D):** `undici` (explicit dep, even though Node 24 includes it — pin for deterministic behavior), `lru-cache@^10`. + +**Exit gate for 1D:** Vitest tests cover: success response deserialization; retry on `[408, 429, 500, 502, 503, 504]`; no retry on other 4xx; timeout; `Retry-After` honored on 429/503; circuit breaker open/half-open/closed transitions; request-scoped cache dedup; client-memory TTL eviction; server LRU byte cap eviction under load. + +--- + +### 1E — SignalR wrapper contracts + +**Exports:** + +- **`src/shared/signalr/connection.ts`** — the reference-counted connection wrapper: + + ```ts + export interface HubOptions { + hubUrl: string; + reconnectDelaysMs?: number[]; // default [0, 2000, 10000, 30000] + gracePeriodMs?: number; // default 5000 + } + + export class SignalRConnection { + constructor(options: HubOptions); + subscribe(channel: string, handler: (message: unknown) => void): () => void; + onStatusChange(handler: (status: ConnectionStatus) => void): () => void; + get status(): ConnectionStatus; + } + + export type ConnectionStatus = "idle" | "connecting" | "live" | "reconnecting" | "offline"; + + export function getSharedConnection(options: HubOptions): SignalRConnection; + ``` + +- **`src/shared/hooks/useLiveFlights.ts`** — **generic** live-data hook (not hardcoded to flight-search params): + + ```ts + export function useLiveFlights( + params: TParams, + initialData: TData[], + config: { + hubUrl: string; + channelKey: (params: TParams) => string; + }, + ): { data: TData[]; connectionStatus: ConnectionStatus }; + ``` + + SSR-safe: during SSR, returns `{ data: initialData, connectionStatus: "idle" }` without importing `@microsoft/signalr` (enforced by 1A-3's ESLint SSR-bundle guard). + +**Exit gate for 1E:** +- Two rapid `useEffect` mounts (Strict Mode double-invoke simulation) result in exactly one `HubConnection.start()` call. +- Unmount + remount within the grace period reuses the connection; unmount + remount after the grace period creates a fresh one. +- SSR render path does not import `@microsoft/signalr` (asserted by inspecting the SSR bundle stats for the absence of the package). + +--- + +### 1F-layout — Root layout + routes + error mapper contracts + +**Exports:** + +- **`src/routes/layout.tsx`** — root HTML shell: ``, ``, ``, ``, root ``, root ``, root ``, root `` (from 1G-analytics). Wrapped by `` (from 1G-logger). + +- **`src/routes/[lang]/layout.tsx`** — locale-scoped layout: validates `params.lang`, creates the request-scoped i18next instance (via 1C's `createI18nInstance`), builds the canonical URL + hreflang set (via 1F-seo's `buildHreflangSet`), passes them into ``. Creates a request-scoped logger child and an OTel span (from 1G-metrics). + +- **`src/routes/error/[code]/page.tsx`** — error page rendered for `code ∈ {404, 500, 503}`. Ports the existing Angular error component layout. Fully responsive (assertions below). + +- **`src/routes/[lang]/smoke/page.tsx`** — smoke route that exercises every foundation subsystem: emits a log at `info`, emits a metric counter, calls `track("smoke.pageview")`, renders `{t("smoke.heading")}`, fetches a dummy API endpoint via `ApiClient.get`, renders `` with canonical + hreflang. Visible in `testing` env at `/ru/smoke` and `/en/smoke`. Uses `React.lazy()` + `` + a server loader as a deliberate stress test of the React 18 concurrent + streaming path. + +- **`src/routes/error/map.ts`** — error-to-HTTP mapper consumed by the SSR loader path: + + ```ts + export interface ErrorResponse { + status: 404 | 500 | 503; + headers?: Record; + errorCode: "not_found" | "internal" | "unavailable"; + } + + export function errorToResponse(error: unknown): ErrorResponse; + ``` + + Mapping rules (design spec §4.6): + - `ApiHttpError` with `status === 404` → `{ status: 404, errorCode: "not_found" }` + - `ApiHttpError` with `status` in 500–599 → `{ status: 500, errorCode: "internal" }` + - `ApiTimeoutError` → `{ status: 503, headers: { "Retry-After": "30" }, errorCode: "unavailable" }` + - Unknown → `{ status: 500, errorCode: "internal" }` + +- **`src/ui/errors/ErrorBoundary.tsx`** — React error boundary component. Logs the error via `useLogger()`, emits the `flights.react.error` metric, shows a fallback UI with a "Retry" button that resets the boundary's state. + +**Shared file ownership flag:** `src/routes/layout.tsx` is owned by 1F-layout. Sub-plans 1G-analytics (analytics loader mount), 1G-logger (provider wrap), and 1H (CSP nonce propagation + stream transform) modify this file via explicit tasks, referencing the 1F-layout-shipped version as the base. + +**Exit gate for 1F-layout:** +- `/ru/smoke` and `/en/smoke` render via SSR in the `testing` env. +- `` contains title, description, canonical, 9 hreflang alternates + `x-default`, OG tags, one JSON-LD block, one `` description (generated by 1F-seo). +- Missing `lang` URLs redirect 301 to `/ru/smoke`. +- 404 route returns HTTP 404 with the error page body. +- **Responsive baseline (R2):** Playwright renders `/ru/smoke` and `/ru/error/404` at widths **320, 768, 1280, 1920** — no horizontal scroll, all critical content visible, error page layout stable. Baselines committed under `tests/fixtures/phase-1/responsive/`. + +--- + +### 1F-seo — SeoHead + hreflang + JsonLdRenderer contracts + +**Exports:** + +- **`src/ui/seo/SeoHead.tsx`** — the `` component from design spec §6.5: + + ```ts + export interface SeoHeadProps { + title: string; + description: string; + canonical: string; + hreflang: Array<{ lang: Language | "x-default"; href: string }>; + og: { + title: string; description: string; url: string; + image: string; type: "website" | "article"; + locale: string; siteName: string; + }; + twitter?: { + card: "summary" | "summary_large_image"; + title?: string; description?: string; image?: string; + }; + jsonLd?: unknown | unknown[]; + noindex?: boolean; + } + export function SeoHead(props: SeoHeadProps): JSX.Element; + ``` + +- **`src/shared/seo/hreflang.ts`** — reusable reciprocal-hreflang builder: + + ```ts + export function buildHreflangSet(args: { + canonicalOrigin: string; + pathWithoutLocale: string; // e.g. "/onlineboard/flight/SU100-2025-01-15" + }): Array<{ lang: Language | "x-default"; href: string }>; + ``` + + Per design spec §1.4: `x-default` points to the Russian (`ru`) variant. + +- **`src/shared/seo/json-ld.tsx`** — `schema-dts`-typed JSON-LD renderer helper: + + ```ts + import type { Thing } from "schema-dts"; + + export interface JsonLdRendererProps { + data: Thing | Thing[]; + } + + export function JsonLdRenderer(props: JsonLdRendererProps): JSX.Element; + export function serializeJsonLd(data: Thing | Thing[]): string; + ``` + + Phase 2+ feature sub-plans ship typed builders (`buildFlightJsonLd`, `buildFlightSearchResultsJsonLd`, etc.) that consume `JsonLdRenderer` — no infrastructure work needed per feature. + +**Exit gate for 1F-seo:** Unit tests cover `buildHreflangSet` for the 9 languages + `x-default`, `SeoHead` emits the full `` shape, and `JsonLdRenderer` round-trips a typed `Thing` through `serializeJsonLd` → DOM string. + +--- + +### 1G-logger — Logger contracts + +**Sequencing:** The **first task** of 1G-logger is `src/observability/logger/types.ts` shipping **only type definitions**, so 1A-1's `HostContract` can import `Logger` without waiting on the runtime implementation. + +**Exports:** + +- **`src/observability/logger/types.ts`** (type-only, ships first): + + ```ts + export type LogLevel = "debug" | "info" | "warn" | "error"; + export type LogFields = Record; + + export interface Logger { + debug(msg: string, fields?: LogFields): void; + info(msg: string, fields?: LogFields): void; + warn(msg: string, fields?: LogFields): void; + error(msg: string, fields?: LogFields & { err?: Error }): void; + child(context: LogFields): Logger; + } + + export interface LogTransport { + write(record: LogRecord): void; + flush(): Promise; + } + + export interface LogRecord { + ts: string; level: LogLevel; msg: string; fields: LogFields; + } + ``` + +- **`src/observability/logger/json-lines-transport.ts`** — default `JsonLinesHttpTransport` with batching, backpressure drop, redaction, `sendBeacon` flush. +- **`src/observability/logger/console-transport.ts`** — dev-mode transport. +- **`src/observability/logger/root.ts`** — `createRootLogger()` factory that reads env config and picks a transport. +- **`src/observability/logger/provider.tsx`** — React context + `useLogger()` hook. Server: request-scoped child logger. Client: shared root logger. + +**A4-trigger task (from requirements gap R3):** When the customer provides the log format (A4 resolution), a follow-on task creates `src/observability/logger/customer-format-transport.ts` implementing the customer-specified format, and updates `createRootLogger()` to pick this transport by default. **No consumer code changes** — the `Logger` interface is stable. This task is attached to 1G-logger's exit gate and fires on A4 resolution (could be during Phase 1, or deferred to early Phase 2). + +**Exit gate for 1G-logger:** +- Type-only file ships first, verified by 1A-1 successfully importing `Logger`. +- Vitest tests cover: batching + flush; redaction of sensitive fields; transport backpressure drops old records; dev-mode console transport; `child()` context propagation. + +--- + +### 1G-metrics — OpenTelemetry + custom instruments contracts + +**Exports:** + +- **`src/observability/metrics/otel.ts`** — OpenTelemetry setup (the **only** file allowed to import from `@opentelemetry/sdk-metrics`, enforced by 1A-3): + + ```ts + export function initServerOtel(env: Env): void; // called once per Node process + export function initBrowserOtel(env: Env): void; // called once per tab + export function getMeter(name: string): Meter; // thin re-export of @opentelemetry/api + export function getTracer(name: string): Tracer; + ``` + +- **`src/observability/metrics/custom.ts`** — minimum-set custom metrics from design spec §7.3 as exported module-level instruments. **This pattern is safe** because the instruments are created against `@opentelemetry/api`'s proxy meter, which lazy-resolves to the real meter after `initServerOtel`/`initBrowserOtel` runs: + + ```ts + import { metrics } from "@opentelemetry/api"; + + const meter = metrics.getMeter("flights"); + + export const flightsSsrRequestDuration = meter.createHistogram("flights.ssr.request.duration"); + export const flightsApiRequestDuration = meter.createHistogram("flights.api.request.duration"); + export const flightsApiError = meter.createCounter("flights.api.error"); + export const flightsSignalRConnected = meter.createUpDownCounter("flights.signalr.connected"); + export const flightsSignalRMessageReceived = meter.createCounter("flights.signalr.message.received"); + export const flightsSignalRDisconnect = meter.createCounter("flights.signalr.disconnect"); + export const flightsFeatureRender = meter.createCounter("flights.feature.render"); + export const flightsReactError = meter.createCounter("flights.react.error"); + // web-vitals histograms created at init time inside initBrowserOtel, not exported statically + ``` + +**Shared file ownership flag:** `modern.config.ts` (owned by 1A-2) — 1G-metrics modifies it to wire OTel SDK init + request tracing plugin into the Modern.js middleware chain. + +**Exit gate for 1G-metrics:** +- Integration test: `initServerOtel` runs, a counter is incremented, and the test reader observes the recorded value (proves the proxy meter resolved correctly). +- ESLint rule from 1A-3 blocks a fabricated `import { MeterProvider } from "@opentelemetry/sdk-metrics"` in a file outside `otel.ts`. + +--- + +### 1G-analytics — Analytics facade contracts + +**Exports:** + +- **`src/observability/analytics/types.ts`**: + + ```ts + export interface AnalyticsProviders { + metrica: boolean; + ctm: boolean; + variocube: boolean; + dynatrace: boolean; + } + + export interface AnalyticsProps { [k: string]: unknown; } + + export interface AnalyticsEvent { + kind: "track" | "page"; + name: string; // event name or page URL + props: AnalyticsProps; + provider: string; // "metrica" | "ctm" | "variocube" | "dynatrace" + ts: string; + } + + export interface Analytics { + track(event: string, props?: AnalyticsProps): void; + page(url: string, props?: AnalyticsProps): void; + } + ``` + +- **`src/observability/analytics/facade.ts`**: + + ```ts + export function createAnalytics(options: { + enabled: AnalyticsProviders; + consent: { analytics: boolean; telemetry: boolean }; + logger: Logger; + }): Analytics; + ``` + +- **`src/observability/analytics/adapters/{metrica,ctm,variocube,dynatrace}.ts`** — four adapters implementing: + + ```ts + export interface AnalyticsAdapter { + name: string; + load(): Promise; + track(event: string, props?: AnalyticsProps): void; + page(url: string, props?: AnalyticsProps): void; + } + ``` + + **Phase 1 ships these as structured stubs** (R8): each stub's `load()`/`track()`/`page()` emits an `AnalyticsEvent` to a **test-observable sink** (`src/observability/analytics/sink.ts`) with the `provider` field set to the adapter's name. Real vendor scripts wire in Phase 2A (alongside the Online Board migration) after A7 resolves. + +- **`src/observability/analytics/sink.ts`** — test-observable event sink; exports `getRecordedEvents()` and `resetEvents()` for integration tests. In production, the sink is a no-op ring buffer. + +- **`src/observability/analytics/loader.tsx`** — `` component that mounts in the root layout (owned by 1F-layout, modified by 1G-analytics to add the mount). Waits for `requestIdleCallback`, then imports enabled adapters and calls `.load()`. + +- **`src/observability/analytics/provider.tsx`** — React context + `useAnalytics()` hook. Server: returns a `NoopAnalytics`. Client: returns the instance from ``. + +**Exit gate for 1G-analytics:** +- Integration test: smoke route calls `track("smoke.pageview")`, all four stub adapters emit exactly one `AnalyticsEvent` to the sink with matching props. +- `consent.analytics = false` short-circuits before any adapter is invoked. +- Adapter load failure emits `flights.analytics.load_failed` counter. + +--- + +### 1H — Security hardening contracts + +**Exports:** + +- **`src/server/middleware/csp.ts`** — Modern.js middleware that generates a per-request nonce, sets the `Content-Security-Policy` header (per design spec §8.1), and exposes the nonce to the React render tree via a request-scoped context. + + ```ts + export function cspMiddleware(options: { reportOnly?: boolean }): ModernMiddleware; + export const CspNonceContext: React.Context; // default ""; components reading client-side no-op on empty + ``` + +- **`src/server/middleware/nonce-stream-transform.ts`** — **workaround for React issue #24883.** React 18's `renderToPipeableStream({ nonce })` only applies the nonce to inline `bootstrapScriptContent`, **not to external `bootstrapScripts` src URLs.** This middleware post-processes the SSR HTML stream to inject `nonce="{nonce}"` on every ` to prevent injection", () => { + const data: Thing = { + "@type": "WebSite", + name: '', + }; + + const result = serializeJsonLd(data); + expect(result).not.toContain(""); + }); +}); + +describe("JsonLdRenderer", () => { + it("renders a "); + expect(html).toContain('"@context":"https://schema.org"'); + expect(html).toContain('"@type":"WebSite"'); + }); + + it("round-trips: serialize → DOM string contains valid JSON-LD", () => { + const data: Thing = { + "@type": "Organization", + name: "Aeroflot PJSC", + url: "https://www.aeroflot.ru", + }; + + const html = renderToStaticMarkup(createElement(JsonLdRenderer, { data })); + + // Extract JSON from the script tag + const match = html.match(/]*>([\s\S]*?)<\/script>/); + expect(match).not.toBeNull(); + + const json = match![1]!.replace(/\\u003c/g, "<"); + const parsed = JSON.parse(json); + expect(parsed["@context"]).toBe("https://schema.org"); + expect(parsed["@type"]).toBe("Organization"); + expect(parsed.name).toBe("Aeroflot PJSC"); + }); +}); +``` + +- [ ] **Step 2: Run — MUST FAIL** + +```bash +pnpm test src/shared/seo/json-ld +``` + +- [ ] **Step 3: Write implementation** + +Create `src/shared/seo/json-ld.tsx`: + +```tsx +import type { Thing } from "schema-dts"; + +export interface JsonLdRendererProps { + data: Thing | Thing[]; +} + +/** + * Serializes a schema-dts Thing (or array of Things) to a JSON-LD string. + * Adds "@context": "https://schema.org" to each item. + * Escapes sequences to prevent XSS. + */ +export function serializeJsonLd(data: Thing | Thing[]): string { + const withContext = Array.isArray(data) + ? data.map((item) => ({ "@context": "https://schema.org" as const, ...item })) + : { "@context": "https://schema.org" as const, ...data }; + + return JSON.stringify(withContext).replace(/<\//g, "\\u003c/"); +} + +/** + * Renders a ` escaping in `serializeJsonLd` → Task 3 + +**Exit gate alignment:** +- "buildHreflangSet covers 9 langs + x-default" — Task 2 tests +- "SeoHead emits the full shape" — Task 4 component (tested by 1F-layout integration) +- "JsonLdRenderer round-trips a typed Thing through serializeJsonLd → DOM string" — Task 3 tests + +**Type consistency.** `Language` from `@/i18n/resolver` (seeded in 1C). `Thing` from `schema-dts`. `SeoHeadProps` matches the master plan contract exactly. diff --git a/docs/superpowers/plans/2026-04-14-phase-1g-analytics.md b/docs/superpowers/plans/2026-04-14-phase-1g-analytics.md new file mode 100644 index 00000000..2d1e3d26 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-1g-analytics.md @@ -0,0 +1,600 @@ +# Phase 1G-analytics — Analytics Facade + Stub Adapters Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the analytics facade — a test-observable event sink, four stub adapters (Yandex.Metrica, CTM, Variocube, Dynatrace), a `createAnalytics()` factory that fans out `track`/`page` calls to enabled adapters with consent gating, plus the `` component and `useAnalytics()` hook — so that 1F-layout and all downstream features can emit analytics events with `analytics.track("search.submit", { query })`. + +**Architecture:** `types.ts` is already seeded (1A-1) with `AnalyticsProviders`, `AnalyticsProps`, `AnalyticsEvent`, `Analytics`, and `AnalyticsAdapter`. Each stub adapter emits `AnalyticsEvent` records to a shared sink (`sink.ts`) for test observability. `facade.ts` accepts enabled providers + consent flags and fans out to adapters. In production, the sink is a no-op ring buffer; in test, `getRecordedEvents()` / `resetEvents()` allow assertions. Real vendor scripts replace the stubs in Phase 2A after A7 resolves. + +**Tech Stack:** No new dependencies. Stubs use no vendor SDKs. + +**Prerequisites:** 1A-1 (skeleton + types.ts seeded), 1A-3 (ESLint boundaries), 1G-logger (Logger types for facade logging). + +--- + +## File structure + +| File | Responsibility | Task | +|---|---|---| +| `src/observability/analytics/sink.ts` | Test-observable event sink | 1 | +| `src/observability/analytics/sink.test.ts` | Tests | 1 | +| `src/observability/analytics/adapters/metrica.ts` | Yandex.Metrica stub adapter | 2 | +| `src/observability/analytics/adapters/ctm.ts` | CTM stub adapter | 2 | +| `src/observability/analytics/adapters/variocube.ts` | Variocube stub adapter | 2 | +| `src/observability/analytics/adapters/dynatrace.ts` | Dynatrace stub adapter | 2 | +| `src/observability/analytics/facade.ts` | `createAnalytics()` factory | 3 | +| `src/observability/analytics/facade.test.ts` | Tests | 3 | +| `src/observability/analytics/loader.tsx` | `` component | 4 | +| `src/observability/analytics/provider.tsx` | `useAnalytics()` hook | 5 | + +--- + +## Task 1 — TDD `sink.ts` + +**Files:** +- Create: `src/observability/analytics/sink.ts` +- Create: `src/observability/analytics/sink.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `src/observability/analytics/sink.test.ts`: + +```typescript +import { describe, expect, it, beforeEach } from "vitest"; +import { emitEvent, getRecordedEvents, resetEvents } from "./sink.js"; +import type { AnalyticsEvent } from "./types.js"; + +describe("analytics sink", () => { + beforeEach(() => { + resetEvents(); + }); + + it("records emitted events", () => { + const event: AnalyticsEvent = { + kind: "track", + name: "test.click", + props: { button: "cta" }, + provider: "metrica", + ts: new Date().toISOString(), + }; + + emitEvent(event); + expect(getRecordedEvents()).toHaveLength(1); + expect(getRecordedEvents()[0]).toEqual(event); + }); + + it("records multiple events in order", () => { + emitEvent({ kind: "track", name: "a", props: {}, provider: "ctm", ts: "t1" }); + emitEvent({ kind: "page", name: "/home", props: {}, provider: "dynatrace", ts: "t2" }); + + const events = getRecordedEvents(); + expect(events).toHaveLength(2); + expect(events[0]?.name).toBe("a"); + expect(events[1]?.name).toBe("/home"); + }); + + it("resetEvents clears all recorded events", () => { + emitEvent({ kind: "track", name: "x", props: {}, provider: "variocube", ts: "t" }); + expect(getRecordedEvents()).toHaveLength(1); + resetEvents(); + expect(getRecordedEvents()).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Run — MUST FAIL** + +```bash +pnpm test src/observability/analytics/sink +``` + +- [ ] **Step 3: Write implementation** + +Create `src/observability/analytics/sink.ts`: + +```typescript +import type { AnalyticsEvent } from "./types.js"; + +let events: AnalyticsEvent[] = []; + +/** + * Emit an analytics event to the test-observable sink. + * In production, this is a no-op ring buffer (capped to prevent memory leaks). + * In test, events are retained for assertion via getRecordedEvents(). + */ +export function emitEvent(event: AnalyticsEvent): void { + events.push(event); + + // Ring buffer: cap at 1000 events to prevent unbounded growth + if (events.length > 1000) { + events = events.slice(-500); + } +} + +/** Returns all recorded events (for test assertions). */ +export function getRecordedEvents(): readonly AnalyticsEvent[] { + return events; +} + +/** Clears all recorded events (for test teardown). */ +export function resetEvents(): void { + events = []; +} +``` + +- [ ] **Step 4: Run — ALL MUST PASS** + +```bash +pnpm test src/observability/analytics/sink +``` + +- [ ] **Step 5: Typecheck + lint, commit** + +```bash +pnpm typecheck && pnpm lint +git add src/observability/analytics/sink.ts src/observability/analytics/sink.test.ts +git commit -m "Add test-observable analytics event sink" +``` + +--- + +## Task 2 — Create 4 stub adapters (no TDD) + +**Files:** +- Create: `src/observability/analytics/adapters/metrica.ts` +- Create: `src/observability/analytics/adapters/ctm.ts` +- Create: `src/observability/analytics/adapters/variocube.ts` +- Create: `src/observability/analytics/adapters/dynatrace.ts` + +- [ ] **Step 1: Write all four adapters** + +Each adapter follows the same pattern. Create `src/observability/analytics/adapters/metrica.ts`: + +```typescript +import type { AnalyticsAdapter, AnalyticsProps } from "../types.js"; +import { emitEvent } from "../sink.js"; + +export class MetricaAdapter implements AnalyticsAdapter { + readonly name = "metrica"; + + async load(): Promise { + // Stub: real Yandex.Metrica script loads in Phase 2A (after A7 resolves) + } + + track(event: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() }); + } + + page(url: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() }); + } +} +``` + +Create `src/observability/analytics/adapters/ctm.ts`: + +```typescript +import type { AnalyticsAdapter, AnalyticsProps } from "../types.js"; +import { emitEvent } from "../sink.js"; + +export class CtmAdapter implements AnalyticsAdapter { + readonly name = "ctm"; + + async load(): Promise { + // Stub: real CTM script loads in Phase 2A + } + + track(event: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() }); + } + + page(url: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() }); + } +} +``` + +Create `src/observability/analytics/adapters/variocube.ts`: + +```typescript +import type { AnalyticsAdapter, AnalyticsProps } from "../types.js"; +import { emitEvent } from "../sink.js"; + +export class VariocubeAdapter implements AnalyticsAdapter { + readonly name = "variocube"; + + async load(): Promise { + // Stub: real Variocube script loads in Phase 2A + } + + track(event: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() }); + } + + page(url: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() }); + } +} +``` + +Create `src/observability/analytics/adapters/dynatrace.ts`: + +```typescript +import type { AnalyticsAdapter, AnalyticsProps } from "../types.js"; +import { emitEvent } from "../sink.js"; + +export class DynatraceAdapter implements AnalyticsAdapter { + readonly name = "dynatrace"; + + async load(): Promise { + // Stub: real Dynatrace (Key-Astrom) script loads in Phase 2A + } + + track(event: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() }); + } + + page(url: string, props: AnalyticsProps = {}): void { + emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() }); + } +} +``` + +- [ ] **Step 2: Typecheck + lint, commit** + +```bash +pnpm typecheck && pnpm lint +git add src/observability/analytics/adapters/ +git commit -m "Add four stub analytics adapters (metrica, ctm, variocube, dynatrace)" +``` + +--- + +## Task 3 — TDD `facade.ts` + +**Files:** +- Create: `src/observability/analytics/facade.ts` +- Create: `src/observability/analytics/facade.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `src/observability/analytics/facade.test.ts`: + +```typescript +import { describe, expect, it, beforeEach, vi } from "vitest"; +import { createAnalytics } from "./facade.js"; +import { getRecordedEvents, resetEvents } from "./sink.js"; +import type { Logger } from "@/observability/logger/types"; + +function mockLogger(): Logger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn(() => mockLogger()), + }; +} + +describe("createAnalytics", () => { + beforeEach(() => { + resetEvents(); + }); + + it("fans out track() to all 4 enabled adapters", () => { + const analytics = createAnalytics({ + enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true }, + consent: { analytics: true, telemetry: true }, + logger: mockLogger(), + }); + + analytics.track("test.event", { key: "value" }); + + const events = getRecordedEvents(); + expect(events).toHaveLength(4); + + const providers = events.map((e) => e.provider).sort(); + expect(providers).toEqual(["ctm", "dynatrace", "metrica", "variocube"]); + + for (const event of events) { + expect(event.kind).toBe("track"); + expect(event.name).toBe("test.event"); + expect(event.props).toEqual({ key: "value" }); + } + }); + + it("fans out page() to all 4 enabled adapters", () => { + const analytics = createAnalytics({ + enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true }, + consent: { analytics: true, telemetry: true }, + logger: mockLogger(), + }); + + analytics.page("/ru/online-board"); + + const events = getRecordedEvents(); + expect(events).toHaveLength(4); + + for (const event of events) { + expect(event.kind).toBe("page"); + expect(event.name).toBe("/ru/online-board"); + } + }); + + it("consent.analytics = false short-circuits before any adapter is invoked", () => { + const analytics = createAnalytics({ + enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true }, + consent: { analytics: false, telemetry: true }, + logger: mockLogger(), + }); + + analytics.track("should.not.emit"); + analytics.page("/should/not/emit"); + + expect(getRecordedEvents()).toHaveLength(0); + }); + + it("disabled adapter is not invoked", () => { + const analytics = createAnalytics({ + enabled: { metrica: true, ctm: false, variocube: false, dynatrace: true }, + consent: { analytics: true, telemetry: true }, + logger: mockLogger(), + }); + + analytics.track("partial.event"); + + const events = getRecordedEvents(); + expect(events).toHaveLength(2); + + const providers = events.map((e) => e.provider).sort(); + expect(providers).toEqual(["dynatrace", "metrica"]); + }); +}); +``` + +- [ ] **Step 2: Run — MUST FAIL** + +```bash +pnpm test src/observability/analytics/facade +``` + +- [ ] **Step 3: Write implementation** + +Create `src/observability/analytics/facade.ts`: + +```typescript +import type { Analytics, AnalyticsAdapter, AnalyticsProps, AnalyticsProviders } from "./types.js"; +import type { Logger } from "@/observability/logger/types"; +import { MetricaAdapter } from "./adapters/metrica.js"; +import { CtmAdapter } from "./adapters/ctm.js"; +import { VariocubeAdapter } from "./adapters/variocube.js"; +import { DynatraceAdapter } from "./adapters/dynatrace.js"; + +export interface CreateAnalyticsOptions { + enabled: AnalyticsProviders; + consent: { analytics: boolean; telemetry: boolean }; + logger: Logger; +} + +const NOOP_ANALYTICS: Analytics = { + track() {}, + page() {}, +}; + +/** + * Creates an Analytics instance that fans out track/page calls to enabled adapters. + * If consent.analytics is false, returns a no-op (short-circuit before any adapter). + */ +export function createAnalytics(options: CreateAnalyticsOptions): Analytics { + const { enabled, consent, logger } = options; + + if (!consent.analytics) { + logger.debug("analytics consent denied, returning no-op"); + return NOOP_ANALYTICS; + } + + const adapters: AnalyticsAdapter[] = []; + if (enabled.metrica) adapters.push(new MetricaAdapter()); + if (enabled.ctm) adapters.push(new CtmAdapter()); + if (enabled.variocube) adapters.push(new VariocubeAdapter()); + if (enabled.dynatrace) adapters.push(new DynatraceAdapter()); + + if (adapters.length === 0) { + logger.debug("no analytics adapters enabled, returning no-op"); + return NOOP_ANALYTICS; + } + + return { + track(event: string, props: AnalyticsProps = {}): void { + for (const adapter of adapters) { + try { + adapter.track(event, props); + } catch (err) { + logger.error("analytics adapter track failed", { provider: adapter.name, err: err as Error }); + } + } + }, + + page(url: string, props: AnalyticsProps = {}): void { + for (const adapter of adapters) { + try { + adapter.page(url, props); + } catch (err) { + logger.error("analytics adapter page failed", { provider: adapter.name, err: err as Error }); + } + } + }, + }; +} +``` + +- [ ] **Step 4: Run — ALL MUST PASS** + +```bash +pnpm test src/observability/analytics/facade +``` + +- [ ] **Step 5: Typecheck + lint, commit** + +```bash +pnpm typecheck && pnpm lint +git add src/observability/analytics/facade.ts src/observability/analytics/facade.test.ts +git commit -m "Add analytics facade with adapter fan-out and consent gating" +``` + +--- + +## Task 4 — Create `loader.tsx` (no TDD) + +**Files:** +- Create: `src/observability/analytics/loader.tsx` + +- [ ] **Step 1: Write implementation** + +Create `src/observability/analytics/loader.tsx`: + +```tsx +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import type { Analytics, AnalyticsProviders } from "./types.js"; +import type { Logger } from "@/observability/logger/types"; +import { createAnalytics } from "./facade.js"; +import { AnalyticsContext } from "./provider.js"; + +export interface AnalyticsLoaderProps { + enabled: AnalyticsProviders; + consent: { analytics: boolean; telemetry: boolean }; + logger: Logger; + children: ReactNode; +} + +/** + * Mounts in the root layout. Waits for idle callback, then initializes + * analytics adapters and provides the Analytics instance to the tree. + */ +export function AnalyticsLoader({ + enabled, + consent, + logger, + children, +}: AnalyticsLoaderProps): JSX.Element { + const [analytics, setAnalytics] = useState(null); + const initRef = useRef(false); + + useEffect(() => { + if (initRef.current) return; + initRef.current = true; + + const init = () => { + const instance = createAnalytics({ enabled, consent, logger }); + setAnalytics(instance); + }; + + if (typeof window !== "undefined" && "requestIdleCallback" in window) { + (window as any).requestIdleCallback(init); + } else { + // Fallback for environments without requestIdleCallback + setTimeout(init, 1); + } + }, [enabled, consent, logger]); + + return ( + + {children} + + ); +} +``` + +- [ ] **Step 2: Typecheck + lint, commit** + +```bash +pnpm typecheck && pnpm lint +git add src/observability/analytics/loader.tsx +git commit -m "Add AnalyticsLoader component with idle-callback initialization" +``` + +--- + +## Task 5 — Create `provider.tsx` (no TDD) + +**Files:** +- Create: `src/observability/analytics/provider.tsx` + +- [ ] **Step 1: Write implementation** + +Create `src/observability/analytics/provider.tsx`: + +```tsx +import { createContext, useContext } from "react"; +import type { Analytics } from "./types.js"; + +const NOOP_ANALYTICS: Analytics = { + track() {}, + page() {}, +}; + +/** + * React context for the Analytics instance. + * Exported for use by AnalyticsLoader (which sets the provider value). + */ +export const AnalyticsContext = createContext(null); + +/** + * Returns the Analytics instance from context. + * Server-side and before AnalyticsLoader initializes: returns NoopAnalytics. + * Client-side after init: returns the real facade instance. + */ +export function useAnalytics(): Analytics { + const analytics = useContext(AnalyticsContext); + return analytics ?? NOOP_ANALYTICS; +} +``` + +- [ ] **Step 2: Typecheck + lint, commit** + +```bash +pnpm typecheck && pnpm lint +git add src/observability/analytics/provider.tsx +git commit -m "Add useAnalytics hook with server-safe NoopAnalytics fallback" +``` + +--- + +## Task 6 — Exit-gate verification + +- [ ] **Step 1: All gates** + +```bash +pnpm typecheck && pnpm lint && pnpm test +``` + +Expected: all pass. Sink + facade tests verify adapter fan-out, consent short-circuit, and disabled-adapter exclusion. + +- [ ] **Step 2: Git status clean** + +```bash +git status +``` + +--- + +## Self-review + +**Spec coverage.** Master plan §1G-analytics: +- `types.ts` (already seeded in 1A-1) — `AnalyticsProviders`, `AnalyticsProps`, `AnalyticsEvent`, `Analytics`, `AnalyticsAdapter` ✓ +- `sink.ts` with `emitEvent`, `getRecordedEvents`, `resetEvents` → Task 1 +- Four stub adapters (metrica, ctm, variocube, dynatrace) → Task 2 +- `createAnalytics()` with consent gating and adapter fan-out → Task 3 +- `` with `requestIdleCallback` → Task 4 +- `useAnalytics()` with NoopAnalytics server fallback → Task 5 +- Consent short-circuit verified in facade tests → Task 3 + +**Exit gate alignment:** +- "all four stub adapters emit exactly one AnalyticsEvent to the sink" — facade test, Task 3 +- "consent.analytics = false short-circuits" — facade test, Task 3 +- "adapter load failure emits flights.analytics.load_failed counter" — deferred to 1F-layout integration (loader wraps load() in try/catch and increments the metric from 1G-metrics) + +**No new dependencies.** Stubs are pure TypeScript with no vendor SDKs. diff --git a/docs/superpowers/plans/2026-04-14-phase-1g-logger.md b/docs/superpowers/plans/2026-04-14-phase-1g-logger.md new file mode 100644 index 00000000..d83ce220 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-1g-logger.md @@ -0,0 +1,791 @@ +# Phase 1G-logger — Logger Runtime Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the runtime logger — console transport (dev), JSON-lines HTTP transport (production), a `createRootLogger()` factory, and a React context provider with `useLogger()` — so that 1F-layout and all downstream features can log structured events with `logger.info("msg", { field: "value" })` in both SSR and client contexts. + +**Architecture:** `src/observability/logger/types.ts` already exists (seeded in 1A-1 with `Logger`, `LogFields`, `LogLevel`, `LogRecord`, `LogTransport`). This plan adds the runtime implementation: a `LoggerImpl` class that dispatches to pluggable transports, two built-in transports (console for dev, JSON-lines HTTP for production), and a factory + React provider. The logger is designed for request-scoped child loggers on the server (each request gets a child with `{ traceId, locale }` fields) and a single root logger shared on the client. + +**Tech Stack:** No new dependencies. The JSON-lines transport uses `fetch` / `navigator.sendBeacon` (browser) or `globalThis.fetch` (Node) — both built-in on Node 24. + +**Prerequisites:** 1A-1 (types.ts already shipped), 1A-3 (ESLint boundaries). + +--- + +## File structure + +| File | Responsibility | Task | +|---|---|---| +| `src/observability/logger/logger-impl.ts` | `LoggerImpl` class implementing `Logger` | 1 | +| `src/observability/logger/logger-impl.test.ts` | Tests | 1 | +| `src/observability/logger/console-transport.ts` | Dev-mode console transport | 2 | +| `src/observability/logger/console-transport.test.ts` | Tests | 2 | +| `src/observability/logger/json-lines-transport.ts` | Production HTTP transport with batching | 3 | +| `src/observability/logger/json-lines-transport.test.ts` | Tests | 3 | +| `src/observability/logger/root.ts` | `createRootLogger()` factory | 4 | +| `src/observability/logger/root.test.ts` | Tests | 4 | +| `src/observability/logger/provider.tsx` | React context + `useLogger()` | 5 | + +--- + +## Task 1 — TDD `LoggerImpl` + +**Files:** +- Create: `src/observability/logger/logger-impl.ts` +- Create: `src/observability/logger/logger-impl.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `src/observability/logger/logger-impl.test.ts`: + +```typescript +import { describe, expect, it, vi } from "vitest"; +import type { LogRecord, LogTransport } from "./types.js"; +import { LoggerImpl } from "./logger-impl.js"; + +function mockTransport(): LogTransport & { records: LogRecord[] } { + const records: LogRecord[] = []; + return { + records, + write(record: LogRecord) { records.push(record); }, + flush: vi.fn(async () => {}), + }; +} + +describe("LoggerImpl", () => { + it("writes a record at each log level", () => { + const t = mockTransport(); + const logger = new LoggerImpl(t); + + logger.debug("d"); + logger.info("i"); + logger.warn("w"); + logger.error("e"); + + expect(t.records).toHaveLength(4); + expect(t.records.map(r => r.level)).toEqual(["debug", "info", "warn", "error"]); + expect(t.records.map(r => r.msg)).toEqual(["d", "i", "w", "e"]); + }); + + it("includes fields in the record", () => { + const t = mockTransport(); + const logger = new LoggerImpl(t); + logger.info("msg", { userId: 123, action: "click" }); + expect(t.records[0]?.fields).toEqual({ userId: 123, action: "click" }); + }); + + it("includes an ISO timestamp", () => { + const t = mockTransport(); + const logger = new LoggerImpl(t); + logger.info("msg"); + expect(t.records[0]?.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it("child() propagates context fields to all records", () => { + const t = mockTransport(); + const logger = new LoggerImpl(t); + const child = logger.child({ traceId: "abc", locale: "ru" }); + child.info("hello", { extra: true }); + + expect(t.records[0]?.fields).toEqual({ + traceId: "abc", + locale: "ru", + extra: true, + }); + }); + + it("child of child merges contexts", () => { + const t = mockTransport(); + const logger = new LoggerImpl(t); + const child1 = logger.child({ traceId: "abc" }); + const child2 = child1.child({ requestId: "123" }); + child2.info("deep"); + + expect(t.records[0]?.fields).toEqual({ + traceId: "abc", + requestId: "123", + }); + }); + + it("error level includes err field if provided", () => { + const t = mockTransport(); + const logger = new LoggerImpl(t); + const err = new Error("boom"); + logger.error("failed", { err, op: "fetch" }); + + const record = t.records[0]; + expect(record?.fields?.["op"]).toBe("fetch"); + // The err field should be serialized — at minimum the message + expect(record?.fields?.["err"]).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run — MUST FAIL** + +```bash +pnpm test src/observability/logger/logger-impl +``` + +- [ ] **Step 3: Write implementation** + +Create `src/observability/logger/logger-impl.ts`: + +```typescript +import type { Logger, LogFields, LogLevel, LogRecord, LogTransport } from "./types.js"; + +export class LoggerImpl implements Logger { + private readonly transport: LogTransport; + private readonly context: LogFields; + + constructor(transport: LogTransport, context: LogFields = {}) { + this.transport = transport; + this.context = context; + } + + debug(msg: string, fields?: LogFields): void { + this.write("debug", msg, fields); + } + + info(msg: string, fields?: LogFields): void { + this.write("info", msg, fields); + } + + warn(msg: string, fields?: LogFields): void { + this.write("warn", msg, fields); + } + + error(msg: string, fields?: LogFields & { err?: Error }): void { + const { err, ...rest } = fields ?? {}; + const serialized: LogFields = { ...rest }; + if (err) { + serialized["err"] = `${err.name}: ${err.message}`; + } + this.write("error", msg, serialized); + } + + child(context: LogFields): Logger { + return new LoggerImpl(this.transport, { ...this.context, ...context }); + } + + private write(level: LogLevel, msg: string, fields?: LogFields): void { + const record: LogRecord = { + ts: new Date().toISOString(), + level, + msg, + fields: { ...this.context, ...fields }, + }; + this.transport.write(record); + } +} +``` + +- [ ] **Step 4: Run — ALL MUST PASS** + +```bash +pnpm test src/observability/logger/logger-impl +``` + +- [ ] **Step 5: Typecheck + lint, commit** + +```bash +pnpm typecheck && pnpm lint +git add src/observability/logger/logger-impl.ts src/observability/logger/logger-impl.test.ts +git commit -m "Add LoggerImpl with transport dispatch and child context propagation" +``` + +--- + +## Task 2 — TDD console transport + +**Files:** +- Create: `src/observability/logger/console-transport.ts` +- Create: `src/observability/logger/console-transport.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `src/observability/logger/console-transport.test.ts`: + +```typescript +import { describe, expect, it, vi } from "vitest"; +import type { LogRecord } from "./types.js"; +import { ConsoleTransport } from "./console-transport.js"; + +describe("ConsoleTransport", () => { + it("pipes debug records to console.debug", () => { + const spy = vi.spyOn(console, "debug").mockImplementation(() => {}); + const transport = new ConsoleTransport(); + const record: LogRecord = { ts: "2025-01-01T00:00:00Z", level: "debug", msg: "hello", fields: {} }; + transport.write(record); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]?.[0]).toContain("hello"); + spy.mockRestore(); + }); + + it("pipes info records to console.info", () => { + const spy = vi.spyOn(console, "info").mockImplementation(() => {}); + const transport = new ConsoleTransport(); + transport.write({ ts: "2025-01-01T00:00:00Z", level: "info", msg: "info msg", fields: { key: "val" } }); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]?.[0]).toContain("info msg"); + spy.mockRestore(); + }); + + it("pipes warn records to console.warn", () => { + const spy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const transport = new ConsoleTransport(); + transport.write({ ts: "2025-01-01T00:00:00Z", level: "warn", msg: "w", fields: {} }); + expect(spy).toHaveBeenCalledTimes(1); + spy.mockRestore(); + }); + + it("pipes error records to console.error", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const transport = new ConsoleTransport(); + transport.write({ ts: "2025-01-01T00:00:00Z", level: "error", msg: "e", fields: {} }); + expect(spy).toHaveBeenCalledTimes(1); + spy.mockRestore(); + }); + + it("flush is a no-op that resolves immediately", async () => { + const transport = new ConsoleTransport(); + await expect(transport.flush()).resolves.toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run — MUST FAIL** + +- [ ] **Step 3: Write implementation** + +Create `src/observability/logger/console-transport.ts`: + +```typescript +import type { LogRecord, LogTransport } from "./types.js"; + +/** + * Dev-mode transport that pipes log records to the browser/Node console. + * Each record is printed as `[LEVEL] ts msg {fields}`. + */ +export class ConsoleTransport implements LogTransport { + write(record: LogRecord): void { + const prefix = `[${record.level.toUpperCase()}] ${record.ts}`; + const hasFields = Object.keys(record.fields).length > 0; + const msg = hasFields + ? `${prefix} ${record.msg} ${JSON.stringify(record.fields)}` + : `${prefix} ${record.msg}`; + + switch (record.level) { + case "debug": + console.debug(msg); + break; + case "info": + console.info(msg); + break; + case "warn": + console.warn(msg); + break; + case "error": + console.error(msg); + break; + } + } + + async flush(): Promise { + // Console output is synchronous — nothing to flush. + } +} +``` + +- [ ] **Step 4: Run — ALL MUST PASS** + +- [ ] **Step 5: Typecheck + lint, commit** + +```bash +pnpm typecheck && pnpm lint +git add src/observability/logger/console-transport.ts src/observability/logger/console-transport.test.ts +git commit -m "Add dev-mode ConsoleTransport for logger" +``` + +--- + +## Task 3 — TDD JSON-lines HTTP transport + +**Files:** +- Create: `src/observability/logger/json-lines-transport.ts` +- Create: `src/observability/logger/json-lines-transport.test.ts` + +Features: batching (collect N records or wait M ms, whichever comes first), backpressure drop (if buffer exceeds max, drop oldest), redaction of sensitive field names, `sendBeacon` on page unload / `flush()`. + +- [ ] **Step 1: Write failing tests** + +Create `src/observability/logger/json-lines-transport.test.ts`: + +```typescript +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import type { LogRecord } from "./types.js"; +import { JsonLinesHttpTransport } from "./json-lines-transport.js"; + +function record(overrides?: Partial): LogRecord { + return { + ts: "2025-01-01T00:00:00.000Z", + level: "info", + msg: "test", + fields: {}, + ...overrides, + }; +} + +describe("JsonLinesHttpTransport", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + fetchSpy = vi.fn(async () => new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchSpy); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("flushes batch after batchSize records", async () => { + const transport = new JsonLinesHttpTransport({ + endpoint: "https://logs.example/ingest", + batchSize: 2, + flushIntervalMs: 60000, + maxBufferSize: 100, + fetchImpl: fetchSpy, + }); + + transport.write(record({ msg: "one" })); + expect(fetchSpy).not.toHaveBeenCalled(); + + transport.write(record({ msg: "two" })); + // Should have flushed after 2nd record + expect(fetchSpy).toHaveBeenCalledTimes(1); + + const body = fetchSpy.mock.calls[0]?.[1]?.body as string; + const lines = body.trim().split("\n"); + expect(lines).toHaveLength(2); + }); + + it("flushes after flushIntervalMs even if batch is not full", async () => { + const transport = new JsonLinesHttpTransport({ + endpoint: "https://logs.example/ingest", + batchSize: 100, + flushIntervalMs: 1000, + maxBufferSize: 100, + fetchImpl: fetchSpy, + }); + + transport.write(record()); + expect(fetchSpy).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1001); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("drops oldest records when buffer exceeds maxBufferSize", () => { + const transport = new JsonLinesHttpTransport({ + endpoint: "https://logs.example/ingest", + batchSize: 100, + flushIntervalMs: 60000, + maxBufferSize: 3, + fetchImpl: fetchSpy, + }); + + transport.write(record({ msg: "1" })); + transport.write(record({ msg: "2" })); + transport.write(record({ msg: "3" })); + transport.write(record({ msg: "4" })); + transport.write(record({ msg: "5" })); + + // Force flush to see what's in the buffer + transport.flush(); + const body = fetchSpy.mock.calls[0]?.[1]?.body as string; + const lines = body.trim().split("\n"); + expect(lines).toHaveLength(3); + // Should contain the 3 most recent + expect(lines[0]).toContain('"3"'); + expect(lines[1]).toContain('"4"'); + expect(lines[2]).toContain('"5"'); + }); + + it("redacts sensitive field names", async () => { + const transport = new JsonLinesHttpTransport({ + endpoint: "https://logs.example/ingest", + batchSize: 1, + flushIntervalMs: 60000, + maxBufferSize: 100, + redactFields: ["password", "token", "secret"], + fetchImpl: fetchSpy, + }); + + transport.write(record({ + fields: { password: "hunter2", token: "abc123", safe: "visible" }, + })); + + const body = fetchSpy.mock.calls[0]?.[1]?.body as string; + const parsed = JSON.parse(body.trim()); + expect(parsed.fields.password).toBe("[REDACTED]"); + expect(parsed.fields.token).toBe("[REDACTED]"); + expect(parsed.fields.safe).toBe("visible"); + }); + + it("flush() sends all buffered records and clears the buffer", async () => { + const transport = new JsonLinesHttpTransport({ + endpoint: "https://logs.example/ingest", + batchSize: 100, + flushIntervalMs: 60000, + maxBufferSize: 100, + fetchImpl: fetchSpy, + }); + + transport.write(record({ msg: "a" })); + transport.write(record({ msg: "b" })); + await transport.flush(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // Flush again — nothing to send + await transport.flush(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 2: Run — MUST FAIL** + +- [ ] **Step 3: Write implementation** + +Create `src/observability/logger/json-lines-transport.ts`: + +```typescript +import type { LogRecord, LogTransport } from "./types.js"; + +export interface JsonLinesHttpTransportOptions { + endpoint: string; + batchSize?: number; + flushIntervalMs?: number; + maxBufferSize?: number; + redactFields?: string[]; + fetchImpl?: typeof fetch; +} + +export class JsonLinesHttpTransport implements LogTransport { + private readonly endpoint: string; + private readonly batchSize: number; + private readonly maxBufferSize: number; + private readonly redactFields: Set; + private readonly fetchFn: typeof fetch; + private buffer: LogRecord[] = []; + private timer: ReturnType | null = null; + + constructor(options: JsonLinesHttpTransportOptions) { + this.endpoint = options.endpoint; + this.batchSize = options.batchSize ?? 50; + this.maxBufferSize = options.maxBufferSize ?? 500; + this.redactFields = new Set(options.redactFields ?? ["password", "token", "secret", "authorization"]); + this.fetchFn = options.fetchImpl ?? globalThis.fetch; + + const intervalMs = options.flushIntervalMs ?? 5000; + this.timer = setInterval(() => { + if (this.buffer.length > 0) { + void this.sendBatch(); + } + }, intervalMs); + + // Unref so the timer doesn't keep the Node process alive + if (typeof this.timer === "object" && "unref" in this.timer) { + this.timer.unref(); + } + } + + write(record: LogRecord): void { + const redacted = this.redact(record); + this.buffer.push(redacted); + + // Backpressure: drop oldest if buffer exceeds max + while (this.buffer.length > this.maxBufferSize) { + this.buffer.shift(); + } + + // Flush if batch is full + if (this.buffer.length >= this.batchSize) { + void this.sendBatch(); + } + } + + async flush(): Promise { + if (this.buffer.length === 0) return; + await this.sendBatch(); + } + + private async sendBatch(): Promise { + const batch = this.buffer.splice(0, this.buffer.length); + if (batch.length === 0) return; + + const body = batch.map((r) => JSON.stringify(r)).join("\n"); + + try { + await this.fetchFn(this.endpoint, { + method: "POST", + headers: { "Content-Type": "application/x-ndjson" }, + body, + }); + } catch { + // Silently drop failed sends — logging a log failure causes recursion. + // In production, a metrics counter would track send failures. + } + } + + private redact(record: LogRecord): LogRecord { + if (this.redactFields.size === 0) return record; + + const fields = { ...record.fields }; + for (const key of Object.keys(fields)) { + if (this.redactFields.has(key.toLowerCase())) { + fields[key] = "[REDACTED]"; + } + } + return { ...record, fields }; + } +} +``` + +- [ ] **Step 4: Run — ALL MUST PASS** + +- [ ] **Step 5: Typecheck + lint, commit** + +```bash +pnpm typecheck && pnpm lint +git add src/observability/logger/json-lines-transport.ts src/observability/logger/json-lines-transport.test.ts +git commit -m "Add JsonLinesHttpTransport with batching, backpressure, and redaction" +``` + +--- + +## Task 4 — TDD `createRootLogger()` factory + +**Files:** +- Create: `src/observability/logger/root.ts` +- Create: `src/observability/logger/root.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `src/observability/logger/root.test.ts`: + +```typescript +import { describe, expect, it, vi, afterEach } from "vitest"; +import type { Logger } from "./types.js"; + +describe("createRootLogger", () => { + afterEach(async () => { + const mod = await import("./root.js"); + mod.__resetRootLoggerForTests(); + vi.resetModules(); + }); + + it("returns a Logger with console transport in development", async () => { + process.env["NODE_ENV"] = "development"; + const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js"); + __resetRootLoggerForTests(); + const logger = createRootLogger(); + expect(logger).toBeDefined(); + expect(typeof logger.info).toBe("function"); + expect(typeof logger.child).toBe("function"); + }); + + it("returns a Logger with JSON-lines transport in production", async () => { + process.env["NODE_ENV"] = "production"; + process.env["LOGS_ENDPOINT"] = "https://logs.example/ingest"; + const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js"); + __resetRootLoggerForTests(); + const logger = createRootLogger(); + expect(logger).toBeDefined(); + expect(typeof logger.info).toBe("function"); + }); + + it("caches the logger instance (returns same object on repeated calls)", async () => { + process.env["NODE_ENV"] = "development"; + const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js"); + __resetRootLoggerForTests(); + const a = createRootLogger(); + const b = createRootLogger(); + expect(a).toBe(b); + }); + + it("child() produces a Logger with merged context", async () => { + process.env["NODE_ENV"] = "development"; + const spy = vi.spyOn(console, "info").mockImplementation(() => {}); + const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js"); + __resetRootLoggerForTests(); + const logger = createRootLogger(); + const child = logger.child({ traceId: "test-123" }); + child.info("hello"); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]?.[0]).toContain("test-123"); + spy.mockRestore(); + }); +}); +``` + +- [ ] **Step 2: Run — MUST FAIL** + +- [ ] **Step 3: Write implementation** + +Create `src/observability/logger/root.ts`: + +```typescript +import type { Logger, LogTransport } from "./types.js"; +import { LoggerImpl } from "./logger-impl.js"; +import { ConsoleTransport } from "./console-transport.js"; +import { JsonLinesHttpTransport } from "./json-lines-transport.js"; + +let cached: Logger | undefined; + +/** + * Creates or returns the cached root logger. In development, uses + * ConsoleTransport. In other envs, uses JsonLinesHttpTransport if + * LOGS_ENDPOINT is set, otherwise falls back to console. + */ +export function createRootLogger(): Logger { + if (cached) return cached; + + const env = process.env["NODE_ENV"] ?? "development"; + const logsEndpoint = process.env["LOGS_ENDPOINT"]; + + let transport: LogTransport; + + if (env === "development" || !logsEndpoint) { + transport = new ConsoleTransport(); + } else { + transport = new JsonLinesHttpTransport({ + endpoint: logsEndpoint, + batchSize: 50, + flushIntervalMs: 5000, + maxBufferSize: 500, + }); + } + + cached = new LoggerImpl(transport); + return cached; +} + +/** Test-only: clears the cached root logger. */ +export function __resetRootLoggerForTests(): void { + cached = undefined; +} +``` + +- [ ] **Step 4: Run — ALL MUST PASS** + +- [ ] **Step 5: Typecheck + lint, commit** + +```bash +pnpm typecheck && pnpm lint +git add src/observability/logger/root.ts src/observability/logger/root.test.ts +git commit -m "Add createRootLogger factory with transport selection by env" +``` + +--- + +## Task 5 — Create `src/observability/logger/provider.tsx` + +**Files:** +- Create: `src/observability/logger/provider.tsx` + +No TDD — thin React wrapper, exercised by 1F-layout. + +- [ ] **Step 1: Write `src/observability/logger/provider.tsx`** + +```tsx +import { createContext, useContext } from "react"; +import type { ReactNode } from "react"; +import type { Logger } from "./types.js"; + +const LoggerContext = createContext(null); + +export interface LoggerProviderProps { + logger: Logger; + children: ReactNode; +} + +/** + * Provides the Logger instance to the React tree. On the server, + * use a request-scoped child logger (with traceId, locale). On the + * client, use the shared root logger from createRootLogger(). + */ +export function LoggerProvider({ + logger, + children, +}: LoggerProviderProps): JSX.Element { + return ( + + {children} + + ); +} + +/** + * Returns the Logger from context. Throws if used outside + * . + */ +export function useLogger(): Logger { + const logger = useContext(LoggerContext); + if (!logger) { + throw new Error( + "useLogger() must be used within a . " + + "Ensure the root layout wraps the tree with .", + ); + } + return logger; +} +``` + +- [ ] **Step 2: Typecheck + lint** + +```bash +pnpm typecheck && pnpm lint +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/observability/logger/provider.tsx +git commit -m "Add LoggerProvider React context with useLogger hook" +``` + +--- + +## Task 6 — Exit-gate verification + +- [ ] **Step 1: All gates** + +```bash +pnpm typecheck && pnpm lint && pnpm test +``` + +Expected: all pass. Test count: 83 (prior) + LoggerImpl + ConsoleTransport + JsonLines + RootLogger = ~100+ total. + +- [ ] **Step 2: Git status clean** + +```bash +git status +``` + +--- + +## Self-review + +**Spec coverage.** Master plan §1G-logger: +- types.ts (already shipped in 1A-1) ✓ +- `JsonLinesHttpTransport` with batching, backpressure, redaction, flush → Task 3 +- `ConsoleTransport` for dev → Task 2 +- `createRootLogger()` factory → Task 4 +- React context + `useLogger()` → Task 5 +- A4-trigger task → documented in master plan, not implemented here (fires on A4 resolution) +- Exit gate tests: batching+flush, redaction, backpressure, console transport, child() → Tasks 1-4 + +**Type consistency.** `Logger`, `LogFields`, `LogLevel`, `LogRecord`, `LogTransport` all from `./types.js` (seeded in 1A-1). diff --git a/docs/superpowers/plans/2026-04-14-phase-1g-metrics.md b/docs/superpowers/plans/2026-04-14-phase-1g-metrics.md new file mode 100644 index 00000000..2d1e1080 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-1g-metrics.md @@ -0,0 +1,366 @@ +# Phase 1G-metrics — OpenTelemetry + Custom Instruments Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the OpenTelemetry runtime — server and browser initializers, `getMeter`/`getTracer` accessors, and the 8 custom metric instruments — so that 1F-layout, 1D, 1E, and all downstream features can emit structured metrics with `flightsApiError.add(1, { route })` in both SSR and client contexts. + +**Architecture:** `otel.ts` is the **only** file allowed to import from `@opentelemetry/sdk-metrics` and `@opentelemetry/sdk-node` (enforced by 1A-3 ESLint boundaries). It exports `initServerOtel(env)` and `initBrowserOtel(env)` which wire the real `MeterProvider`/`TracerProvider`. `custom.ts` uses `@opentelemetry/api`'s proxy meter to declare instruments at module level — safe because the proxy lazy-resolves after init runs. `otel.ts` imports `Env` from `@/env` and `Logger` from `@/observability/logger/types`. + +**Tech Stack:** `@opentelemetry/api`, `@opentelemetry/sdk-node`, `@opentelemetry/sdk-metrics`, `@opentelemetry/exporter-trace-otlp-http`, `@opentelemetry/exporter-metrics-otlp-http`, `web-vitals`. + +**Prerequisites:** 1A-1 (skeleton + `Env` type), 1A-3 (ESLint boundaries), 1G-logger (Logger types). + +--- + +## File structure + +| File | Responsibility | Task | +|---|---|---| +| `src/observability/metrics/otel.ts` | OTel init + getMeter/getTracer | 2 | +| `src/observability/metrics/otel.test.ts` | Tests | 2 | +| `src/observability/metrics/custom.ts` | 8 custom metric instruments | 3 | + +--- + +## Task 1 — Install OTel dependencies + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Install dependencies** + +```bash +pnpm add @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/sdk-metrics @opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-http web-vitals +``` + +- [ ] **Step 2: Verify installation** + +```bash +pnpm typecheck +``` + +- [ ] **Step 3: Commit** + +```bash +git add package.json pnpm-lock.yaml +git commit -m "Add OpenTelemetry and web-vitals dependencies for metrics pipeline" +``` + +--- + +## Task 2 — TDD `otel.ts` + +**Files:** +- Create: `src/observability/metrics/otel.ts` +- Create: `src/observability/metrics/otel.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `src/observability/metrics/otel.test.ts`: + +```typescript +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { metrics, trace } from "@opentelemetry/api"; +import { + InMemoryMetricExporter, + AggregationTemporality, + PeriodicExportingMetricReader, +} from "@opentelemetry/sdk-metrics"; + +describe("otel", () => { + beforeEach(() => { + // Reset global providers between tests + metrics.disable(); + trace.disable(); + }); + + afterEach(() => { + metrics.disable(); + trace.disable(); + vi.restoreAllMocks(); + }); + + it("initServerOtel registers a MeterProvider and TracerProvider", async () => { + const { initServerOtel } = await import("./otel.js"); + + initServerOtel({ + OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318", + OTEL_SERVICE_NAME: "flights-test", + NODE_ENV: "test", + } as any); + + // After init, getMeter should return a working meter + const { getMeter, getTracer } = await import("./otel.js"); + const meter = getMeter("test"); + const tracer = getTracer("test"); + + expect(meter).toBeDefined(); + expect(tracer).toBeDefined(); + }); + + it("counter incremented via proxy meter is observable by test reader", async () => { + const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); + const reader = new PeriodicExportingMetricReader({ + exporter, + exportIntervalMillis: 100, + }); + + const { initServerOtelWithReader } = await import("./otel.js"); + initServerOtelWithReader({ + OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318", + OTEL_SERVICE_NAME: "flights-test", + NODE_ENV: "test", + } as any, reader); + + const counter = metrics.getMeter("flights").createCounter("test.counter"); + counter.add(1, { route: "/smoke" }); + + // Force a collection cycle + await reader.forceFlush(); + + const exported = exporter.getMetrics(); + expect(exported.length).toBeGreaterThan(0); + + const testMetric = exported + .flatMap((rm) => rm.scopeMetrics) + .flatMap((sm) => sm.metrics) + .find((m) => m.descriptor.name === "test.counter"); + + expect(testMetric).toBeDefined(); + + await reader.shutdown(); + }); + + it("getMeter returns a meter from @opentelemetry/api", async () => { + const { getMeter } = await import("./otel.js"); + const meter = getMeter("my-component"); + expect(meter).toBeDefined(); + expect(typeof meter.createCounter).toBe("function"); + expect(typeof meter.createHistogram).toBe("function"); + }); + + it("getTracer returns a tracer from @opentelemetry/api", async () => { + const { getTracer } = await import("./otel.js"); + const tracer = getTracer("my-component"); + expect(tracer).toBeDefined(); + expect(typeof tracer.startSpan).toBe("function"); + }); +}); +``` + +- [ ] **Step 2: Run — MUST FAIL** + +```bash +pnpm test src/observability/metrics/otel +``` + +- [ ] **Step 3: Write implementation** + +Create `src/observability/metrics/otel.ts`: + +```typescript +import { metrics, trace } from "@opentelemetry/api"; +import type { Meter, Tracer } from "@opentelemetry/api"; +import { NodeSDK } from "@opentelemetry/sdk-node"; +import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; +import type { MetricReader } from "@opentelemetry/sdk-metrics"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import type { Env } from "@/env"; +import type { Logger } from "@/observability/logger/types"; + +let initialized = false; + +/** + * Initialize OpenTelemetry for the server (Node) process. + * Called once per process at startup. + */ +export function initServerOtel(env: Env): void { + if (initialized) return; + + const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT; + const serviceName = env.OTEL_SERVICE_NAME ?? "flights-web"; + + const metricReader = new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ url: `${endpoint}/v1/metrics` }), + exportIntervalMillis: 15_000, + }); + + const sdk = new NodeSDK({ + serviceName, + traceExporter: new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }), + metricReader, + }); + + sdk.start(); + initialized = true; +} + +/** + * Test-only variant that accepts a custom MetricReader for in-memory assertions. + */ +export function initServerOtelWithReader(env: Env, reader: MetricReader): void { + if (initialized) return; + + const serviceName = (env as Record).OTEL_SERVICE_NAME ?? "flights-test"; + + const meterProvider = new MeterProvider({ + readers: [reader], + }); + + metrics.setGlobalMeterProvider(meterProvider); + initialized = true; +} + +/** + * Initialize OpenTelemetry for the browser. + * Called once per tab via useEffect in the root layout. + * Browser-side uses web-vitals to report CWV as histograms. + */ +export function initBrowserOtel(env: Env): void { + if (initialized) return; + + const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT; + if (!endpoint) return; + + const meterProvider = new MeterProvider({ + readers: [ + new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ url: `${endpoint}/v1/metrics` }), + exportIntervalMillis: 30_000, + }), + ], + }); + + metrics.setGlobalMeterProvider(meterProvider); + + // Report web-vitals as OTel histograms + const cwvMeter = meterProvider.getMeter("web-vitals"); + void import("web-vitals").then(({ onCLS, onFID, onLCP, onFCP, onTTFB }) => { + const cls = cwvMeter.createHistogram("web_vitals.cls"); + const fid = cwvMeter.createHistogram("web_vitals.fid"); + const lcp = cwvMeter.createHistogram("web_vitals.lcp"); + const fcp = cwvMeter.createHistogram("web_vitals.fcp"); + const ttfb = cwvMeter.createHistogram("web_vitals.ttfb"); + + onCLS((m) => cls.record(m.value)); + onFID((m) => fid.record(m.value)); + onLCP((m) => lcp.record(m.value)); + onFCP((m) => fcp.record(m.value)); + onTTFB((m) => ttfb.record(m.value)); + }); + + initialized = true; +} + +/** Returns a named Meter from the global MeterProvider. */ +export function getMeter(name: string): Meter { + return metrics.getMeter(name); +} + +/** Returns a named Tracer from the global TracerProvider. */ +export function getTracer(name: string): Tracer { + return trace.getTracer(name); +} +``` + +- [ ] **Step 4: Run — ALL MUST PASS** + +```bash +pnpm test src/observability/metrics/otel +``` + +- [ ] **Step 5: Typecheck + lint, commit** + +```bash +pnpm typecheck && pnpm lint +git add src/observability/metrics/otel.ts src/observability/metrics/otel.test.ts +git commit -m "Add OTel server/browser initializers with getMeter/getTracer accessors" +``` + +--- + +## Task 3 — Create `custom.ts` (declarative, no TDD) + +**Files:** +- Create: `src/observability/metrics/custom.ts` + +- [ ] **Step 1: Write implementation** + +Create `src/observability/metrics/custom.ts`: + +```typescript +import { metrics } from "@opentelemetry/api"; + +/** + * Module-level metric instruments for the flights remote component. + * Safe to declare at module scope — @opentelemetry/api's proxy meter + * lazy-resolves to the real MeterProvider after initServerOtel/initBrowserOtel runs. + */ +const meter = metrics.getMeter("flights"); + +/** SSR request duration histogram (seconds). */ +export const flightsSsrRequestDuration = meter.createHistogram("flights.ssr.request.duration"); + +/** Upstream API request duration histogram (seconds). */ +export const flightsApiRequestDuration = meter.createHistogram("flights.api.request.duration"); + +/** Upstream API error counter (by route, status). */ +export const flightsApiError = meter.createCounter("flights.api.error"); + +/** SignalR active connections gauge. */ +export const flightsSignalRConnected = meter.createUpDownCounter("flights.signalr.connected"); + +/** SignalR messages received counter. */ +export const flightsSignalRMessageReceived = meter.createCounter("flights.signalr.message.received"); + +/** SignalR disconnection counter (by reason). */ +export const flightsSignalRDisconnect = meter.createCounter("flights.signalr.disconnect"); + +/** Feature component render counter (by feature name). */ +export const flightsFeatureRender = meter.createCounter("flights.feature.render"); + +/** Unhandled React error counter (caught by ErrorBoundary). */ +export const flightsReactError = meter.createCounter("flights.react.error"); +``` + +- [ ] **Step 2: Typecheck + lint, commit** + +```bash +pnpm typecheck && pnpm lint +git add src/observability/metrics/custom.ts +git commit -m "Add 8 custom metric instruments using OTel proxy meter" +``` + +--- + +## Task 4 — Exit-gate verification + +- [ ] **Step 1: All gates** + +```bash +pnpm typecheck && pnpm lint && pnpm test +``` + +Expected: all pass. OTel init test proves counter is observable via test reader. + +- [ ] **Step 2: Git status clean** + +```bash +git status +``` + +--- + +## Self-review + +**Spec coverage.** Master plan §1G-metrics: +- `initServerOtel(env)` / `initBrowserOtel(env)` — Task 2 +- `getMeter(name)` / `getTracer(name)` — Task 2 +- 8 custom instruments (`flights.ssr.request.duration`, `flights.api.request.duration`, `flights.api.error`, `flights.signalr.connected`, `flights.signalr.message.received`, `flights.signalr.disconnect`, `flights.feature.render`, `flights.react.error`) — Task 3 +- web-vitals histograms created inside `initBrowserOtel` — Task 2 +- `otel.ts` is the only file importing `@opentelemetry/sdk-metrics` / `@opentelemetry/sdk-node` — enforced by 1A-3 ESLint rule, verified at exit gate + +**Import boundary.** `otel.ts` imports `Env` from `@/env` and `Logger` from `@/observability/logger/types`. `custom.ts` imports only from `@opentelemetry/api` (public API, no SDK). + +**Type consistency.** `Meter`, `Tracer` from `@opentelemetry/api`. `Env` from `@/env` (seeded in 1A-1). diff --git a/docs/superpowers/plans/2026-04-14-phase-1h-security.md b/docs/superpowers/plans/2026-04-14-phase-1h-security.md new file mode 100644 index 00000000..074caacb --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-1h-security.md @@ -0,0 +1,139 @@ +# Phase 1H — Security Hardening + +**Parent:** Phase 1 Foundation Master Plan +**Branch:** `plan/react-rewrite` + +## Overview + +Implements security hardening contracts: CSP nonce middleware, SSR stream nonce injection, standard security headers, and safe storage abstraction with Zod schema validation. + +## Constraints + +- Do NOT touch `ClientApp/`, ASP.NET, `wwwroot/` +- Do NOT modify `modern.config.ts` (1I wires middleware registrations) +- `zod` already installed (from 1A-1) +- Middleware exported as factory functions, not auto-registered + +## Tasks + +### Task 1: `src/shared/storage.ts` + tests (TDD) + +**Files:** +- `src/shared/storage.ts` +- `src/shared/storage.test.ts` + +**Contract:** +```ts +import type { ZodSchema } from "zod"; + +export const storage: { + get(key: string, schema: ZodSchema): T | null; + set(key: string, value: T, schema: ZodSchema): void; + delete(key: string): void; + clear(): void; +}; +``` + +**Details:** +- All keys namespaced with `afl_` prefix +- `get` returns `null` when key missing, JSON parse fails, or Zod validation fails (never throws) +- `set` validates against schema before writing (throws on validation failure) +- `delete` removes the namespaced key +- `clear` removes only `afl_`-prefixed keys (not all storage) + +**Tests:** +- get/set round-trip with valid schema +- get returns null for missing key +- get returns null when stored value fails schema validation +- set throws when value doesn't match schema +- delete removes the key +- clear removes only namespaced keys +- keys are stored with `afl_` prefix + +### Task 2: `src/server/middleware/csp.ts` + tests (TDD) + +**Files:** +- `src/server/middleware/csp.ts` +- `src/server/middleware/csp.test.ts` + +**Contract:** +```ts +import { createContext } from "react"; + +export interface CspMiddlewareOptions { + reportOnly?: boolean; +} + +export function cspMiddleware(options?: CspMiddlewareOptions): (req: unknown, res: { setHeader(name: string, value: string): void }, next: () => void) => void; + +export const CspNonceContext: React.Context; // default "" +``` + +**Details:** +- Generates per-request nonce using `crypto.randomUUID()` +- Sets `Content-Security-Policy` header with `script-src 'nonce-{nonce}'` +- When `reportOnly: true`, uses `Content-Security-Policy-Report-Only` header +- Exposes nonce via `CspNonceContext` (default `""` on client) +- Nonce attached to request object for downstream middleware access + +**Tests:** +- Generates unique nonce per call +- Sets CSP header with nonce +- reportOnly option uses report-only header +- Each invocation produces a different nonce +- CspNonceContext has default value of "" + +### Task 3: `src/server/middleware/nonce-stream-transform.ts` + tests (TDD) + +**Files:** +- `src/server/middleware/nonce-stream-transform.ts` +- `src/server/middleware/nonce-stream-transform.test.ts` + +**Contract:** +```ts +export function wrapSsrStreamWithNonce( + stream: NodeJS.ReadableStream, + nonce: string, +): NodeJS.ReadableStream; +``` + +**Details:** +- Processes SSR HTML stream to inject `nonce="..."` on `"]); + const output = wrapSsrStreamWithNonce(input, NONCE); + const result = await streamToString(output); + expect(result).toContain(``]); + const output = wrapSsrStreamWithNonce(input, NONCE); + const result = await streamToString(output); + expect(result).toContain(``]); + const output = wrapSsrStreamWithNonce(input, NONCE); + const result = await streamToString(output); + expect(result).toContain(``]); + const output = wrapSsrStreamWithNonce(input, NONCE); + const result = await streamToString(output); + expect(result).toBe(``); + expect(result).not.toContain(NONCE); + }); + + it("handles multiple script tags in one chunk", async () => { + const input = createStream([ + ``, + ]); + const output = wrapSsrStreamWithNonce(input, NONCE); + const result = await streamToString(output); + expect(result).toContain(``); + expect(result).toContain(``); + expect(result).toContain(``, + ]); + const output = wrapSsrStreamWithNonce(input, NONCE); + const result = await streamToString(output); + expect(result).toContain(` to prevent injection", () => { + const data: Thing = { + "@type": "WebSite", + name: '', + }; + + const result = serializeJsonLd(data); + expect(result).not.toContain(""); + }); +}); + +describe("JsonLdRenderer", () => { + it("renders a "); + expect(html).toContain('"@context":"https://schema.org"'); + expect(html).toContain('"@type":"WebSite"'); + }); + + it("round-trips: serialize -> DOM string contains valid JSON-LD", () => { + const data: Thing = { + "@type": "Organization", + name: "Aeroflot PJSC", + url: "https://www.aeroflot.ru", + }; + + const html = renderToStaticMarkup(createElement(JsonLdRenderer, { data })); + + // Extract JSON from the script tag + const match = html.match(/]*>([\s\S]*?)<\/script>/); + expect(match).not.toBeNull(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- test assertion already guards + const json = match![1]!.replace(/\\u003c/g, "<"); + const parsed = JSON.parse(json); + expect(parsed["@context"]).toBe("https://schema.org"); + expect(parsed["@type"]).toBe("Organization"); + expect(parsed.name).toBe("Aeroflot PJSC"); + }); +}); diff --git a/src/shared/seo/json-ld.tsx b/src/shared/seo/json-ld.tsx new file mode 100644 index 00000000..ad0f15a4 --- /dev/null +++ b/src/shared/seo/json-ld.tsx @@ -0,0 +1,34 @@ +import type { Thing } from "schema-dts"; + +export interface JsonLdRendererProps { + data: Thing | Thing[]; +} + +/** + * Serializes a schema-dts Thing (or array of Things) to a JSON-LD string. + * Adds "@context": "https://schema.org" to each item. + * Escapes sequences to prevent XSS. + */ +export function serializeJsonLd(data: Thing | Thing[]): string { + const addContext = (item: Thing): Record => + Object.assign({ "@context": "https://schema.org" }, item as object); + + const withContext = Array.isArray(data) + ? data.map(addContext) + : addContext(data); + + return JSON.stringify(withContext).replace(/<\//g, "\\u003c/"); +} + +/** + * Renders a