diff --git a/.claude/skills/add-frontend-route/SKILL.md b/.claude/skills/add-frontend-route/SKILL.md index f695a9c8..e0101fb5 100644 --- a/.claude/skills/add-frontend-route/SKILL.md +++ b/.claude/skills/add-frontend-route/SKILL.md @@ -315,11 +315,13 @@ records passing production replacement proofs, completed reviews and verified fi hosts/locales. If unavailable, evidence stays pending and completion is not claimed. HTTP/RSC assertions are not browser or assistive-technology proof. P02d-7 owns browser/demo and Lighthouse; Phase 11 owns web-vitals telemetry. -- Lighthouse budget check on representative public routes — CI's `lighthouse budget` - job remains deferred until P02d-7/G44/G45 after P6 pages; judge by reading until - that harness is implemented. Its remaining details are in the - [decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); - the pass that closes it rewrites this bullet. +- CI's required `public demo + lighthouse` job exercises the seeded public + catalog/course/lesson routes under + [ADR-0056](../../../docs/decisions/0056-public-demo-browser-and-lighthouse-enforcement.md). + Preserve its production-page admission, hard budgets and owned cleanup. The + [P7 Step 4 record](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) + owns completed G44/G45 enforcement; Phase 06 still owns broader Playwright/axe + coverage. ## Validation diff --git a/.claude/skills/add-integration-test/SKILL.md b/.claude/skills/add-integration-test/SKILL.md index 2e684504..16256df7 100644 --- a/.claude/skills/add-integration-test/SKILL.md +++ b/.claude/skills/add-integration-test/SKILL.md @@ -44,11 +44,12 @@ architecture test) plus any other invariant the change touches. See - Domain-method invariants. Those are unit tests in `LearnStack.Tests.Unit`. - Structural rules (no cross-module reference). Architecture tests own those. - UI flows. There is no `LearnStack.Tests.EndToEnd` project. End-to-end means a - browser: Playwright over a running stack, owned by - [Phase 06](../../../docs/roadmap/phase-06-renderer-admin-studio.md) per - [Testing Standards § End-to-End Tests](../../../docs/standards/06-testing.md). - [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md) puts two - tenants in a browser but gates on a human opening them, not on a Playwright run. + browser over a running stack. P02d-7 supplies the narrow Playwright library + walkthrough of nine seeded product pages and 27 Lighthouse audits through + `make demo DEMO_MODE=ci`; G33's human walkthrough remains separate. + [Phase 06](../../../docs/roadmap/phase-06-renderer-admin-studio.md) owns the + broader Playwright Test/axe suite, per + [Testing Standards § End-to-End Tests](../../../docs/standards/06-testing.md#end-to-end-tests). ## Inputs @@ -69,9 +70,12 @@ architecture test) plus any other invariant the change touches. See > what almost every test wants: **every** migration chain applied — through > `MigrationChains.ApplyAllAsync`, never a list a fixture keeps itself — and every > table seeded for two tenants, with a second organization under tenant A. There -> are four chains and seventeen tables as of Packet 9; a fixture that names them is -> a fixture that goes stale, which is the defect below. Share it with `[Collection(SharedSchema.Name)]` rather than -> `IClassFixture<>`, so one container serves the whole schema suite. +> are five chains and 21 data tables (26 including migration histories). Discover +> current chains and history names through +> [MigrationChains](../../../backend/tests/LearnStack.Tests.Integration/Database/MigrationChains.cs); +> do not copy that inventory into a fixture. Share the schema with +> `[Collection(SharedSchema.Name)]` rather than `IClassFixture<>`, so one container +> serves the whole schema suite. > > Anything touching either carries `[Trait(RequiresDocker.Key, RequiresDocker.Value)]`, > which is how CI routes it to `backend-integration` rather than `backend`. @@ -225,7 +229,11 @@ and removes every other `ITestOnlyController`. That is the precedent `ProductionHostFixture` is the counterpart that adds none, so the production endpoint set stays exactly what a deployed instance serves. It drives the real middleware chain and the real EF -query filters without moving Phase 02d's first `/api/v1/*` read endpoints earlier. +query filters. Current production Education reads are covered separately by +[PublicEducationHttpTests](../../../backend/tests/LearnStack.Tests.Integration/Database/PublicEducationHttpTests.cs); +[EducationIsolationTests](../../../backend/tests/LearnStack.Tests.Integration/Database/EducationIsolationTests.cs) +independently exercises EF and raw RLS scope, writes and parent guards. Follow +those shipped examples for Education rather than inventing an authenticated route. ### Step 3: Outbox round-trip @@ -237,9 +245,9 @@ query filters without moving Phase 02d's first `/api/v1/*` read endpoints earlie > [ADR-0037 Amendment 1](../../../docs/decisions/0037-idempotency-key-contract.md) > names: the first `[Idempotent]` endpoint, or the first deployment running more > than one instance. Packet 6 shipped the `idempotency_keys` table, not the store. The `_fx.*` members below are illustrative of what those phases will -> provide; today the only fixtures are `PostgresFixture` and `SchemaFixture`, and -> the only session-context helper is `SchemaQueries`. Write against Step 1 and -> Step 2's shapes until the owning phase lands. +> provide. The schema examples above use `PostgresFixture`, `SchemaFixture` and +> `SchemaQueries`; production public HTTP cases use the separately shipped fixtures +> linked above. Write against those delivered APIs until the owning phase lands. For a handler that publishes an integration event: diff --git a/.claude/skills/add-page-block/SKILL.md b/.claude/skills/add-page-block/SKILL.md index 55605b50..6ef66a7d 100644 --- a/.claude/skills/add-page-block/SKILL.md +++ b/.claude/skills/add-page-block/SKILL.md @@ -219,8 +219,10 @@ the page. [Step 4 record](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) records product proofs and the scoped manual browser/screen-reader checks. - [Performance Standards](../../../docs/standards/15-performance.md) governs pages - embedding the block. Accepted G44 assigns Lighthouse activation and assertions - to P02d-7 after P6; the job remains disabled until that harness ships. + embedding the block. P02d-7's shared `make demo DEMO_MODE=ci` proof supplies + nine-page browser/Lighthouse checks; follow + [run-tests-locally](../run-tests-locally/SKILL.md#public-demo-managed-browser-and-lighthouse) + for prerequisites and evidence scope. Phase 06 owns full Playwright Test/axe. ## Validation diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index 9aad9d03..66a40489 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -185,7 +185,7 @@ change. | Outbox round-trip test for new integration event | Always | | Permission denied test for every new permission key | Always | | Boundary tests for every DSL band threshold | Scoring / completion rules | -| Lighthouse / axe-core for public-route changes | Accepted P02d-5 G44 assigns Lighthouse activation to P02d-7/G44/G45 after P6 pages. It is still a disabled placeholder; no passing audit is claimed. Accepted P02d-6/G43 requires applicable jsx-a11y error rules with planted controls, actual-page semantics and real manual keyboard/focus/reflow/contrast/screen-reader evidence; the broader axe/Playwright suite belongs to Phase 06. Pending manual evidence prevents packet completion. | +| Lighthouse / axe-core for public-route changes | P02d-7 supplies the shared `make demo DEMO_MODE=ci` seeded browser/Lighthouse proof under ADR-0056; follow run-tests-locally for prerequisites and review its sanitized receipt/reports. Accepted P02d-6/G43 requires applicable jsx-a11y error rules with planted controls, actual-page semantics and real manual keyboard/focus/reflow/contrast/screen-reader evidence; the broader axe/Playwright suite belongs to Phase 06. Pending manual evidence prevents packet completion. | A change without tests is incomplete; flag as Blocker unless the user explicitly deferred the test. diff --git a/.claude/skills/implement-task/SKILL.md b/.claude/skills/implement-task/SKILL.md index 370c9afc..c26bfb10 100644 --- a/.claude/skills/implement-task/SKILL.md +++ b/.claude/skills/implement-task/SKILL.md @@ -162,12 +162,15 @@ match the change: - Backend changes → `dotnet build`, `LearnStack.Tests.Architecture`, `LearnStack.Tests.Unit`, `LearnStack.Tests.Integration` (Testcontainers). -- Frontend changes → `pnpm lint`, `pnpm typecheck` and `pnpm test`. No Lighthouse - command exists yet, locally or in CI — the `lighthouse-budget` job is a deferred - placeholder — so judge public-route budgets against - [Performance Standards](../../../docs/standards/15-performance.md) by reading. There - is no `pnpm test:a11y` yet - — the axe suite arrives in Phase 06 ([run-tests-locally](../run-tests-locally/SKILL.md)). +- Frontend changes → `pnpm lint`, `pnpm typecheck` and guarded `pnpm test`. + Public-route changes also use the shared P7 browser/Lighthouse lifecycle: + `(cd frontend && pnpm exec playwright install --with-deps chromium)`, then + `make demo DEMO_MODE=ci` from a fresh checkout without private `.env`/ + `.env.local` configuration or `artifacts/public-demo`. Follow + [run-tests-locally](../run-tests-locally/SKILL.md#public-demo-managed-browser-and-lighthouse) + for prerequisites, artifact review and cleanup. The Playwright library proof + covers nine seeded pages and 27 audits; Phase 06 owns the full Playwright Test/ + axe suite. There is no `pnpm test:a11y` or `pnpm test:e2e` script. - Documentation-only → broken-link sweep + `docs/analysis/` residual scan (see Step 7's link audit). @@ -264,9 +267,12 @@ template. The prompt must: Deliver the prompt as a code-fence in the response so the user can copy and dispatch it. -**Lighthouse ownership — Accepted P02d-5, 2026-10-08.** P02d-7/G44/G45 -activates the full-stack job after P6 pages; it remains a disabled placeholder. -The entry decision does not provide a Lighthouse command or passing audit. +**Lighthouse ownership — P02d-7 / ADR-0056.** The shared demo lifecycle supplies +browser/audit proof. The packet's Step 4 record and +[branch protection](../../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) +own real CI receipts and required-check activation; local success alone does not +establish activation. Automated accessibility scores preserve P6's independently +recorded human/VoiceOver evidence and do not claim full WCAG conformance. ## Validation (Definition of Done) diff --git a/.claude/skills/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index d6cf5e88..ae8eaa45 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -7,7 +7,8 @@ description: > `docker-compose` plus the project's `make dev` orchestrator. USE FOR: first-time workstation setup, restoring a broken local environment, switching between `DeploymentMode` for testing. DO NOT USE FOR: production deployment (separate - topic), CI environment configuration (CI uses Testcontainers directly), or + topic), CI environment configuration (integration uses Testcontainers; P02d-7 + owns the compose-backed demo), or cloud / managed equivalents (those are deployment-mode-specific composition). --- @@ -39,8 +40,8 @@ the backend runs today calls them, so `make dev` starts 7 services and ## When not to use - Production deployment. Topic for ops runbooks, not this skill. -- CI configuration. CI uses Testcontainers via `LearnStack.Tests.Integration`, - not `make dev`. +- CI configuration. Integration uses Testcontainers; P02d-7 separately owns + the isolated compose-backed demo and browser/audit job. - Cloud-managed services (AWS RDS, Confluent Cloud). Composition is similar but config differs; out of scope. @@ -177,8 +178,9 @@ docker compose --env-file .env -f infra/compose/dev.yml config --format json ### Step 4: First-run bootstrap -`make seed` brings the stack up, applies migrations (it depends on `migrate`), -checks the two Keycloak realms, and — since Phase 02a Packet 7 — writes the two +`make seed` starts the selected stack, waits for PostgreSQL before migrations, +checks all seven default services and both Keycloak realm issuers, and — since +Phase 02a Packet 7 — writes the two demo tenants. One command from a clean checkout. [SeedData](../../../backend/src/LearnStack.Tools.Seeder/SeedData.cs) owns the exact @@ -187,7 +189,24 @@ built-in and tenant-authored definitions, branding and Course/Lesson content. [seed-tenant](../seed-tenant/SKILL.md) owns convergence, mismatch handling and future data scope. A completed rerun is write/audit-neutral; a divergent run fails without overwriting or resetting authored state. Public reads are delivered by P02d-4; -seed alone does not deliver pages. +P02d-6 delivers pages, and P02d-7 starts them with `make demo` after the manual +TLS/hosts prerequisite. Seed alone does not start a renderer. + +P02d-7 also supplies isolated CI mode over the same production lifecycle. In a +fresh checkout without root `.env`, web `.env.local` or `artifacts/public-demo`: + +```bash +(cd frontend && pnpm install --frozen-lockfile) +(cd frontend && pnpm exec playwright install --with-deps chromium) +make demo DEMO_MODE=ci +``` + +This creates disposable private TLS/configuration and a unique tmpfs-backed +Compose project, then runs nine browser pages and 27 Lighthouse audits with its +Keycloak stopped. It removes only owned resources and requires successful cleanup. +Follow [run-tests-locally](../run-tests-locally/SKILL.md#public-demo-managed-browser-and-lighthouse) +for fresh artifacts, receipts and prerequisites. Manual hosts/CA trust remains the +human local-mode prerequisite; CI mode does not modify system hosts or trust. ### Step 5: Verify @@ -211,8 +230,9 @@ curl -fsS https://localhost:3000/api/healthz ``` P02d-5 delivers native ingress/TLS, live-host bootstrap and enabled-locale entry. -P02d-6 owns public page composition and P02d-7 owns the two-host browser harness. -TLS socket tests with isolated trust roots do not claim a workstation browser run. +P02d-6 supplies public page composition and P02d-7 supplies the narrow two-host +browser/Lighthouse proof. TLS socket tests with isolated trust roots remain +separate from browser evidence and P6's recorded manual accessibility checks. ### Step 6: Switch deployment modes locally @@ -244,8 +264,8 @@ separately configured runtime credential. For the paired renderer workflow, rest with `make public-api` so its child-only database and hop configuration is retained: ```bash -# In the terminal running `dotnet run`, press Ctrl+C, then: -dotnet run --project backend/src/LearnStack.Api +# In the paired API terminal, press Ctrl+C, then: +make public-api ``` ### Step 7: Common troubleshooting @@ -273,7 +293,10 @@ state. [ADR-0053](../../../docs/decisions/0053-trusted-public-server-rendering.md) and the [P5 package](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-5-decision-package-2026-10-08) -own the accepted host/TLS/transport contract. `make demo` remains P02d-7/G45. +own the accepted host/TLS/transport contract. P02d-7 supplies foreground `make demo` +and CI browser/audit mode. Follow README Quickstart for production-build launch, +owned cleanup, retained local data and stale-lock handling. The packet's Step 4 +record and CONTRIBUTING own real CI receipts and required-check activation. - `make dev` exits 0; after starting the API separately, `/healthz` responds 200. - After `make dev-gated` and with that API running, APISIX forwards `/healthz`. @@ -281,9 +304,9 @@ own the accepted host/TLS/transport contract. `make demo` remains P02d-7/G45. - Direct stock Next cannot authenticate forged ingress carriers; ordinary `pnpm dev/start` use the mandatory launcher. - Keycloak login works for both realms. -- `dotnet test backend/tests/LearnStack.Tests.Integration` passes against the - same containers (the Testcontainers fixture is independent; this is just a - consistency check). +- `dotnet test backend/tests/LearnStack.Tests.Integration` passes against its + independently provisioned Testcontainers databases; it does not use the dev + Compose database. P7's demo proof separately exercises the Compose topology. ## Common pitfalls diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index f77fd761..8ea2ee4e 100644 --- a/.claude/skills/run-tests-locally/SKILL.md +++ b/.claude/skills/run-tests-locally/SKILL.md @@ -33,7 +33,7 @@ flags, and a triage map for the most common failure shapes. | Input | Required | Description | |-------|----------|-------------| -| Suite | Yes | `unit` / `integration` / `architecture` / `contract` / `frontend`. (`e2e` arrives in [Phase 06](../../../docs/roadmap/phase-06-renderer-admin-studio.md).) | +| Suite | Yes | `unit` / `integration` / `architecture` / `contract` / `frontend` / `public-demo` (P02d-7). Phase 06 owns the broader Playwright Test/axe suite. | | Filter | No | `--filter ` to run a subset. | | Docker available? | Integration (`Requires=Docker`) | A running daemon. Postgres only — no Valkey, no Kafka. | @@ -70,10 +70,11 @@ backend/tests/ # learnstack_app), split by [Trait("Requires","Docker")]. LearnStack.Tests.Contract/ # OpenAPI / SDK contract assertions. -frontend/apps/web/ # Vitest. The axe-core and Playwright suites - # arrive in Phase 06. Accepted P02d-5 G44 assigns - # the deferred Lighthouse job to P02d-7/G44/G45 - # after P6 pages; it remains disabled today. +frontend/apps/web/ # Guarded Vitest unit/component/control suites. +scripts/demo.mjs # Shared local/CI production lifecycle. +frontend/apps/web/scripts/ + verify-public-demo.mjs # P02d-7 Playwright library + Lighthouse proof. + # Phase 06 owns full Playwright Test/axe flows. ``` ### Step 3: Run unit tests @@ -171,7 +172,8 @@ pagination, presentation, concurrent isolation and freshness/failure states. Tho HTTP/HTML/RSC proofs do not claim browser or screen-reader execution; P6's manual accessibility closeout is recorded with its tested scope in the [packet closeout](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10). -New UI changes require their own applicable manual checks; P7 owns the browser demo. +New UI changes require their own applicable manual checks; P7 supplies the narrow +seeded browser demo described below. ADR-0055's separate admission fixture adds a narrow stock-Next browser navigation probe and development HMR admission controls. Its Docker integration run requires @@ -185,19 +187,68 @@ Browser observations cover a refused Flight followed by stock Next's fresh docum navigation and separately counted API calls. HMR controls cover source recompilation and a fresh live site snapshot in the same development process. The [Step 3 record](../../../docs/roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) -owns actual execution, completed reviews and verified fixes. This probe does not -deliver P7's browser demo, full Playwright/axe or Lighthouse. - -> **`pnpm test:a11y` and `pnpm test:e2e` do not exist yet.** `package.json` -> defines `dev`, `build`, `start`, `lint`, `typecheck` and `test`, and neither -> `axe-core` nor `@playwright/test` is a dependency. Both arrive in **Phase 06**, per +owns actual execution, completed reviews and verified fixes. This probe remains +separate from P7's browser demo/Lighthouse and Phase 06's full Playwright Test/axe. + +> **`pnpm test:a11y` and `pnpm test:e2e` do not exist.** P7 directly pins the +> development tools `playwright@1.64.0` and `lighthouse@13.5.0` in the frontend +> workspace. Lighthouse brings transitive development `axe-core@4.14.0`; this is +> not an application axe dependency or a standalone axe suite. There is no +> `@playwright/test` project. Phase 06 owns that broader harness, per > [Testing Standards § End-to-End Tests](../../../docs/standards/06-testing.md). -> Accepted P02d-5 G44 assigns Lighthouse to P02d-7/G44/G45 after P6 pages; the job -> remains disabled. Accepted P02d-6/G43 requires failing applicable `jsx-a11y` -> rules, localized page semantics and a real manual screen-reader smoke. These -> lint and product-semantic controls are implemented. Passing manual evidence is -> recorded in the P6 delivery record. P5's production HTML/RSC mode proves transport; -> P6's separate product modes still do not claim a browser/a11y audit. +> P6's blocking `jsx-a11y` and product-semantic controls and recorded manual smoke +> remain separate evidence from P7's automated Lighthouse accessibility score. + +### Public demo: managed browser and Lighthouse + +From a fresh checkout without root `.env`, web `.env.local` or +`artifacts/public-demo`, install the pinned managed Chromium and run the shared +lifecycle: + +```bash +(cd frontend && pnpm install --frozen-lockfile) +(cd frontend && pnpm exec playwright install --with-deps chromium) +make demo DEMO_MODE=ci +``` + +Docker, .NET, Node, pnpm, OpenSSL and make must be available; the fixed application +and Compose ports must be free. CI mode creates private disposable configuration, +uses a unique tmpfs-backed Compose project, migrates and seeds through the normal +pipeline, stops its Keycloak, and builds the production API/native HTTPS renderer. +It runs nine linked product pages and 27 cold Lighthouse audits over one unchanged +API/build/database, with real admission pacing and certificate-specific trust. +Missing browser installation fails the proof rather than skipping it. + +On GitHub-hosted Linux runners, run `node scripts/ci-public-chromium.mjs prepare` +after installation and before the lifecycle, then run +`node scripts/ci-public-chromium.mjs cleanup` in an always-running cleanup step. +The helper discovers Playwright's exact executable and performs a sandbox-enabled +20-second launch smoke with a disposable browser profile. If Ubuntu's AppArmor +user-namespace restriction is enabled, it loads one invocation-owned profile +allowing `userns` only for that exact executable; the source and ownership marker +stay under `RUNNER_TEMP`, and cleanup unloads only that profile. It preserves the +kernel restriction and foreign policies/files. This follows +[Chromium's AppArmor guidance](https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md) +with a literal attachment path. The helper refuses local/self-hosted execution; +it does not change workstation policy. Run its isolated controls with +`node --test scripts/ci-public-chromium.test.mjs`; these use fake commands and +an executable fixture without mutating host AppArmor. A passing Linux job remains +required evidence of the actual runner prerequisite and complete demo/audit flow. + +Review `artifacts/public-demo/receipt.json`, the 27 JSON/HTML report pairs and browser +screenshots. Proof and owned cleanup must both pass. Archive only this sanitized +artifact directory; private configuration, certificates, keys, profiles and raw +process logs are excluded. Existing artifacts must be moved aside before another +run so stale evidence cannot pass. The +[P7 delivery record](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-step-3--browser-and-audit-proof-2026-10-11) +owns local proof/review evidence; the packet's Step 4 record and +[branch protection](../../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) +own real CI receipts and required-check activation. Local success alone does not +establish activation. + +For a human foreground demo, use `make demo` after the root README's manual +hosts/CA/leaf setup. Local mode retains Compose data and joins its owned API/web +processes on Ctrl+C; it does not run the CI browser/audit proof. ### Step 7: Single-test focus @@ -285,9 +336,9 @@ For flaky tests, run with `--blame-hang` and `--blame-hang-timeout`: dotnet test --blame-hang --blame-hang-timeout 5min ``` -**Lighthouse ownership — Accepted P02d-5, 2026-10-08.** P02d-7/G44/G45 -activates the full-stack job after P6 pages; it remains a disabled placeholder. -The entry decision does not provide a Lighthouse command or passing audit. +The `public demo + lighthouse` job uses the same `make demo DEMO_MODE=ci` lifecycle. +Its real receipt and required-check status are recorded in the packet's Step 4 +record and CONTRIBUTING; do not infer them from a local audit. ## Validation @@ -297,7 +348,8 @@ The entry decision does not provide a Lighthouse command or passing audit. The P5 native renderer fixtures require free 3000/3011 and fail on collisions. - A failing test message points at the specific rule / scenario it violates. - For frontend changes, `pnpm test`, `pnpm lint` and `pnpm typecheck` are clean. - The axe suite joins this list in Phase 06. Accepted P02d-6/G43 requires failing + Public-route changes also run the applicable P7 browser/Lighthouse proof. The + full axe suite joins this list in Phase 06. Accepted P02d-6/G43 requires failing applicable `jsx-a11y` rules and localized page-semantics checks, plus manual keyboard, focus, reflow, contrast and screen-reader evidence. Acceptance does not claim those controls pass; implementation records their actual results in diff --git a/.claude/skills/seed-tenant/SKILL.md b/.claude/skills/seed-tenant/SKILL.md index 9c329710..c3955494 100644 --- a/.claude/skills/seed-tenant/SKILL.md +++ b/.claude/skills/seed-tenant/SKILL.md @@ -17,8 +17,11 @@ The [P02d-2 accepted package](../../../docs/roadmap/phase-02d-walking-skeleton.m records the decisions. Step 4 delivers the complete inventory and contextual verification; both implementation review rounds and final verification passed. P02d-2 is merged, and P02d-4 supplies public API reads. Server ingress/transport -belongs to P02d-5; pages and the browser demo remain P02d-6/7. Do not claim a -rendered demo from seed alone. +belongs to P02d-5; P02d-6 pages and P02d-7's shared foreground/CI demo are delivered. +Step 3 supplies the narrow browser/Lighthouse proof; its +[delivery record](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-step-3--browser-and-audit-proof-2026-10-11) +owns local execution and reviews. The packet's Step 4 record owns real CI receipts +and activation. Do not claim a rendered demo from seed alone. [SeedData](../../../backend/src/LearnStack.Tools.Seeder/SeedData.cs) owns all demo identities, schemas, labels, palettes, bodies and computed inventory. The runner @@ -53,8 +56,11 @@ to a BYPASSRLS/superuser role on every physical connection. make seed ``` -This target depends on migration and invokes `scripts/seed.sh`, which checks compose -health and both Keycloak realms before running the .NET tool. The direct tool runs +This target starts the selected stack and waits for PostgreSQL before migration. +`scripts/seed.sh` then checks full Compose health and both realm issuers before +running the .NET tool. `scripts/compose.mjs` shares project/files/env selection +with the demo lifecycle; inherited Compose profiles cannot widen its default set. +The direct tool runs from `backend/`, where `global.json` pins the SDK: ```bash @@ -147,8 +153,13 @@ and planted companions catch calls, method groups, private transactions, accesso assignment, announcing SQL, direct EF mutations and ad hoc database commands. A separate guard requires the direct tool to build its pool through the shared application-role guard. G20(a)'s literal reader observes the actual declaration -and fails on unreadable/empty data. The complete demo-literal production-branch guard -remains Registered for P02d-5/6 scope and P02d-7 exit. +and fails on unreadable/empty data. P02d-7 Step 2 implements G20's nonempty exact +demo-literal source fence across backend/frontend/scripts, dirty/clean controls and +the backend production-project Seeder-reference ban. The bounded schema-replacement +companion checks data-driven behavior. These controls do not prove the absence of every +possible computed-identity branch; the +[Step 2 record](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-step-2--genericity-and-exit-foundations-2026-10-11) +owns their delivered scope and review evidence. ### 6. Reach the hosts @@ -157,8 +168,12 @@ hosts aliases and local HTTPS; Step 1 supplies the native launcher and private shared-source configuration. Follow [README Quickstart](../../../README.md#3-prepare-local-https-and-start-the-applications) for explicit CA trust/leaf creation and `make public-api` / `make public-web`. Repository scripts never edit hosts or system trust. P02d-5 delivers live-host -bootstrap and locale entry; P02d-6 owns pages, and P02d-7 owns the two-site browser -demo. No browser render is supplied by seeding alone. +bootstrap and locale entry; P02d-6 supplies pages. `make demo` now owns the +foreground production launch, while P02d-7 Step 3 supplies browser/audit proof. +For the isolated proof, install managed Chromium and use `make demo DEMO_MODE=ci` +from a fresh checkout, following +[run-tests-locally](../run-tests-locally/SKILL.md#public-demo-managed-browser-and-lighthouse). +Seeding alone supplies no browser render. ### 7. Reset only an explicitly disposable development environment diff --git a/.claude/skills/standards-check/SKILL.md b/.claude/skills/standards-check/SKILL.md index ef3e44b3..347b60f6 100644 --- a/.claude/skills/standards-check/SKILL.md +++ b/.claude/skills/standards-check/SKILL.md @@ -315,18 +315,20 @@ domain the diff doesn't touch. - [ ] Public-route budgets in [15-performance.md § Initial Budgets](../../../docs/standards/15-performance.md#initial-budgets) and [§ Bundle Size](../../../docs/standards/15-performance.md#bundle-size) respected. - **Not mechanically checkable yet** — `ci.yml`'s `lighthouse-budget` job is a - placeholder. Accepted P02d-5 G44 assigns activation to P02d-7/G44/G45 after - P6 pages. Judge by reading until that harness ships; do not report an unrun - audit as a measured pass or failure. + P02d-7's `make demo DEMO_MODE=ci` proof enforces the ADR-0056 normalized + gzip/CLS/automated-accessibility checks on nine seeded pages; timing medians + remain advisory. Follow + [run-tests-locally](../run-tests-locally/SKILL.md#public-demo-managed-browser-and-lighthouse) + for prerequisites and inspect actual receipts rather than claiming an unrun pass. - [ ] Backend latency budget per module respected. #### `16-accessibility.md` - [ ] WCAG 2.2 AA target. -- [ ] `axe-core` clean on changed components. **Not mechanically checkable yet** — - no `axe` dependency exists in `frontend/`; [Phase 06](../../../docs/roadmap/phase-06-renderer-admin-studio.md) - wires the automated checks, per - [Standards index row 16](../../../docs/standards/README.md). Judge by reading. +- [ ] Applicable blocking jsx-a11y rules, component semantics and P7's automated + Lighthouse accessibility checks pass. Lighthouse's transitive development axe + audit is bounded to its nine pages; the full Playwright Test/direct axe suite + remains [Phase 06](../../../docs/roadmap/phase-06-renderer-admin-studio.md). + Automated scores do not replace the recorded manual checks below. - [ ] Keyboard navigation + focus order reviewed. - [ ] Color contrast verified per brand token. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 46a9832c..255ab840 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -61,6 +61,17 @@ Configure these in **GitHub → Settings → Branches → Branch protection rule every other protection setting unchanged. The [packet delivery record](../docs/roadmap/phase-02d-walking-skeleton.md#p02d-4-step-4-contract-sdk-and-ci) records the successful job and activation proof. + - `public demo + lighthouse` — **required since P02d-7, 2026-10-11.** The + first complete [real PR run](https://github.com/HodeTech/LearnStack/actions/runs/38106505383) + passes before activation. Downloaded artifacts confirm all nine pages, + 27 audits and owned cleanup. Full API read-back confirms seven GitHub + Actions contexts, strict up-to-date checking, all six previous contexts + retained and every unrelated protection setting unchanged. The + [CI and exit closeout](../docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-ci-and-phase-exit-closeout-2026-10-11) + records execution, artifact validation and activation proof. + The separate [reviewed-head verification](../docs/roadmap/phase-02d-walking-skeleton.md#reviewed-implementation-head-verification-2026-10-11) + identifies the exact implementation head inspected after activation; + subsequent heads still require all seven passing checks. - P02d-5 keeps the existing check names. The frontend job uses the guarded workspace test runner and native production/cleanup controls. Backend integration also builds a disposable Next app against the real API/PostgreSQL; @@ -78,14 +89,21 @@ Configure these in **GitHub → Settings → Branches → Branch protection rule public-key trust. Its Flight-to-document navigation and fresh HMR admission scope does not deliver P7's browser demo, full Playwright/axe or Lighthouse. The [Step 3 record](../docs/roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) - owns actual execution and remaining reviews. - - `lighthouse budget (deferred to P02d-7)` remains behind its unset - `vars.ENABLE_LIGHTHOUSE_BUDGET` condition. Accepted P02d-5 G44 assigns - activation to P02d-7/G44/G45 after P6 pages. It remains optional and disabled; - P7 selects the harness/assertions and any live required-check registration. + owns completed implementation, reviews and execution evidence. + - P02d-7 replaces the disabled Lighthouse placeholder with the always-running + `public demo + lighthouse` job under ADR-0056. It installs the locked browser + tools and Playwright-managed Chromium, then runs `make demo DEMO_MODE=ci` over + the same lifecycle as local use. Nine actual pages, 27 JSON/HTML audits, + normalized gzip/CLS/automated-accessibility checks, real quota observation + and confirmed cleanup are mandatory. Only public reports/screenshots and + sanitized receipts are uploaded; raw logs, profiles and private TLS/env files + are excluded. The authorized seventh-context activation followed the complete + successful real PR run on 2026-10-11. The + [Step 4 record](../docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) + owns the actual run and verified activation/read-back. GitHub matches required checks **by name**. The - [packet record](../docs/roadmap/phase-02d-walking-skeleton.md#p02d-4-step-4-contract-sdk-and-ci) + [current CI and exit record](../docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-ci-and-phase-exit-closeout-2026-10-11) owns execution and dated API read-back; a workflow edit alone is not a protection-setting change. - **Require conversation resolution before merging**: on. @@ -96,9 +114,11 @@ Configure these in **GitHub → Settings → Branches → Branch protection rule - **Allow force pushes**: off. - **Allow deletions**: off. -The CI workflow is intentionally fast (~3 min target). If a step exceeds -that budget for two consecutive merges, raise a follow-up issue rather -than skipping the step on `main`. +The ordinary fast checks retain their approximately three-minute target. +ADR-0056's full-stack job deliberately includes real limiter-window waits: its +20-minute job ceiling includes setup/build, a 12-minute serial proof and separate +30-second cleanup. Timing ceilings are not performance targets. A failing or +slow check is investigated rather than skipped on `main`. ## Commit messages diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab6f6500..864ac5f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,8 @@ # § Branch protection. # # P02d-4 activates the always-running `openapi diff` check: pinned oasdiff, -# ADR-0024 policy controls and verified base/head snapshots. Lighthouse remains -# deferred under P02d-7's G44/G45; a public-page harness has not shipped yet. +# ADR-0024 policy controls and verified base/head snapshots. P02d-7 adds the +# always-running seeded browser/Lighthouse proof under ADR-0056. name: ci @@ -374,13 +374,118 @@ jobs: path: artifacts/openapi if-no-files-found: error - # ─── Lighthouse budget (deferred — Phase 02d) ───────────────────────── - lighthouse-budget: - name: lighthouse budget (deferred to P02d-7) + # ─── Seeded production browser + Lighthouse proof (P02d-7) ──────────── + public-demo: + name: public demo + lighthouse runs-on: ubuntu-latest - if: vars.ENABLE_LIGHTHOUSE_BUDGET == 'true' + timeout-minutes: 20 steps: - - run: echo "Placeholder — P02d-7 wires the full-stack harness after P02d-6 pages." + # Start before checkout. Reserve four minutes for the shared 30-second + # teardown, owned sandbox-policy cleanup and bounded receipt/artifact steps. + - name: Reserve demo cleanup and artifact time + run: echo "PUBLIC_DEMO_DEADLINE=$(( $(date +%s) + 16 * 60 ))" >> "$GITHUB_ENV" + + - name: Checkout + timeout-minutes: 2 + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up .NET SDK + timeout-minutes: 3 + uses: actions/setup-dotnet@v4 + with: + global-json-file: backend/global.json + + - name: Set up Node.js + timeout-minutes: 2 + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Set up pnpm + timeout-minutes: 1 + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + run_install: false + + - name: Cache NuGet packages + timeout-minutes: 1 + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('backend/**/*.csproj', 'backend/Directory.Packages.props') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Install pinned browser tools + timeout-minutes: 5 + working-directory: frontend + run: | + pnpm install --frozen-lockfile + pnpm exec playwright install --with-deps chromium + + - name: Prepare sandbox-enabled managed Chromium + timeout-minutes: 1 + run: | + node --test scripts/ci-public-chromium.test.mjs + node scripts/ci-public-chromium.mjs prepare + + # The same entrypoint owns restoration, seven-service readiness, migrations, + # contextual seeding, native TLS, production builds and confirmed cleanup. + # Its internal 12-minute proof bound remains; this earlier absolute job + # deadline also bounds cumulative setup/build before owned teardown. + - name: Prove the seeded production sites and budgets + id: demo + run: | + remaining=$(( PUBLIC_DEMO_DEADLINE - $(date +%s) )) + if [ "$remaining" -le 0 ]; then + echo 'Demo setup exhausted the reserved job budget.' >&2 + exit 1 + fi + # SIGINT enters the shared owner teardown; forced termination is a + # failed run, never passing or partial proof evidence. + timeout --signal=INT --kill-after=35s "${remaining}s" make demo DEMO_MODE=ci + + - name: Remove only the owned Chromium sandbox profile + if: always() + timeout-minutes: 1 + run: node scripts/ci-public-chromium.mjs cleanup + + - name: Record bounded workflow outcome + if: always() + timeout-minutes: 1 + env: + DEMO_OUTCOME: ${{ steps.demo.outcome }} + CHECKOUT_SHA: ${{ github.sha }} + SOURCE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + python3 - <<'PY' + import json, os + from pathlib import Path + output = Path('artifacts/public-demo-ci') + output.mkdir(parents=True, exist_ok=True) + receipt = {key: os.environ.get(key, '') for key in ( + 'DEMO_OUTCOME', 'CHECKOUT_SHA', 'SOURCE_HEAD_SHA', 'RUN_URL')} + (output / 'workflow.json').write_text(json.dumps(receipt, indent=2) + '\n') + PY + + - name: Upload public reports and sanitized receipts only + if: always() + timeout-minutes: 1 + uses: actions/upload-artifact@v4 + with: + name: public-demo-lighthouse + path: | + artifacts/public-demo/receipt.json + artifacts/public-demo/audit-*.json + artifacts/public-demo/audit-*.html + artifacts/public-demo/browser-*.png + artifacts/public-demo-ci/workflow.json + if-no-files-found: error + retention-days: 7 # ─── Meta (commit-message format, link audit) ───────────────────────── meta: diff --git a/.gitignore b/.gitignore index bcd9a715..cdd5aa09 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ dist/ build/ coverage/ +# Local test and browser proof artifacts +/artifacts/ + # Environment — track only the explicitly-listed *.example templates, # never real envs. `!*.example` would un-ignore every future `foo.example` # file in the tree (e.g. `infra/foo.conf.example`); the explicit allowlist diff --git a/CLAUDE.md b/CLAUDE.md index a0632c14..9dbc7f19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,8 @@ so a status header, a catalogue entry, a published count or a planted `Skip` fails the build rather than a reader. The whole .NET suite runs with **zero skips**, which the runner now refuses to let change. -**[Phase 02d](docs/roadmap/phase-02d-walking-skeleton.md) is in progress**: its kickoff +**[Phase 02d](docs/roadmap/phase-02d-walking-skeleton.md) implementation and exit +verification are complete; PR #28 review and merge remain pending**. Its kickoff shipped the packet table and the decision register, and every later packet opens with its decision pass. **P02d-1 is complete and merged** through [PR #22](https://github.com/HodeTech/LearnStack/pull/22) on 2026-09-14. Education's @@ -134,18 +135,34 @@ with actual-config planted controls. Its owns verification and review status. Step 4 adds concurrent host/locale, theme, freshness and content-fault product proofs; its [delivery record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) -records both completed review rounds. **P02d-6 implementation is complete — -2026-10-10, unmerged**: the +records both completed review rounds. **P02d-6 is complete and merged** through +[PR #27](https://github.com/HodeTech/LearnStack/pull/27) on 2026-10-10; its +[merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-merge-and-closeout-2026-10-10) +records final verification. The [packet closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) records passing manual browser checks and the maintainer's VoiceOver smoke. -Maintainer PR review and merge remain pending. ADR-0055 remediation delivers +ADR-0055 remediation delivers single-bootstrap admission and passing production replacement proofs. All three steps completed both independent review rounds; the [Step 3 record](docs/roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) owns completed Step 3 reviews, verified fixes and delivery evidence. The five new catalogue rules are Implemented. -P02d-7 owns the browser demo and Lighthouse; Phase 06 retains -the full renderer/Studio expansion. +P02d-7's demo, Lighthouse and phase-exit +[decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-decision-package-2026-10-10) +was approved on 2026-10-11, including bounded seventh-check activation after real +success. ADR-0056 was accepted on the same date. Steps 1 and 2 completed both +independent review rounds; the +[Step 3 record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-step-3--browser-and-audit-proof-2026-10-11) +owns browser/audit implementation, passing complete local reruns and both +completed Step 3 review rounds. The +[Step 4 record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) +owns CI enforcement, completed reviews and individual exit reconciliation. Its +[CI and exit closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-ci-and-phase-exit-closeout-2026-10-11) +verifies all seven real PR jobs, complete artifacts and live seventh-check +activation with all other protection settings preserved. All four steps completed +both review rounds. [PR #28](https://github.com/HodeTech/LearnStack/pull/28) awaits +maintainer review and merge. P02b-0 re-verification/decision pass is next afterward; +Phase 06 retains the full renderer/Studio expansion. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` (core + 7 modules × 4 projects + 4 test projects including the diff --git a/Makefile b/Makefile index 27774619..2efda584 100644 --- a/Makefile +++ b/Makefile @@ -14,23 +14,12 @@ SHELL := /bin/bash .DEFAULT_GOAL := help .ONESHELL: -# Compose layering — dev.yml is always the base; e2e.yml overlays for the -# end-to-end test suite (Playwright + Testcontainers harness). -# Compose resolves its default env file from the PROJECT directory — the -# directory of the first `-f` file, i.e. `infra/compose/` — not from the cwd. -# Without `--env-file` the repo-root `.env` that `.env.example` documents is -# silently ignored and every `${VAR:-default}` falls back. `--env-file` on a -# missing path is a hard error, so the flag is conditional. -# Recursively expanded (`=`, not `:=`) AND $(shell), not $(wildcard): `.env` is -# a prerequisite that the rule below creates. `:=` would evaluate at parse time, -# before the file exists; $(wildcard) would not help either, because make caches -# its directory listing for the whole invocation and keeps answering "absent" -# even after the prerequisite wrote the file. Either way the first `make dev` on -# a fresh clone ran without the `.env` it had just written, and silently picked -# up any stray `infra/compose/.env` instead. -ENV_FILE = $(shell test -f .env && echo --env-file .env) -COMPOSE_DEV = docker compose $(ENV_FILE) -f infra/compose/dev.yml -COMPOSE_E2E = docker compose $(ENV_FILE) -f infra/compose/dev.yml -f infra/compose/e2e.yml +# All lifecycle commands share an explicit project/files/env selection through +# scripts/compose.mjs. Normal targets use the dev stack; LEARNSTACK_COMPOSE_MODE, +# LEARNSTACK_COMPOSE_PROJECT and LEARNSTACK_COMPOSE_ENV_FILE select an explicit +# alternative. Ambient COMPOSE_* overrides never widen the selected service set. +COMPOSE_DEV = node scripts/compose.mjs selected +COMPOSE_E2E = node scripts/compose.mjs e2e # Kafka, Valkey, Vault, APISIX and the two Dapr services sit behind the `gated` # compose profile per ADR-0035: their ports ship now, their adapters land in @@ -45,8 +34,8 @@ COMPOSE_E2E = docker compose $(ENV_FILE) -f infra/compose/dev.yml -f infra/com # Kafka broker, a Vault and their volumes, while `make ps` said the stack was # down. GATED_PROFILE = gated -COMPOSE_ALL = docker compose $(ENV_FILE) --profile '*' -f infra/compose/dev.yml -COMPOSE_E2E_ALL = docker compose $(ENV_FILE) --profile '*' -f infra/compose/dev.yml -f infra/compose/e2e.yml +COMPOSE_ALL = node scripts/compose.mjs selected --profile '*' +COMPOSE_E2E_ALL = node scripts/compose.mjs e2e --profile '*' # Colour helpers (no-op when stdout is not a TTY). ifeq ($(shell test -t 1 && echo 1),1) @@ -84,7 +73,7 @@ dev: .env ## Bring the local dev stack up (Postgres, Keycloak, SeaweedFS, …). .PHONY: dev-gated dev-gated: .env ## Bring the dev stack up INCLUDING the demand-gated services (Kafka, kafka-ui, Valkey, Vault, APISIX, Dapr). - COMPOSE_PROFILES=$(GATED_PROFILE) $(COMPOSE_DEV) up -d + $(COMPOSE_DEV) --profile $(GATED_PROFILE) up -d @printf "\n$(CYAN)Full stack up.$(RESET) Nothing the backend runs today calls these — see ADR-0035.\n" .PHONY: down @@ -104,7 +93,7 @@ ps: ## Show service health summary, gated services included. $(COMPOSE_ALL) ps .PHONY: e2e-up -e2e-up: .env ## Bring the default dev services up with the e2e overlay (set COMPOSE_PROFILES=gated for all 14). +e2e-up: .env ## Bring the seven default services up with the ephemeral e2e overlay. $(COMPOSE_E2E) up -d @printf "\n$(CYAN)E2E stack up.$(RESET) Data is ephemeral — every restart wipes state.\n" @@ -187,8 +176,9 @@ migrate: ## Apply every EF migration chain (platform + each module) as `learnsta @# `database update` is a no-op, but a recipe that prints the same chain @# twice reads like a bug in the recipe. @migration_cs="$${ConnectionStrings__Migration:-}"; \ - if [ -z "$$migration_cs" ] && [ -f .env ]; then \ - migration_cs=$$(sed -n "s/^ConnectionStrings__Migration=//p" .env \ + migration_env="$${LEARNSTACK_COMPOSE_ENV_FILE:-.env}"; \ + if [ -z "$$migration_cs" ] && [ -f "$$migration_env" ]; then \ + migration_cs=$$(sed -n "s/^ConnectionStrings__Migration=//p" "$$migration_env" \ | tail -1 | tr -d "\r" | sed "s/^['\"]//; s/['\"]$$//"); \ fi; \ role=$$(printf '%s' "$$migration_cs" | awk -f scripts/connection-string.awk -v field=user); \ @@ -298,9 +288,21 @@ format: ## Apply formatters in place (backend dotnet-format + frontend prettier) typecheck: ## `pnpm -r typecheck` (tsc --noEmit across the monorepo). (cd frontend && pnpm -r typecheck) +.PHONY: demo wait-postgres +DEMO_MODE ?= local +export DEMO_MODE +demo: ## Start the seeded production demo in the foreground (local or ci). + node scripts/demo.mjs + +wait-postgres: ## Wait for the selected stack's PostgreSQL before migrations. + node scripts/wait-compose.mjs --postgres + # ─── Seed ───────────────────────────────────────────────────────────────── -.PHONY: seed -seed: dev migrate ## Bring the stack up, apply migrations, seed demo data (idempotent). +.PHONY: seed seed-config +seed-config: .env + ./scripts/seed.sh --validate-config + +seed: seed-config dev wait-postgres migrate ## Bring the stack up, apply migrations, seed demo data (idempotent). ./scripts/seed.sh # ─── Bootstrap ──────────────────────────────────────────────────────────── diff --git a/README.md b/README.md index ffe643e6..46f59592 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,8 @@ items, rules, custom fields and notification templates; their delivery is tracke ## Where it is today -**Phase 01 and Phase 02a are complete. Phase 02d is in progress.** +**Phase 01 and Phase 02a are complete. Phase 02d implementation and exit +verification are complete; PR #28 awaits maintainer review and merge.** [P02d-1](docs/roadmap/phase-02d-walking-skeleton.md#merge-and-closeout-2026-09-14) is **complete and merged**: Education domain, schema and isolation proofs. **P02d-2's decision package and ADR-0050/0051 are Accepted** as of 2026-10-02, @@ -101,8 +102,19 @@ The original P02d-6 implementation is complete. ADR-0055 remediation delivers single-bootstrap admission and passing production replacement proofs; all three steps completed both independent review rounds, and the [Step 3 record](docs/roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) -owns completed Step 3 reviews, verified fixes and delivery evidence. Maintainer -PR review and merge remain pending. The browser demo remains P02d-7. The +owns completed Step 3 reviews, verified fixes and delivery evidence. **P02d-6 is +complete and merged** through [PR #27](https://github.com/HodeTech/LearnStack/pull/27) +on 2026-10-10; its +[merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-merge-and-closeout-2026-10-10) +records final verification. P02d-7's demo, Lighthouse and phase-exit +[decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-decision-package-2026-10-10) +and ADR-0056 were approved on 2026-10-11. All four steps completed both independent +review rounds. The shared demo, genericity fences and seeded browser/Lighthouse +proof pass real CI; the seventh required context is active. The +[Step 4 record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) +owns CI enforcement, reviews and the positive phase-exit verdict. +[PR #28](https://github.com/HodeTech/LearnStack/pull/28) awaits maintainer review +and merge; P02b-0 re-verification is next afterward. The [P02d-4 decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-4-decision-package-2026-10-03) is Accepted — 2026-10-03. All four implementation steps and their review loops are complete. P02d-5 supplies trusted server rendering; P02d-6 supplies public @@ -115,7 +127,7 @@ page consumers and P02d-7 the two-host browser demo. | **Audit** | Classified write path and transactional durability for business changes | Operational hardening in [Phase 11](docs/roadmap/phase-11-production-hardening.md) | | **Education** | Course and Lesson aggregates, translations, protected-content policy, scoped authoring commands, complete demo seeds and isolated public GET/HEAD reads | Full versioned curriculum in [Phase 05](docs/roadmap/phase-05-education-learning-content.md) | | **API foundation** | Error contracts, validation, tenancy, concurrency and observability infrastructure | Authentication and durable event processing in [Phase 02b](docs/roadmap/phase-02b-events-auth.md) | -| **Frontend** | Native TLS ingress, live locale entry, trusted server caller, localized catalog/course/lesson pages, safe themes, accessibility controls/manual smoke and generated typed SDK | Two-tenant browser demo and Lighthouse in [P02d-7](docs/roadmap/phase-02d-walking-skeleton.md) | +| **Frontend** | Native TLS ingress, live locale entry, trusted server caller, localized catalog/course/lesson pages, safe themes, accessibility controls/manual smoke and generated typed SDK | Full renderer/Studio and Playwright Test/axe in [Phase 06](docs/roadmap/phase-06-renderer-admin-studio.md) | **Four modules contain domain implementations:** Tenancy, Customization, Audit and Education. Identity, Content and Media remain scaffolded. @@ -147,10 +159,13 @@ Use Docker with **Compose V2**, Git, Make, Bash, Python 3, curl and OpenSSL ### 2. Bootstrap from the repository root ```bash -make install # create .env if absent, restore dependencies, install git hooks -make seed # start infrastructure, apply migrations, seed two demo tenants +make hooks # optional: install the repository Git hooks ``` +Continue with the manual HTTPS prerequisites below, then run `make demo`. It +creates missing private configuration and restores dependencies without changing +Git hooks. `make seed` remains available for infrastructure/database-only use. + The seed provisions `demo-english` and `demo-yoga`, their organizations and host mappings, enabled locales, distinct branding, built-in and tenant-authored definitions, and eight scoped courses, ten lessons and twenty-seven translations. It uses the @@ -159,9 +174,8 @@ ordinary authoring pipeline and verifies completed acts before skipping them on ### 3. Prepare local HTTPS and start the applications -```bash -make public-env # generate a private 32-byte hop secret in the ignored root .env -``` +The demo prepares a missing private 32-byte hop secret in the ignored root `.env`. +Existing values and optional projections must agree; they are not reset. Install mkcert using its [official instructions](https://github.com/FiloSottile/mkcert). Run its trust step yourself, then create the local leaf certificate: @@ -178,14 +192,27 @@ commands never edit that file or install trust. Keep the CA private key local; only the ignored leaf/key paths belong in `.env`. ```bash -# Terminal 1 — loopback API with runtime credentials and network + hop secret. -make public-api +make demo ``` -```bash -# Terminal 2 — the mandatory native HTTPS launcher, then Next.js. -make public-web -``` +This foreground command restores pinned dependencies, starts the selected default +stack, waits for PostgreSQL before migrations, retains both Keycloak realm gates, +converges the seed and builds/starts the production API and native HTTPS web. +Readiness includes actual seeded lesson content; nine seed-derived URLs are printed. +Ctrl+C joins its owned API/web processes and preserves local infrastructure/data. +Use `make down` to stop infrastructure explicitly. A second demo invocation or a +foreign listener is refused; no foreign process is killed. If a hard kill leaves +`.data/demo.lock`, confirm the earlier process tree has stopped before removing +that lock manually. Missing/stale configuration or TLS is refused without reset. + +For separate development/watch terminals, retain `make public-api` and +`make public-web`. The CI lifecycle uses an isolated checkout/project and disposable +configuration through `make demo DEMO_MODE=ci`. Install its locked managed browser +first with `cd frontend && pnpm exec playwright install --with-deps chromium`. +The CI mode requires a fresh checkout configuration and artifact directory; use +local `make demo` for an existing workstation. The +[Step 4 record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) +owns actual CI and protection receipts. Readiness is for the API and for the web listener. `pnpm dev/start` also use @@ -201,7 +228,7 @@ both independent review rounds and fix verification. Source fences, the no-skip test runner and real-API production HTML/RSC proofs pass. The [remediation and closeout record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09) owns current review/CI evidence. P02d-6 implements public pages; -the two-host browser demo with `make demo` is P02d-7. See +P02d-7 supplies `make demo` and the seeded browser/Lighthouse proof. See [local setup](.claude/skills/local-dev-setup/SKILL.md) and [Compose documentation](infra/compose/README.md) for troubleshooting. @@ -210,6 +237,7 @@ the two-host browser demo with `make demo` is P02d-7. See | Command | Purpose | |---|---| | `make help` | List available tasks. | +| `make demo` | Build/start the seeded production sites; Ctrl+C preserves local data. | | `make dev` | Start the default local infrastructure. | | `make migrate` | Apply platform and module migration chains. | | `make test` | Restore dependencies and run backend and frontend suites; Docker is required. | diff --git a/backend/src/LearnStack.Tools.Seeder/DemoDatabaseConfiguration.cs b/backend/src/LearnStack.Tools.Seeder/DemoDatabaseConfiguration.cs new file mode 100644 index 00000000..9d280687 --- /dev/null +++ b/backend/src/LearnStack.Tools.Seeder/DemoDatabaseConfiguration.cs @@ -0,0 +1,32 @@ +using Npgsql; + +namespace LearnStack.Tools.Seeder; + +/// Rejects stale local credentials before infrastructure or migrations run. +public static class DemoDatabaseConfiguration +{ + public static bool IsValid(IReadOnlyDictionary values) + { + try + { + foreach (var (key, role, password) in new[] + { + ("Migration", "learnstack_migration", "LEARNSTACK_MIGRATION_PW"), + ("Default", "learnstack_app", "LEARNSTACK_APP_PW"), + ("PlatformAdmin", "learnstack_platform", "LEARNSTACK_PLATFORM_PW"), + ("OutboxDispatcher", "learnstack_outbox_admin", "LEARNSTACK_OUTBOX_PW"), + }) + { + var builder = new NpgsqlConnectionStringBuilder(values[$"ConnectionStrings__{key}"]); + if (builder.Username != role || builder.Password != values[password] + || builder.Database != values["POSTGRES_DB"] || builder.Port != 5432 + || builder.Host is not ("localhost" or "127.0.0.1")) return false; + } + return true; + } + catch (Exception failure) when (failure is ArgumentException or KeyNotFoundException) + { + return false; + } + } +} diff --git a/backend/src/LearnStack.Tools.Seeder/DemoIdentities.cs b/backend/src/LearnStack.Tools.Seeder/DemoIdentities.cs new file mode 100644 index 00000000..dbd6c10e --- /dev/null +++ b/backend/src/LearnStack.Tools.Seeder/DemoIdentities.cs @@ -0,0 +1,126 @@ +using System.Text.Json; +using LearnStack.Modules.Customization.Application.Contracts.Customization; + +namespace LearnStack.Tools.Seeder; + +/// Read-only, typed G20 vocabulary. SeedData is its only tenant-data source. +public sealed record DemoIdentities(IReadOnlyList Values, IReadOnlyList PlatformValues) +{ + public static DemoIdentities Create() + { + var values = new HashSet(); + void Add(string kind, string value) + { + if (string.IsNullOrWhiteSpace(value)) throw new InvalidOperationException("Empty demo identity."); + values.Add(new(kind, value)); + } + void Id(Guid id) => Add("id", id.ToString("D")); + void Labels(string kind, IReadOnlyDictionary labels) + { + if (labels.Count == 0) throw new InvalidOperationException("Missing demo labels."); + foreach (var label in labels.Values) Add(kind, label); + } + foreach (var tenant in SeedData.All) + { + Id(tenant.TenantId.Value); + Add("tenant-slug", tenant.Slug); + Add("tenant-name", tenant.DisplayName); + Add("host", tenant.Host); + foreach (var organization in new[] { tenant.DefaultOrganization, tenant.SecondOrganization }) + { + Id(organization.OrganizationId.Value); + Add("organization-slug", organization.Slug); + Add("organization-name", organization.DisplayName); + } + foreach (var type in SeedData.ContentTypes(tenant)) + { + Id(type.Id); + Add("content-type-key", type.Key); + Labels("definition-label", type.DisplayName); + foreach (var identity in SchemaValues(type.JsonSchema)) values.Add(identity); + } + foreach (var taxonomy in SeedData.Taxonomies(tenant)) + { + Id(taxonomy.Id); + Add("taxonomy-key", taxonomy.Key); + Labels("definition-label", taxonomy.DisplayName); + foreach (var band in taxonomy.Bands) + { + Add("band-key", band.Key); + Labels("band-label", band.DisplayName); + } + } + if (tenant.Curriculum is not { } curriculum) continue; + Id(curriculum.Theme.Id); + foreach (var course in curriculum.Courses) + { + Id(course.Id); + Add("course-key", course.SlugKey); + foreach (var translation in course.Translations) + { + Add("course-slug", translation.Slug); + Add("course-title", translation.Title); + } + foreach (var lesson in course.Lessons) + { + Id(lesson.Id); + foreach (var translation in lesson.Translations) + { + Add("lesson-slug", translation.Slug); + Add("lesson-title", translation.Title); + } + } + } + } + var platform = new HashSet(StringComparer.Ordinal) + { + BuiltInCustomizations.Card.Key, BuiltInCustomizations.Card.RendererKey, BuiltInCustomizations.Plain.Key + }; + platform.UnionWith(BuiltInCustomizations.Card.DisplayName.Values); + platform.UnionWith(BuiltInCustomizations.Plain.DisplayName.Values); + platform.UnionWith(SchemaValues(BuiltInCustomizations.Card.JsonSchema).Select(identity => identity.Value)); + foreach (var band in BuiltInCustomizations.Plain.Bands) + { + platform.Add(band.Key); + platform.UnionWith(band.DisplayName.Values); + } + return new([.. values.OrderBy(identity => identity.Kind, StringComparer.Ordinal) + .ThenBy(identity => identity.Value, StringComparer.Ordinal)], [.. platform.Order(StringComparer.Ordinal)]); + } + + private static DemoIdentity[] SchemaValues(string schema) + { + using var document = JsonDocument.Parse(schema); + if (document.RootElement.ValueKind != JsonValueKind.Object) + throw new InvalidOperationException("Demo schema must be an object."); + return Walk(document.RootElement).ToArray(); + + static IEnumerable Walk(JsonElement node) + { + if (node.ValueKind == JsonValueKind.Array) + { + foreach (var element in node.EnumerateArray()) + foreach (var value in Walk(element)) yield return value; + } + if (node.ValueKind != JsonValueKind.Object) yield break; + foreach (var property in node.EnumerateObject()) + { + if (property.Name == "properties") + foreach (var field in property.Value.EnumerateObject()) + yield return new("property-name", field.Name); + if (property.Name == "x-fields") + foreach (var field in property.Value.EnumerateArray()) + { + yield return new("property-name", field.GetProperty("name").GetString() + ?? throw new InvalidOperationException("Missing descriptor name.")); + foreach (var label in field.GetProperty("label").EnumerateObject()) + yield return new("field-label", label.Value.GetString() + ?? throw new InvalidOperationException("Missing descriptor label.")); + } + foreach (var value in Walk(property.Value)) yield return value; + } + } + } +} + +public sealed record DemoIdentity(string Kind, string Value); diff --git a/backend/src/LearnStack.Tools.Seeder/DemoManifest.cs b/backend/src/LearnStack.Tools.Seeder/DemoManifest.cs new file mode 100644 index 00000000..70eecec3 --- /dev/null +++ b/backend/src/LearnStack.Tools.Seeder/DemoManifest.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.Tools.Seeder; + +/// Projects the declared seed into public demo expectations without a database. +public static class DemoManifest +{ + public static IReadOnlyList Create() + { + var lanes = new List(); + foreach (var tenant in SeedData.All) + { + var curriculum = tenant.Curriculum ?? throw new InvalidOperationException("Missing demo curriculum."); + OrganizationId? hostOrganization = tenant.MapHostToDefaultOrganization ? tenant.DefaultOrganization.OrganizationId : null; + // Prefer the host's own scope so both tenant-wide and organization + // admission are exercised. Other visible catalog entries remain data. + var course = curriculum.Courses.Single(row => row.OrganizationId == hostOrganization + && row.Status == "Published" && row.ContentAccess == "public"); + foreach (var locale in curriculum.Locales.Where(row => row.IsEnabled).OrderBy(row => row.Sort)) + { + var translation = course.Translations.Single(row => row.Locale == locale.Locale); + var lesson = course.Lessons.Where(row => row.Status == "Published").OrderBy(row => row.Sort).First(); + var lessonTranslation = lesson.Translations.Single(row => row.Locale == locale.Locale); + var type = SeedData.ContentTypes(tenant).Single(row => row.Key == lesson.ContentTypeKey + && row.SchemaVersion == lesson.ContentTypeSchemaVersion); + using var schema = JsonDocument.Parse(type.JsonSchema); + using var body = JsonDocument.Parse(lessonTranslation.Body); + var fields = schema.RootElement.GetProperty("x-fields").EnumerateArray().Select(field => + new DemoField(field.GetProperty("name").GetString()!, + Label(field.GetProperty("label"), locale.Locale), + body.RootElement.GetProperty(field.GetProperty("name").GetString()!).GetString()!)).ToArray(); + var prefix = $"https://{tenant.Host}:3000/{locale.Locale}/courses"; + using var theme = JsonDocument.Parse(curriculum.Theme.Value); + lanes.Add(new(tenant.Host, locale.Locale, tenant.DisplayName, translation.Title, lessonTranslation.Title, + prefix, $"{prefix}/{translation.Slug}", $"{prefix}/{translation.Slug}/lessons/{lessonTranslation.Slug}", + fields, theme.RootElement.EnumerateObject().ToDictionary(row => row.Name, row => row.Value.GetString()!), + // Restricted courses retain public marketing; only lesson + // bodies require access. Never classify their titles as leaks. + curriculum.Courses.Where(row => row.Status != "Published" + || (row.OrganizationId is not null && row.OrganizationId != hostOrganization)) + .SelectMany(row => row.Translations.Select(value => value.Title)) + .Concat(SeedData.All.Where(row => row.TenantId != tenant.TenantId) + .SelectMany(row => row.Curriculum!.Courses.SelectMany(value => value.Translations.Select(text => text.Title)))) + .Distinct(StringComparer.Ordinal).ToArray(), + HiddenContent(tenant, hostOrganization))); + } + } + if (lanes.Count != 3 || lanes.SelectMany(row => new[] { row.CatalogUrl, row.CourseUrl, row.LessonUrl }) + .Distinct(StringComparer.Ordinal).Count() != 9) + throw new InvalidOperationException("The accepted demo requires three lanes and nine distinct URLs."); + return lanes; + } + + private static string Label(JsonElement labels, string locale) => + labels.TryGetProperty(locale, out var exact) ? exact.GetString()! + : labels.GetProperty("en").GetString()!; + + private static string[] HiddenContent(SeedTenant tenant, OrganizationId? organization) + { + var visible = tenant.Curriculum!.Courses.Where(course => course.Status == "Published" + && (course.OrganizationId is null || course.OrganizationId == organization) + && course.ContentAccess == "public").SelectMany(course => course.Lessons) + .Where(lesson => lesson.Status == "Published").SelectMany(lesson => lesson.Translations).ToArray(); + var visibleValues = visible.SelectMany(ContentValues).ToHashSet(StringComparer.Ordinal); + return SeedData.All.SelectMany(source => source.Curriculum!.Courses + .SelectMany(course => course.Lessons.Where(lesson => source.TenantId != tenant.TenantId + || course.Status != "Published" || course.ContentAccess != "public" + || course.OrganizationId is not null && course.OrganizationId != organization + || lesson.Status != "Published").SelectMany(lesson => lesson.Translations))) + .SelectMany(ContentValues).Where(value => !visibleValues.Contains(value)) + .Distinct(StringComparer.Ordinal).ToArray(); + } + + private static IEnumerable ContentValues(SeedLessonTranslation translation) + { + using var body = JsonDocument.Parse(translation.Body); + return body.RootElement.EnumerateObject().Select(row => row.Value.GetString()!) + .Prepend(translation.Title).ToArray(); + } +} + +public sealed record DemoLane(string Host, string Locale, string DisplayName, string CourseTitle, + string LessonTitle, string CatalogUrl, string CourseUrl, string LessonUrl, + IReadOnlyList Fields, IReadOnlyDictionary Theme, IReadOnlyList ExcludedTitles, + IReadOnlyList ExcludedContent); +public sealed record DemoField(string Name, string Label, string Value); diff --git a/backend/src/LearnStack.Tools.Seeder/Program.cs b/backend/src/LearnStack.Tools.Seeder/Program.cs index f04643ff..2fe700e6 100644 --- a/backend/src/LearnStack.Tools.Seeder/Program.cs +++ b/backend/src/LearnStack.Tools.Seeder/Program.cs @@ -2,6 +2,33 @@ using LearnStack.Tools.Seeder; using Microsoft.Extensions.Logging; using LearnStack.Infrastructure.Persistence; +using System.Text.Json; + +// Read-only tooling projection: no data source, seeding side effect or credentials. +if (args is ["--demo-identities"]) +{ + Console.WriteLine(JsonSerializer.Serialize(DemoIdentities.Create(), JsonSerializerOptions.Web)); + return 0; +} +if (args is ["--demo-manifest"]) +{ + Console.WriteLine(JsonSerializer.Serialize(DemoManifest.Create(), JsonSerializerOptions.Web)); + return 0; +} +if (args is ["--validate-demo-config"]) +{ + // Configuration arrives via the environment, never credential-bearing argv. + var values = Environment.GetEnvironmentVariables().Cast() + .ToDictionary(row => (string)row.Key, row => (string)row.Value!, StringComparer.Ordinal); + if (DemoDatabaseConfiguration.IsValid(values)) return 0; + Console.Error.WriteLine("demo: database configuration disagrees with the selected local stack."); + return 2; +} +if (args.Length != 0) +{ + Console.Error.WriteLine("seed: unsupported arguments."); + return 2; +} // The seeder is a host without an HTTP surface, and it exists so the two demo tenants are // written by the same commands a request writes them with — ADR-0042 requires that: a diff --git a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs new file mode 100644 index 00000000..8d2584fa --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs @@ -0,0 +1,356 @@ +using System.Text.Json; +using FluentAssertions; +using LearnStack.Tools.Seeder; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class DomainGenericityTests +{ + /// Phase 02d G20's exact-literal fence, not a proof against computed identities. + [Fact] + public void Production_Code_Does_Not_Branch_On_Demo_Tenant_Literals() + { + var vocabulary = DemoIdentities.Create(); + vocabulary.Values.Should().NotBeEmpty(); + var allFiles = DemoProductionSourceCensus(); + var files = allFiles + // Sole authoritative tenant declaration, never the whole Seeder project. + .Where(path => Path.GetRelativePath(RepositoryPaths.BackendSrc(), path).Replace('\\', '/') + != "LearnStack.Tools.Seeder/SeedData.cs").Order(StringComparer.Ordinal).ToArray(); + files.Length.Should().BeGreaterThan(100, "the production census must not disappear"); + files.Select(Path.GetFileName).Should().Contain("SeedRunner.cs").And.Contain("Program.cs") + .And.Contain("appsettings.json").And.Contain("PublicEducationDisplay.cs"); + files.SelectMany(path => DemoLiteralViolations(path, File.ReadAllText(path), vocabulary) + .Select(value => $"{Path.GetRelativePath(RepositoryPaths.BackendSrc(), path)}: {value}")) + .Should().BeEmpty("G20: demo identities are data; Fix: remove the production literal or branch"); + } + + [Fact] + public void Demo_Identity_Projection_Is_Complete_And_The_Frontend_Snapshot_Matches() + { + var vocabulary = DemoIdentities.Create(); + var values = vocabulary.Values; + values.Select(row => row.Kind).Distinct().Should().BeEquivalentTo(IdentityKinds); + + foreach (var tenant in SeedData.All) + { + values.Should().Contain(new DemoIdentity("id", tenant.TenantId.Value.ToString("D"))); + values.Should().Contain(new DemoIdentity("host", tenant.Host)); + values.Should().Contain(new DemoIdentity("tenant-slug", tenant.Slug)) + .And.Contain(new DemoIdentity("tenant-name", tenant.DisplayName)); + foreach (var organization in new[] { tenant.DefaultOrganization, tenant.SecondOrganization }) + { + values.Should().Contain(new DemoIdentity("id", organization.OrganizationId.Value.ToString("D"))); + values.Should().Contain(new DemoIdentity("organization-slug", organization.Slug)) + .And.Contain(new DemoIdentity("organization-name", organization.DisplayName)); + } + foreach (var type in SeedData.ContentTypes(tenant)) + { + values.Should().Contain(new DemoIdentity("id", type.Id.ToString("D"))); + values.Should().Contain(new DemoIdentity("content-type-key", type.Key)); + foreach (var label in type.DisplayName.Values) + values.Should().Contain(new DemoIdentity("definition-label", label)); + using var schema = JsonDocument.Parse(type.JsonSchema); + foreach (var property in schema.RootElement.GetProperty("properties").EnumerateObject()) + values.Should().Contain(new DemoIdentity("property-name", property.Name)); + if (schema.RootElement.TryGetProperty("x-fields", out var descriptors)) + foreach (var descriptor in descriptors.EnumerateArray()) + foreach (var label in descriptor.GetProperty("label").EnumerateObject()) + values.Should().Contain(new DemoIdentity("field-label", label.Value.GetString()!)); + } + foreach (var taxonomy in SeedData.Taxonomies(tenant)) + { + values.Should().Contain(new DemoIdentity("id", taxonomy.Id.ToString("D"))); + values.Should().Contain(new DemoIdentity("taxonomy-key", taxonomy.Key)); + foreach (var label in taxonomy.DisplayName.Values) + values.Should().Contain(new DemoIdentity("definition-label", label)); + foreach (var band in taxonomy.Bands) + { + values.Should().Contain(new DemoIdentity("band-key", band.Key)); + foreach (var label in band.DisplayName.Values) + values.Should().Contain(new DemoIdentity("band-label", label)); + } + } + if (tenant.Curriculum is not { } curriculum) continue; + values.Should().Contain(new DemoIdentity("id", curriculum.Theme.Id.ToString("D"))); + foreach (var course in curriculum.Courses) + { + values.Should().Contain(new DemoIdentity("id", course.Id.ToString("D"))); + values.Should().Contain(new DemoIdentity("course-key", course.SlugKey)); + foreach (var translation in course.Translations) + values.Should().Contain(new DemoIdentity("course-title", translation.Title)) + .And.Contain(new DemoIdentity("course-slug", translation.Slug)); + foreach (var lesson in course.Lessons) + { + values.Should().Contain(new DemoIdentity("id", lesson.Id.ToString("D"))); + foreach (var translation in lesson.Translations) + values.Should().Contain(new DemoIdentity("lesson-title", translation.Title)) + .And.Contain(new DemoIdentity("lesson-slug", translation.Slug)); + } + } + } + values.Where(row => row.Kind == "id").Select(row => row.Value).Should().OnlyHaveUniqueItems(); + values.Should().NotContain(row => row.Value == "Published" || row.Value == "enrollment_required" || row.Value == "en"); + var fixture = Path.Combine(RepositoryPaths.RepoRoot(), "frontend/apps/web/src/test/fixtures/demo-identities.json"); + JsonSerializer.Serialize(JsonSerializer.Deserialize(File.ReadAllText(fixture), JsonSerializerOptions.Web), JsonSerializerOptions.Web) + .Should().Be(JsonSerializer.Serialize(vocabulary, JsonSerializerOptions.Web), + "Fix: regenerate the derived fixture with the Seeder --demo-identities command after SeedData changes"); + } + + [Fact] + public void Demo_Literal_Fence_Detects_Decoded_Identities_And_Admits_Platform_Data() + { + var vocabulary = DemoIdentities.Create(); + var identity = vocabulary.Values.First(row => row.Kind == "tenant-name").Value; + var id = vocabulary.Values.First(row => row.Kind == "id").Value; + var key = vocabulary.Values.First(row => row.Kind == "content-type-key" && !vocabulary.PlatformValues.Contains(row.Value)).Value; + foreach (var value in new[] { identity, key, Guid.Parse(id).ToString("B").ToUpperInvariant(), Guid.Parse(id).ToString("N") }) + DemoLiteralViolations("Probe.cs", $"class Probe {{ string Value = {JsonSerializer.Serialize(value)}; }}", vocabulary) + .Should().ContainSingle("each backend identity/customization/alternate UUID spelling must be seen"); + var project = Path.GetFullPath("/repo/backend/src/Probe"); + var generated = new[] { Path.Combine(project, "bin"), Path.Combine(project, "obj") }; + foreach (var directory in new[] { "bin", "obj" }) + { + BackendDemoSourceKind(Path.Combine(project, "Features", directory, "Probe.cs"), generated) + .Should().Be(1, "only actual project outputs are excluded"); + BackendDemoSourceKind(Path.Combine(project, directory, "Generated.cs"), generated).Should().Be(0); + } + foreach (var name in new[] { ".env.local", ".DS_Store", "Thumbs.db", "editor.swp", "runtime.log" }) + { + var local = Path.Combine(RepositoryPaths.BackendSrc(), "LearnStack.Api", name); + IsDemoLocalArtifact(local).Should().BeTrue(); + OmitIgnoredDemoLocalFile(local).Should().BeTrue("the actual Git rules omit known machine files"); + } + foreach (var name in new[] { ".env.hidden.cs", ".env.hidden.json", "hidden.resx", "Probe.csproj.user" }) + IsDemoLocalArtifact(Path.Combine(project, name)).Should().BeFalse("ignored source/unknown formats are not machine-file exemptions"); + // MSBuild automatically imports .csproj.user, so ignore rules cannot exempt it. + OmitIgnoredDemoLocalFile(Path.Combine(RepositoryPaths.BackendSrc(), "LearnStack.Api", "Probe.csproj.user")) + .Should().BeFalse(); + Action ignoredBuildHook = () => _ = BackendDemoSourceKind(Path.Combine(project, "Probe.csproj.user"), generated); + ignoredBuildHook.Should().Throw(); + Action unknownFormat = () => _ = BackendDemoSourceKind(Path.Combine(project, "Probe.resx"), generated); + unknownFormat.Should().Throw(); + Action linkedInput = () => RequireCompileCoverage([Path.Combine(project, "Probe.cs")], ["/repo/shared/Linked.cs"]); + linkedInput.Should().Throw(); + RequireCompileCoverage([Path.Combine(project, "Probe.cs")], [Path.Combine(project, "Probe.cs")]); + Action dynamicCompile = () => RefuseExecutionTimeCompile(""); + dynamicCompile.Should().Throw(); + RefuseExecutionTimeCompile(""); + Action importedTarget = () => RefuseExecutionTimeCompile(""); + importedTarget.Should().Throw(); + Action customSdk = () => RefuseExecutionTimeCompile(""); + customSdk.Should().Throw(); + Action implicitImport = () => RefuseExecutionTimeCompile("../../tests/CensusInjection.targets"); + implicitImport.Should().Throw(); + RefuseExecutionTimeCompile("net10.0"); + Action externalProject = () => RequireProductionProjectCoverage([Path.Combine(project, "Probe.csproj")], ["/repo/backend/tests/RuntimeHelper/RuntimeHelper.csproj"]); + externalProject.Should().Throw(); + RequireProductionProjectCoverage([Path.Combine(project, "Probe.csproj")], [Path.Combine(project, "Probe.csproj")]); + var escaped = $"\\u{(int)identity[0]:x4}{identity[1..]}"; + DemoLiteralViolations("Probe.cs", "class Probe { string Value = \"" + escaped + "\"; }", vocabulary) + .Should().ContainSingle("Roslyn decodes escaped strings"); + DemoLiteralViolations("Probe.cs", "class Probe { string Value = \"\"\"" + identity + "\"\"\"; }", vocabulary) + .Should().ContainSingle("raw literals are values too"); + DemoLiteralViolations("Probe.cs", "class Probe { string Value = $\"" + identity + "\"; }", vocabulary) + .Should().ContainSingle("constant templates are complete literals"); + DemoLiteralViolations("Probe.cs", "class Probe { string Value = \"{\\\"name\\\":\\\"" + identity + "\\\"}\"; }", vocabulary) + .Should().ContainSingle("embedded JSON values must not hide identities"); + var json = JsonSerializer.Serialize(new { name = identity }); + foreach (var literal in new[] { + JsonSerializer.Serialize(identity) + "u8", + "\"\"\"" + identity + "\"\"\"u8", + "\"" + escaped + "\"u8", + JsonSerializer.Serialize(json) + "u8", + "\"\"\"" + json + "\"\"\"u8", + "\"{\\\"name\\\":\\\"" + escaped + "\\\"}\"u8" }) + DemoLiteralViolations("Probe.cs", "class Probe { System.ReadOnlySpan Value => " + literal + "; }", vocabulary) + .Should().Equal([identity], "UTF-8 literals decode ordinary, raw, escaped and embedded JSON values too"); + var declaration = "class Probe { string Value = " + JsonSerializer.Serialize(identity) + "; }"; + foreach (var source in new[] { + "#if NET\n" + declaration + "\n#endif", + "#if NET10_0\r\n" + declaration + "\r\n#endif", + "#if FUTURE_TARGET\n" + declaration + "\n#endif", + "#if NET\nclass Probe {}\n#else\n" + declaration + "\n#endif", + "#if NET\nclass Probe {}\n#elif FUTURE_TARGET\n" + declaration + "\n#endif", + "#if NET\n#if NET10_0\nclass Probe {}\n#elif FUTURE_TARGET\n" + declaration + "\n#else\nclass Probe {}\n#endif\n#endif", + "#if NET\n#if NET10_0\nclass Probe {}\n#else\n" + declaration + "\n#endif\n#endif" }) + { + DemoLiteralViolations("Probe.cs", source, vocabulary) + .Should().Equal([identity], "every feasible conditional configuration is scanned independently of build symbols"); + DemoLiteralViolations("Probe.cs", source.Replace(identity, "generic", StringComparison.Ordinal), vocabulary) + .Should().BeEmpty("mutually exclusive declarations need only be syntactically valid for the source scan"); + } + DemoLiteralViolations("Probe.cs", "#if NET\n// " + identity + "\n#elif NET10_0\n/* " + identity + + " */\n#else\nclass Probe { string Value = \"generic\"; }\n#endif", vocabulary) + .Should().BeEmpty("comments remain excluded in every conditional branch"); + var directiveText = "#if NET\n" + identity + "\n#endif"; + CSharpValues("class Probe { string Value = \"\"\"\n" + directiveText + "\n\"\"\"; }") + .Should().Equal([directiveText], "directive-looking text inside a raw literal must remain unchanged"); + var splitComment = "#if FOO\n/*\n#endif\n" + declaration + "\n#if FOO\n*/\n#endif"; + DemoLiteralViolations("Probe.cs", splitComment, vocabulary).Should().Equal([identity]); + DemoLiteralViolations("Probe.cs", splitComment.Replace(identity, "generic", StringComparison.Ordinal), vocabulary) + .Should().BeEmpty(); + DemoLiteralViolations("Probe.cs", "#define FOO\n" + splitComment, vocabulary).Should().BeEmpty(); + var lateSymbol = "#if FOO\n/*\n#endif\n#if LATE_SYMBOL\n" + declaration + + "\n#endif\n#if FOO\n*/\n#endif"; + DemoLiteralViolations("Probe.cs", lateSymbol, vocabulary).Should().Equal([identity], + "symbols exposed by another lexical configuration must enter the fixed-point census"); + var excessive = string.Join("\n", Enumerable.Range(0, 9).Select(index => $"#if SYMBOL{index}\nclass Probe{index} {{}}\n#endif")); + Action tooManySymbols = () => _ = DemoLiteralViolations("Probe.cs", excessive, vocabulary); + tooManySymbols.Should().Throw("an excessive census must fail rather than omit configurations"); + DemoLiteralViolations("Probe.json", JsonSerializer.Serialize(new { value = identity }), vocabulary).Should().ContainSingle(); + DemoLiteralViolations("Probe.cs", "// " + identity + "\nclass Probe { string Value = \"generic\"; }", vocabulary).Should().BeEmpty(); + foreach (var value in vocabulary.PlatformValues) + { + DemoLiteralViolations("Probe.cs", $"class Probe {{ string Value = {JsonSerializer.Serialize(value)}; }}", vocabulary).Should().BeEmpty(); + DemoLiteralViolations("Probe.cs", $"class Probe {{ System.ReadOnlySpan Value => {JsonSerializer.Serialize(value)}u8; }}", vocabulary) + .Should().BeEmpty("canonical platform values remain allowed as UTF-8 literals"); + } + var testData = DemoLiteralViolations("Probe.cs", $"class Probe {{ string Value = {JsonSerializer.Serialize(identity + " suffix")}; }}", vocabulary); + testData.Should().BeEmpty("matching is complete ordinal equality, never a substring"); + Action broken = () => _ = DemoLiteralViolations("Probe.json", "{", vocabulary); + broken.Should().Throw(); + Action brokenCode = () => _ = DemoLiteralViolations("Probe.cs", "class {", vocabulary); + brokenCode.Should().Throw(); + Action brokenConditional = () => _ = DemoLiteralViolations("Probe.cs", "#if NET10_0\nclass {\n#endif", vocabulary); + brokenConditional.Should().Throw("unreadable branches must not silently disappear"); + Action brokenDirective = () => _ = DemoLiteralViolations("Probe.cs", "#if NET10_0\nclass Probe {}", vocabulary); + brokenDirective.Should().Throw("unbalanced directives must remain syntax failures"); + } + + /// G20 also bans symbolic access to the Seeder from every production project. + [Fact] + public void Production_Projects_Do_Not_Reference_The_Seeder() + { + var projects = ProductionSourceRoots.SelectMany(root => Directory.GetFiles(root, "*.csproj", SearchOption.AllDirectories)) + .Where(path => Path.GetFileNameWithoutExtension(path) != SeederAssembly).ToArray(); + projects.Length.Should().BeGreaterThan(20); + projects.Select(Path.GetFileNameWithoutExtension).Should().Contain("LearnStack.Api").And.Contain("LearnStack.SharedKernel"); + foreach (var project in projects) + { + var name = Path.GetFileNameWithoutExtension(project); + var assembly = System.Reflection.Assembly.Load(name); + assembly.GetReferencedAssemblies().Select(row => row.Name).Should().NotContain(SeederAssembly, + $"{name}: Fix: depend on application contracts, never demo SeedData"); + var assets = Path.Combine(Path.GetDirectoryName(project)!, "obj/project.assets.json"); + File.Exists(assets).Should().BeTrue("restore-resolved references must exist"); + ModuleDependencyTests.RestoredProjectReferences(File.ReadAllText(assets)).Where(IsSeederReference) + .Should().BeEmpty($"{name}: Fix: remove the production Seeder project reference"); + } + } + + [Fact] + public void Seeder_Reference_Fence_Detects_Compiled_And_Restored_Edges() + { + typeof(DemoIdentities).Assembly.GetName().Name.Should().Be(SeederAssembly); + typeof(DomainGenericityTests).Assembly.GetReferencedAssemblies().Select(row => row.Name) + .Should().Contain(SeederAssembly, "the test assembly is an actual compiled planted reference"); + var graph = """ + {"project":{"restore":{"frameworks":{"net10.0":{"projectReferences":{ + "C:\\repo\\LearnStack.Tools.Seeder.csproj":{},"/repo/LearnStack.SharedKernel.csproj":{}}}}}}} + """; + ModuleDependencyTests.RestoredProjectReferences(graph).Where(IsSeederReference).Should().ContainSingle(); + IsSeederReference("/repo/LearnStack.Tools.SeederCopy.csproj").Should().BeFalse(); + } + + private static readonly string[] IdentityKinds = [ + "id", "tenant-slug", "tenant-name", "host", "organization-slug", "organization-name", + "content-type-key", "taxonomy-key", "definition-label", "band-key", "band-label", + "course-key", "course-slug", "course-title", "lesson-slug", "lesson-title", "property-name", "field-label" + ]; + private static readonly string[] ProductionSourceRoots = [RepositoryPaths.BackendSrc(), + Path.Combine(RepositoryPaths.RepoRoot(), "backend/analyzers")]; + + private const string SeederAssembly = "LearnStack.Tools.Seeder"; + private static bool IsSeederReference(string path) => + Path.GetFileNameWithoutExtension(path.Replace('\\', '/').Split('/')[^1]) == SeederAssembly; + + private static string[] DemoLiteralViolations(string path, string source, DemoIdentities vocabulary) + { + var ids = vocabulary.Values.Where(row => row.Kind == "id").Select(row => Guid.Parse(row.Value)).ToHashSet(); + var strings = vocabulary.Values.Where(row => row.Kind != "id").Select(row => row.Value) + .Except(vocabulary.PlatformValues, StringComparer.Ordinal).ToHashSet(StringComparer.Ordinal); + return (Path.GetExtension(path) == ".json" ? JsonValues(source) : CSharpValues(source)) + .Where(value => strings.Contains(value) || Guid.TryParse(value, out var id) && ids.Contains(id)) + .Distinct(StringComparer.Ordinal).ToArray(); + } + + private static IEnumerable CSharpValues(string source) + { + foreach (var syntax in CSharpConditionalConfigurations(source)) + foreach (var node in syntax.GetRoot().DescendantNodes()) + { + var value = node switch + { + LiteralExpressionSyntax literal when literal.IsKind(SyntaxKind.StringLiteralExpression) + || literal.IsKind(SyntaxKind.Utf8StringLiteralExpression) => literal.Token.ValueText, + InterpolatedStringExpressionSyntax template when template.Contents.All(part => part is InterpolatedStringTextSyntax) => + string.Concat(template.Contents.Cast().Select(part => part.TextToken.ValueText)), + _ => null + }; + if (value is null) continue; + yield return value; + // JSON embedded in C# is a source of complete decoded values too. + string[] nested; + try { nested = value.TrimStart().StartsWith('{') || value.TrimStart().StartsWith('[') ? JsonValues(value).ToArray() : []; } + catch (JsonException) { nested = []; } // Ordinary text/SQL may start with a bracket. + foreach (var inner in nested) yield return inner; + } + } + + private static List CSharpConditionalConfigurations(string source) + { + // Parse configurations independently: flattening branches can let an inactive + // comment or raw literal swallow code compiled in another configuration. + const int maximumSymbols = 8; + var symbols = new SortedSet(StringComparer.Ordinal); + while (true) + { + var names = symbols.ToArray(); + var trees = new List(); + for (var mask = 0; mask < (1 << names.Length); mask++) + { + var defined = names.Where((_, index) => (mask & (1 << index)) != 0); + var tree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default.WithPreprocessorSymbols(defined)); + if (tree.GetDiagnostics().Any(row => row.Severity == DiagnosticSeverity.Error)) + throw new InvalidOperationException("Unreadable production C#; Fix: restore syntax in every preprocessor configuration."); + trees.Add(tree); + foreach (var directive in tree.GetRoot().DescendantTrivia(descendIntoTrivia: true) + .Select(trivia => trivia.GetStructure()).OfType()) + { + ExpressionSyntax? condition = directive switch + { + IfDirectiveTriviaSyntax value => value.Condition, + ElifDirectiveTriviaSyntax value => value.Condition, + _ => null + }; + if (condition is not null) + foreach (var identifier in condition.DescendantNodesAndSelf().OfType()) + symbols.Add(identifier.Identifier.ValueText); + } + if (symbols.Count > maximumSymbols) + throw new InvalidOperationException("Preprocessor census exceeds eight symbols; Fix: review this source explicitly before widening the bounded scan."); + } + // A configuration can expose directives hidden inside another branch's + // comment. Discover symbols to a fixed point before accepting the census. + if (names.Length == symbols.Count) return trees; + } + } + + private static string[] JsonValues(string source) + { + using var document = JsonDocument.Parse(source); + return Walk(document.RootElement).ToArray(); + static IEnumerable Walk(JsonElement node) => node.ValueKind switch + { + JsonValueKind.String => [node.GetString()!], + JsonValueKind.Array => node.EnumerateArray().SelectMany(Walk), + JsonValueKind.Object => node.EnumerateObject().SelectMany(row => Walk(row.Value)), + _ => [] + }; + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs new file mode 100644 index 00000000..8807cc6d --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs @@ -0,0 +1,149 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Xml.Linq; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class DomainGenericityTests +{ + private static readonly string[] DemoOutputProperties = ["BaseOutputPath", "BaseIntermediateOutputPath"]; + + private static string[] DemoProductionSourceCensus() + { + var projects = ProductionSourceRoots.SelectMany(root => Directory.EnumerateFiles(root, "*.csproj", SearchOption.AllDirectories)) + .Order(StringComparer.Ordinal).ToArray(); + var evaluated = projects.Select(EvaluatedDemoCompile).ToArray(); + RequireProductionProjectCoverage(projects, evaluated.SelectMany(row => row.References)); + var outputs = evaluated.SelectMany(row => row.Outputs).Distinct(StringComparer.Ordinal).ToArray(); + var census = ProductionSourceRoots.SelectMany(root => Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + .Where(path => !OmitIgnoredDemoLocalFile(path)).ToArray(); + var files = census.Where(path => BackendDemoSourceKind(path, outputs) == 1).ToArray(); + // Evaluation includes explicit/linked Compile items as well as the SDK's default glob. + // A compiler input outside the supported census must refuse, never disappear. + RequireCompileCoverage(files, evaluated.SelectMany(row => row.Sources)); + foreach (var file in census + .Where(path => BackendDemoSourceKind(path, outputs) == 2) + .Concat([Path.Combine(RepositoryPaths.RepoRoot(), "backend/Directory.Build.props"), + Path.Combine(RepositoryPaths.RepoRoot(), "backend/Directory.Packages.props")]) + .Where(path => Path.GetExtension(path) is ".csproj" or ".props" or ".targets")) + RefuseExecutionTimeCompile(File.ReadAllText(file)); + return files; + } + + private static bool IsDemoLocalArtifact(string path) => Path.GetFileName(path) is + ".env" or ".env.local" or ".env.development" or ".env.test" or ".env.production" + or ".env.development.local" or ".env.test.local" or ".env.production.local" or ".DS_Store" or "Thumbs.db" + || Path.GetExtension(path) is ".swp" or ".log" or ".suo" or ".rsuser"; + + private static bool OmitIgnoredDemoLocalFile(string path) + { + if (!IsDemoLocalArtifact(path)) return false; + using var child = new Process + { + StartInfo = new ProcessStartInfo("git") + { + ArgumentList = { "check-ignore", "--quiet", "--", path }, + WorkingDirectory = RepositoryPaths.RepoRoot(), + RedirectStandardError = true, + } + }; + child.Start(); + var error = child.StandardError.ReadToEndAsync(); + if (!child.WaitForExit(10_000)) + { + child.Kill(entireProcessTree: true); + child.WaitForExit(); + throw new InvalidOperationException("Git ignored-local-file admission deadline."); + } + error.GetAwaiter().GetResult(); + return child.ExitCode switch + { + 0 => true, + 1 => false, + _ => throw new InvalidOperationException("Git ignored-local-file admission failed.") + }; + } + + private static int BackendDemoSourceKind(string path, IEnumerable outputs) + { + path = Path.GetFullPath(path); + if (outputs.Any(root => path.StartsWith(Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, StringComparison.Ordinal))) + return 0; + if (Path.GetExtension(path) is ".cs" or ".json") return 1; + if (Path.GetExtension(path) is ".csproj" or ".props" or ".targets" or ".md" || Path.GetFileName(path) == ".editorconfig") return 2; + throw new InvalidOperationException($"Unclassified backend production source: {path}; Fix: review its census ownership."); + } + + private static void RequireCompileCoverage(IEnumerable files, IEnumerable compiled) + { + var admitted = files.Select(Path.GetFullPath).ToHashSet(StringComparer.Ordinal); + if (compiled.Any(path => !admitted.Contains(Path.GetFullPath(path)))) + throw new InvalidOperationException("Unclassified Compile input; Fix: include the linked source in the supported production census."); + } + + private static void RequireProductionProjectCoverage(IEnumerable projects, IEnumerable references) + { + var admitted = projects.Select(Path.GetFullPath).ToHashSet(StringComparer.Ordinal); + if (references.Any(path => !admitted.Contains(Path.GetFullPath(path)))) + throw new InvalidOperationException("Unclassified production ProjectReference; Fix: include its compiler inputs in the supported census."); + } + + // Property-selected SDK hooks can import targets absent from evaluation-only Compile items. + // New build properties must be reviewed rather than silently expanding this bounded model. + private static readonly HashSet DemoBuildProperties = new(StringComparer.Ordinal) + { + "AnalysisLevel", "AnalysisMode", "AssemblyName", "EnforceCodeStyleInBuild", + "EnforceExtendedAnalyzerRules", "GenerateDocumentationFile", "ImplicitUsings", "IsPackable", + "LangVersion", "ManagePackageVersionsCentrally", "NoWarn", "NuGetAuditLevel", "NuGetAuditMode", + "Nullable", "OutputType", "RootNamespace", "TargetFramework", "TreatWarningsAsErrors", + "UserSecretsId", "WarningsNotAsErrors" + }; + + private static void RefuseExecutionTimeCompile(string source) + { + var document = XDocument.Parse(source); + const string modulesImport = "$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))"; + if (document.Root?.Elements().Any(node => node.Name.LocalName is not ("PropertyGroup" or "ItemGroup" or "Import")) == true + || document.Descendants().Where(node => node.Name.LocalName == "PropertyGroup") + .SelectMany(node => node.Elements()).Any(node => !DemoBuildProperties.Contains(node.Name.LocalName)) + || document.Descendants().Any(node => node.Name.LocalName is "Target" or "Sdk" + || node.Name.LocalName == "Import" && (string?)node.Attribute("Project") != modulesImport) + || document.Root?.Attribute("Sdk") is { } sdk && sdk.Value is not ("Microsoft.NET.Sdk" or "Microsoft.NET.Sdk.Web")) + throw new InvalidOperationException("Execution-time Compile/import shapes are unmodeled; Fix: review compiler-input admission before introducing a custom target or import."); + } + + private static (string[] Sources, string[] Outputs, string[] References) EvaluatedDemoCompile(string project) + { + using var child = new Process + { + StartInfo = new ProcessStartInfo("dotnet") + { + ArgumentList = { "msbuild", project, "-getItem:Compile,ProjectReference", "-getProperty:BaseOutputPath,BaseIntermediateOutputPath", "-p:Configuration=Release", "-nologo" }, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = Path.GetDirectoryName(project)!, + } + }; + child.Start(); + var output = child.StandardOutput.ReadToEndAsync(); + var error = child.StandardError.ReadToEndAsync(); + if (!child.WaitForExit(120_000)) + { + child.Kill(entireProcessTree: true); + child.WaitForExit(); + throw new InvalidOperationException("MSBuild Compile census deadline."); + } + Task.WaitAll(output, error); + if (child.ExitCode != 0) throw new InvalidOperationException("MSBuild Compile census failed."); + using var document = JsonDocument.Parse(output.Result); + var sources = document.RootElement.GetProperty("Items").GetProperty("Compile").EnumerateArray() + .Select(row => Path.GetFullPath(row.GetProperty("FullPath").GetString()!)).ToArray(); + var references = document.RootElement.GetProperty("Items").GetProperty("ProjectReference").EnumerateArray() + .Select(row => Path.GetFullPath(row.GetProperty("FullPath").GetString()!)).ToArray(); + var properties = document.RootElement.GetProperty("Properties"); + var outputs = DemoOutputProperties + .Select(name => properties.GetProperty(name).GetString()!) + .Select(path => Path.GetFullPath(path.Replace('\\', Path.DirectorySeparatorChar), Path.GetDirectoryName(project)!)).ToArray(); + return (sources, outputs, references); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs index 36d4f2dd..35c588b4 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Net; using System.Net.Http.Json; +using System.Security.Cryptography; using System.Text.Json; using System.Text.RegularExpressions; using FluentAssertions; @@ -19,11 +20,21 @@ public sealed class PublicLocalLaunchTests(PublicReadFixture fixture) [Fact] public async Task Public_local_API_launcher_serves_seeded_reads_without_a_PlatformAdmin_credential() + => await AssertLauncherAsync(useDemoRuntime: false); + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Demo_API_runtime_rejects_a_foreign_user_secret_only_with_profile_isolation(bool isolateProfile) + => await AssertLauncherAsync(useDemoRuntime: true, isolateProfile); + + private async Task AssertLauncherAsync(bool useDemoRuntime, bool isolateProfile = true) { var repository = RepositoryRoot(); var temporary = Path.Combine(Path.GetTempPath(), "learnstack-public-local-" + Guid.NewGuid().ToString("N")); var app = Path.Combine(temporary, "frontend/apps/web"); var backend = Path.Combine(temporary, "backend"); + var foreignSecret = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(3)); using var process = new Process(); var started = false; @@ -34,8 +45,23 @@ public async Task Public_local_API_launcher_serves_seeded_reads_without_a_Platfo Directory.CreateDirectory(Path.Combine(temporary, "scripts")); Directory.CreateDirectory(Path.Combine(app, "src/server")); Directory.CreateDirectory(Path.Combine(temporary, "user-secrets")); + Directory.CreateDirectory(Path.Combine(temporary, "owned-profile")); File.WriteAllText(Path.Combine(temporary, "package.json"), "{\"type\":\"module\"}"); File.Copy(Path.Combine(repository, "scripts/public-local.mjs"), Path.Combine(temporary, "scripts/public-local.mjs")); + File.Copy(Path.Combine(repository, "scripts/public-runtime.mjs"), Path.Combine(temporary, "scripts/public-runtime.mjs")); + if (!isolateProfile) + { + // Deliberately remove only the profile boundary in the owned copy. + // The same live request must now reach the site as an accepted hop. + var runtime = Path.Combine(temporary, "scripts/public-runtime.mjs"); + var source = File.ReadAllText(runtime); + foreach (var assignment in new[] { "HOME: profile,", "APPDATA: profile,", "TMPDIR: profile," }) + { + source.Should().Contain(assignment, "the mutation must change the shipped profile boundary"); + source = source.Replace(assignment, string.Empty, StringComparison.Ordinal); + } + File.WriteAllText(runtime, source); + } foreach (var file in new[] { "tsconfig.json", "tsconfig.server.json", "src/server/ingress.ts" }) File.Copy(Path.Combine(repository, "frontend/apps/web", file), Path.Combine(app, file)); Directory.CreateSymbolicLink(Path.Combine(app, "node_modules"), Path.Combine(repository, "frontend/apps/web/node_modules")); @@ -47,16 +73,47 @@ public async Task Public_local_API_launcher_serves_seeded_reads_without_a_Platfo // never depend on a preexisting ignored .server build artifact. await CompileIngressAsync(repository, app, deadline.Token); process.StartInfo = NodeStart(temporary); - process.StartInfo.ArgumentList.Add(Path.Combine(temporary, "scripts/public-local.mjs")); - process.StartInfo.ArgumentList.Add("api"); + if (useDemoRuntime) + { + // Exercise the shipped demo environment factory with a real API, + // while the launcher itself sees a planted foreign secret profile. + var secrets = Path.Combine(temporary, "user-secrets", "Microsoft", "UserSecrets", "learnstack-api-dev"); + Directory.CreateDirectory(secrets); + await File.WriteAllTextAsync(Path.Combine(secrets, "secrets.json"), JsonSerializer.Serialize(new + { + Tenancy = new { TrustedHop = new { Secrets = new Dictionary { ["1"] = foreignSecret } } } + }), deadline.Token); + var script = Path.Combine(temporary, "scripts/demo-api-probe.mjs"); + await File.WriteAllTextAsync(script, """ + import {spawn} from 'node:child_process'; + import {resolve} from 'node:path'; + import {publicDemoApiEnvironment} from './public-runtime.mjs'; + const root = resolve(import.meta.dirname, '..'); + const env = publicDemoApiEnvironment(process.env, { + apiOrigin: process.env.LEARNSTACK_PUBLIC_API_ORIGIN, + secret: process.env.LEARNSTACK_PUBLIC_HOP_SECRET + }, resolve(root, 'owned-profile')); + const child = spawn('dotnet', [resolve(root, 'backend/src/LearnStack.Api/bin/Release/net10.0/LearnStack.Api.dll')], { + cwd: resolve(root, 'backend/src/LearnStack.Api'), env, stdio: 'inherit' + }); + child.on('error', () => process.exit(1)); + child.on('exit', code => process.exit(code ?? 1)); + """, deadline.Token); + process.StartInfo.ArgumentList.Add(script); + } + else + { + process.StartInfo.ArgumentList.Add(Path.Combine(temporary, "scripts/public-local.mjs")); + process.StartInfo.ArgumentList.Add("api"); + } process.StartInfo.Environment["ConnectionStrings__Default"] = fixture.AppConnectionString; process.StartInfo.Environment["LEARNSTACK_PUBLIC_API_ORIGIN"] = "http://127.0.0.1:0"; process.StartInfo.Environment["LEARNSTACK_PUBLIC_HOP_SECRET"] = Secret; // API configuration validates paths but does not open renderer TLS files. process.StartInfo.Environment["LEARNSTACK_PUBLIC_TLS_CERT"] = "unused-public.pem"; process.StartInfo.Environment["LEARNSTACK_PUBLIC_TLS_KEY"] = "unused-public-key.pem"; - // WebApplication's Development user-secret provider otherwise consults - // the developer's profile. APPDATA selects an empty owned secret root. + // The older launcher gets an empty test profile. The demo case sees + // a populated foreign profile and must replace it with its owned one. process.StartInfo.Environment["APPDATA"] = Path.Combine(temporary, "user-secrets"); process.StartInfo.Environment.ContainsKey("ConnectionStrings__PlatformAdmin").Should().BeFalse(); @@ -84,6 +141,19 @@ public async Task Public_local_API_launcher_serves_seeded_reads_without_a_Platfo var site = await response.Content.ReadFromJsonAsync(deadline.Token); site.GetProperty("displayName").GetString().Should().Be(tenant.DisplayName); } + if (useDemoRuntime) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/public/site"); + request.Headers.Host = "gateway.internal"; + request.Headers.Add(TrustedHopOptions.HostHeaderName, SeedData.English.Host); + request.Headers.Add(TrustedHopOptions.SecretHeaderName, foreignSecret); + request.Headers.Add(AnonymousRequestIdentity.VisitorHeaderName, "203.0.113.90"); + using var response = await client.SendAsync(request, deadline.Token); + response.StatusCode.Should().Be(isolateProfile ? HttpStatusCode.NotFound : HttpStatusCode.OK, + "only the owned profile prevents a foreign secret from extending the trusted hop"); + File.Exists(Path.Combine(temporary, "user-secrets", "Microsoft", "UserSecrets", + "learnstack-api-dev", "secrets.json")).Should().BeTrue("the foreign profile is never modified"); + } } finally { diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicLocaleEvolutionHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicLocaleEvolutionHttpTests.cs new file mode 100644 index 00000000..4f85383d --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicLocaleEvolutionHttpTests.cs @@ -0,0 +1,604 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentAssertions; +using LearnStack.Modules.Education.Application.Contracts.Courses; +using LearnStack.Modules.Education.Application.Contracts.Lessons; +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Application.Contracts.Branding; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.Modules.Tenancy.Application.Contracts.PublicReads; +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.Tools.Seeder; +using MediatR; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Collection(SharedSchema.Name)] +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed class PublicLocaleEvolutionHttpTests(SchemaFixture schema, WebApplicationFactory factory) + : IClassFixture> +{ + private const string Root = "/api/v1/public/courses"; + private const string Site = "/api/v1/public/site"; + private const string FrenchCourseSlug = "pratique-francaise"; + private const string FrenchLessonSlug = "posture-stable"; + private const string FrenchCourseTitle = "Pratique en français"; + private const string FrenchLessonTitle = "Une posture stable"; + private const string FrenchSummary = "Un cours rédigé en français."; + // Body-member order deliberately differs from the pinned presentation order. + private const string FrenchBody = """{"breathing":"Respirez lentement.","pose":"Posture stable","instruction":"Tenez la posture."}"""; + private static SeedTenant Yoga => SeedData.Yoga; + + [Fact] + public async Task Third_locale_authored_through_api_commands_serves_exact_new_urls_without_changing_existing_responses_or_migrations() + { + await using var database = await SeededDatabaseAsync(); + var observation = new PublicReadObservation(); + await using var host = ApiHost(database, observation); + using var client = Client(host, Yoga.Host); + var context = new SeedTenantContext(Yoga.TenantId, null); + var organizationContext = new SeedTenantContext(Yoga.TenantId, Yoga.DefaultOrganization.OrganizationId); + var baselinePaths = new[] + { + Root + "?locale=en", Root + "?locale=tr-TR", + Root + "/foundation?locale=en", Root + "/studyo-temelleri?locale=tr-TR", + Root + "/foundation/lessons/tree-pose?locale=en", + Root + "/studyo-temelleri/lessons/agac-durusu?locale=tr-TR", + }; + var before = new Dictionary(StringComparer.Ordinal); + foreach (var path in baselinePaths) before.Add(path, await JsonAsync(client, path)); + var siteBefore = await JsonAsync(client, Site); + var historiesBefore = await MigrationHistoryAsync(database.MigrationConnectionString); + var tenantBefore = (await SendAsync(host, context, new GetTenantSeedStateQuery())).State!; + var existingCourse = Yoga.Curriculum!.Courses[1]; + var existingLesson = existingCourse.Lessons[0]; + var courseBefore = (await SendAsync(host, organizationContext, new GetCourseSeedStateQuery(existingCourse.Id))).State!; + var lessonBefore = (await SendAsync(host, organizationContext, new GetLessonSeedStateQuery(existingLesson.Id))).State!; + + var locales = await SendAsync(host, context, new AddTenantLocaleCommand(tenantBefore.Version, "fr", true, false, 0)); + locales.Version.Should().Be(tenantBefore.Version + 1); + locales.Locales.Where(locale => locale.Locale != "fr").Should().BeEquivalentTo( + tenantBefore.Locales, options => options.WithStrictOrdering()); + locales.Locales.Single(locale => locale.Locale == "fr").Should().Be(new TenantLocaleDto("fr", true, false, 0)); + + // Published seeds cannot accept translations. Author a new French-only + // draft through the existing writers, then publish each root normally. + var course = await SendAsync(host, context, new CreateCourseCommand(Guid.CreateVersion7(), "third-locale", "public")); + course.Status.Should().Be("Draft"); + course = await SendAsync(host, context, new AddCourseTranslationCommand( + course.Id, course.Version, "fr", FrenchCourseTitle, FrenchSummary, FrenchCourseSlug)); + var contentType = Yoga.Curriculum.ContentType; + var lesson = await SendAsync(host, context, new CreateLessonCommand( + Guid.CreateVersion7(), course.Id, 0, contentType.Key, contentType.SchemaVersion)); + lesson.Status.Should().Be("Draft"); + lesson = await SendAsync(host, context, new AddLessonTranslationCommand( + lesson.Id, lesson.Version, "fr", FrenchLessonTitle, FrenchLessonSlug, FrenchBody)); + lesson = await SendAsync(host, context, new PublishLessonCommand(lesson.Id, lesson.Version)); + course = await SendAsync(host, context, new PublishCourseCommand(course.Id, course.Version)); + lesson.Status.Should().Be("Published"); + course.Status.Should().Be("Published"); + course.ContentAccess.Should().Be("public"); + + var englishContext = new SeedTenantContext(SeedData.English.TenantId, null); + var englishBefore = (await SendAsync(host, englishContext, new GetTenantSeedStateQuery())).State!; + await SendAsync(host, englishContext, new AddTenantLocaleCommand(englishBefore.Version, "fr", true, false, 1)); + + var auditsAfterAuthoring = await CountsAsync(database.AppConnectionString, organizationContext); + var catalog = await JsonAsync(client, Root + "?locale=fr"); + var detail = await JsonAsync(client, $"{Root}/{FrenchCourseSlug}?locale=fr"); + var lessonDetail = await JsonAsync(client, $"{Root}/{FrenchCourseSlug}/lessons/{FrenchLessonSlug}?locale=fr"); + AssertFrenchResponses(catalog, detail, lessonDetail); + foreach (var (path, expected) in before) + JsonNode.DeepEquals(await JsonAsync(client, path), expected).Should().BeTrue( + "existing exact-locale DTOs, ordered fields/lessons and alternate pairs must remain unchanged at {0}", path); + + foreach (var locale in new[] { "en", "tr-TR" }) + { + await NotFoundAsync(client, $"{Root}/{FrenchCourseSlug}?locale={locale}"); + await NotFoundAsync(client, $"{Root}/{FrenchCourseSlug}/lessons/{FrenchLessonSlug}?locale={locale}"); + } + foreach (var path in new[] { "/foundation", "/studyo-temelleri", "/foundation/lessons/tree-pose", "/studyo-temelleri/lessons/agac-durusu" }) + await NotFoundAsync(client, Root + path + "?locale=fr"); + // Both hosts now admit French, and these exact URLs succeeded on Yoga. + // The foreign refusal therefore discriminates tenant isolation from an + // unsupported locale or a translation missing on the owning tenant. + var englishFrench = await JsonAsync(client, $"http://{SeedData.English.Host}{Root}?locale=fr"); + englishFrench["locale"]!.GetValue().Should().Be("fr"); + englishFrench["items"]!.AsArray().Should().BeEmpty(); + await NotFoundAsync(client, $"http://{SeedData.English.Host}{Root}/{FrenchCourseSlug}?locale=fr"); + await NotFoundAsync(client, $"http://{SeedData.English.Host}{Root}/{FrenchCourseSlug}/lessons/{FrenchLessonSlug}?locale=fr"); + await NotFoundAsync(client, $"{Root}/foundation/lessons/{FrenchLessonSlug}?locale=fr"); + + var siteAfter = await JsonAsync(client, Site); + var expectedLocales = locales.Locales.Where(locale => locale.IsEnabled).Select(locale => locale.Locale).ToArray(); + siteBefore["enabledLocales"]!.AsArray().Select(locale => locale!.GetValue()).Should().Equal("tr-TR", "en"); + expectedLocales.Should().Equal("fr", "tr-TR", "en"); + siteAfter["enabledLocales"]!.AsArray().Select(locale => locale!.GetValue()).Should().Equal(expectedLocales); + siteBefore.AsObject().Remove("enabledLocales"); + siteAfter.AsObject().Remove("enabledLocales"); + JsonNode.DeepEquals(siteAfter, siteBefore).Should().BeTrue("only the exact enabled-locale delta may change the bootstrap"); + (await SendAsync(host, organizationContext, new GetCourseSeedStateQuery(existingCourse.Id))).State + .Should().BeEquivalentTo(courseBefore, options => options.WithStrictOrdering()); + (await SendAsync(host, organizationContext, new GetLessonSeedStateQuery(existingLesson.Id))).State + .Should().BeEquivalentTo(lessonBefore, options => options.WithStrictOrdering()); + var authoredLesson = (await SendAsync(host, context, new GetLessonSeedStateQuery(lesson.Id))).State!; + authoredLesson.TenantId.Should().Be(Yoga.TenantId); + authoredLesson.OrganizationId.Should().BeNull(); + authoredLesson.CourseId.Should().Be(course.Id); + authoredLesson.ContentTypeKey.Should().Be(contentType.Key); + authoredLesson.ContentTypeSchemaVersion.Should().Be(contentType.SchemaVersion); + authoredLesson.Translations.Should().ContainSingle().Which.Locale.Should().Be("fr"); + (await MigrationHistoryAsync(database.MigrationConnectionString)).Should().BeEquivalentTo( + historiesBefore, options => options.WithStrictOrdering()); + (await CountsAsync(database.AppConnectionString, organizationContext)).Should().Be(auditsAfterAuthoring, + "successful and hidden public reads must add no setting or audit rows"); + observation.Reads.Should().BeGreaterThan(0, "the production reader must positively observe learnstack_app under physical READ ONLY"); + } + + [Fact] + public async Task Organization_branding_writer_is_refused_with_denied_audit_and_mapped_host_keeps_tenant_palette() + { + await using var database = await SeededDatabaseAsync(); + var observation = new PublicReadObservation(); + await using var host = ApiHost(database, observation); + using var client = Client(host, Yoga.Host); + var context = new SeedTenantContext(Yoga.TenantId, Yoga.DefaultOrganization.OrganizationId); + var before = await JsonAsync(client, Site); + var mapped = await SendAsync(host, context, new GetHostMappingSeedStateQuery(Yoga.Host)); + mapped.State!.OrganizationId.Should().Be(Yoga.DefaultOrganization.OrganizationId); + mapped.State.IsPubliclyLive.Should().BeTrue(); + var settingBefore = (await SendAsync(host, context, new GetSettingSeedStateQuery(Yoga.Curriculum!.Theme.Id))).State!; + settingBefore.OrganizationId.Should().BeNull(); + JsonNode.DeepEquals(before["theme"], JsonNode.Parse(settingBefore.Value)).Should().BeTrue(); + var countsBefore = await CountsAsync(database.AppConnectionString, context); + var deniedBefore = await BrandingDenialsAsync(database.AppConnectionString, context); + await using (var scope = host.Services.CreateAsyncScope()) + { + var accessor = scope.ServiceProvider.GetRequiredService(); + var previous = accessor.Current; + accessor.Current = context; + try + { + var result = await scope.ServiceProvider.GetRequiredService().Send(new SetTenantBrandingCommand( + settingBefore.Id, """{"primary":"#2345aa","background":"#ffffff","foreground":"#111111","muted":"#555555"}""", settingBefore.Version)); + result.IsFailure.Should().BeTrue(); + result.Error!.Code.Should().Be("resource_scope_violation"); + } + finally { accessor.Current = previous; } + } + (await SendAsync(host, context, new GetSettingSeedStateQuery(settingBefore.Id))).State.Should().BeEquivalentTo(settingBefore); + var after = await JsonAsync(client, Site); + JsonNode.DeepEquals(after, before).Should().BeTrue(); + var countsAfter = await CountsAsync(database.AppConnectionString, context); + countsAfter.Settings.Should().Be(countsBefore.Settings); + countsAfter.SuccessfulBrandingAudits.Should().Be(countsBefore.SuccessfulBrandingAudits); + countsAfter.Audits.Should().Be(countsBefore.Audits + 1, "the normal MUST audit pipeline retains the denied authoring attempt"); + (await BrandingDenialsAsync(database.AppConnectionString, context)).Should().Be(deniedBefore + 1); + observation.Reads.Should().BeGreaterThan(0); + } + + [Fact] + public async Task Public_host_locale_and_organization_contracts_hold_together_with_exact_request_connection_translation_scope() + { + await using var database = await SeededDatabaseAsync(); + var observation = new PublicReadObservation(); + var scopes = new TranslationScopeObservation(); + await using var host = ApiHost(database, observation, scopes); + using var client = Client(host, Yoga.Host); + var context = new SeedTenantContext(Yoga.TenantId, null); + const string tenantHost = "yoga-tenant-scope.example.test"; + var mapping = await SendAsync(host, context, new MapHostToTenantCommand(tenantHost, null, true, true)); + mapping.TenantId.Should().Be(Yoga.TenantId); + mapping.OrganizationId.Should().BeNull(); + var tenant = (await SendAsync(host, context, new GetTenantSeedStateQuery())).State!; + await SendAsync(host, context, new AddTenantLocaleCommand(tenant.Version, "fr", true, false, 2)); + var probe = await AuthorLocaleProbeAsync(host, context); + scopes.Expect(SeedData.English.Host, SeedData.English, null, null); + scopes.Expect(Yoga.Host, Yoga, Yoga.DefaultOrganization.OrganizationId, probe); + scopes.Expect(tenantHost, Yoga, null, probe); + var requests = 0; + + // The same supported route and locale positively resolve B's unique + // course, then conceal it on A's already live host. + var yogaOnly = Yoga.Curriculum!.Courses.Single(course => course.SlugKey == "shared-practice"); + var yogaOnlySlug = yogaOnly.Translations.Single(row => row.Locale == "en").Slug; + JsonNode.DeepEquals(await Read(Yoga.Host, $"/{yogaOnlySlug}?locale=en"), + ExpectedSeedCourse(Yoga, yogaOnly.Id, "en")).Should().BeTrue(); + await Hidden(SeedData.English.Host, $"/{yogaOnlySlug}?locale=en"); + + // Stable source identities select the full expected public payloads; + // neither accepted public DTO exposes its source ID. + var englishShared = SeedData.English.Curriculum!.Courses.Single(course => course.SlugKey == "foundations"); + var yogaShared = Yoga.Curriculum.Courses.Single(course => course.SlugKey == "studio-foundations"); + var englishDetail = await Read(SeedData.English.Host, "/foundation?locale=en"); + var yogaDetail = await Read(Yoga.Host, "/foundation?locale=en"); + JsonNode.DeepEquals(englishDetail, ExpectedSeedCourse(SeedData.English, englishShared.Id, "en")).Should().BeTrue(); + JsonNode.DeepEquals(yogaDetail, ExpectedSeedCourse(Yoga, yogaShared.Id, "en")).Should().BeTrue(); + JsonNode.DeepEquals(englishDetail, yogaDetail).Should().BeFalse(); + + var englishProbe = await Read(Yoga.Host, "/locale-probe?locale=en"); + englishProbe["course"]!["title"]!.GetValue().Should().Be("Locale probe"); + Slugs(englishProbe["lessons"]!["items"]!).Should().Equal("english-lesson"); + var turkishProbe = await Read(Yoga.Host, "/yerel-prova?locale=tr-TR"); + Slugs(turkishProbe["lessons"]!["items"]!).Should().Equal("yalniz-turkce"); + var translatedLesson = await Read(Yoga.Host, "/locale-probe/lessons/english-lesson?locale=en"); + translatedLesson["lesson"]!["title"]!.GetValue().Should().Be("English lesson"); + var otherLocaleLesson = await Read(Yoga.Host, "/yerel-prova/lessons/yalniz-turkce?locale=tr-TR"); + otherLocaleLesson["lesson"]!["title"]!.GetValue().Should().Be("Yalnız Türkçe"); + await Hidden(Yoga.Host, "/locale-probe/lessons/yalniz-turkce?locale=en"); + await Hidden(Yoga.Host, "/locale-probe?locale=fr"); + var frenchCatalog = await Read(Yoga.Host, "?locale=fr"); + frenchCatalog["locale"]!.GetValue().Should().Be("fr"); + Slugs(frenchCatalog["items"]!).Should().BeEmpty("French is enabled but none of these published courses has a French translation"); + + var organizationCatalog = await Read(Yoga.Host, "?locale=en"); + Slugs(organizationCatalog["items"]!).Should().Equal( + SeedCatalogSlugs(Yoga, Yoga.DefaultOrganization.OrganizationId, "en").Append("locale-probe")); + Slugs((await Read(Yoga.Host, "?locale=tr-TR"))["items"]!).Should().Contain("yerel-prova"); + var sibling = Yoga.Curriculum.Courses.Single(course => course.OrganizationId == Yoga.SecondOrganization.OrganizationId); + await Hidden(Yoga.Host, "/" + sibling.Translations.Single(row => row.Locale == "en").Slug + "?locale=en"); + var tenantCatalog = await Read(tenantHost, "?locale=en"); + Slugs(tenantCatalog["items"]!).Should().Equal(SeedCatalogSlugs(Yoga, null, "en").Append("locale-probe")); + await Hidden(tenantHost, "/foundation?locale=en"); + JsonNode.DeepEquals(await Read(tenantHost, $"/{yogaOnlySlug}?locale=en"), + ExpectedSeedCourse(Yoga, yogaOnly.Id, "en")).Should().BeTrue(); + + // Each successful/hidden HTTP request passes the raw satellite observer + // exactly once on its own physically read-only application transaction. + scopes.Reads.Should().Be(requests); + observation.Reads.Should().Be(requests); + + async Task Read(string hostname, string path) + { + requests++; + return await JsonAsync(client, $"http://{hostname}{Root}{path}"); + } + async Task Hidden(string hostname, string path) + { + requests++; + await NotFoundAsync(client, $"http://{hostname}{Root}{path}"); + } + } + + private async Task SeededDatabaseAsync() + { + var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + try + { + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var runner = new SeedRunner(context => SeedComposition.Build(source, context, NullLoggerFactory.Instance), NullLogger.Instance); + (await runner.RunAsync(CancellationToken.None)).Should().Be(0); + return database; + } + catch + { + await database.DisposeAsync(); + throw; + } + } + + private WebApplicationFactory ApiHost(DisposableSchemaDatabase database, PublicReadObservation observation, + TranslationScopeObservation? scopes = null) => + factory.WithWebHostBuilder(builder => builder + .UseEnvironment(Environments.Development) + .UseSetting("ConnectionStrings:Default", database.AppConnectionString) + .UseSetting("ConnectionStrings:PlatformAdmin", database.PlatformConnectionString) + .ConfigureTestServices(services => + { + services.AddSingleton(observation); + services.AddHttpContextAccessor(); + services.AddScoped(); + // This existing observer checks the actual enlisted transaction's + // READ ONLY, current_user, superuser and BYPASSRLS flags. + services.AddScoped(); + services.AddScoped(provider => scopes is null + ? provider.GetRequiredService() + : new RequestTranslationScopeReader(provider.GetRequiredService(), + provider.GetRequiredService(), provider.GetRequiredService(), + provider.GetRequiredService(), scopes)); + })); + + private static HttpClient Client(WebApplicationFactory host, string hostname) => + host.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri($"http://{hostname}/") }); + + private static async Task SendAsync(WebApplicationFactory host, ITenantContext context, IRequest> request) + { + await using var scope = host.Services.CreateAsyncScope(); + var accessor = scope.ServiceProvider.GetRequiredService(); + var previous = accessor.Current; + accessor.Current = context; + try + { + var result = await scope.ServiceProvider.GetRequiredService().Send(request); + result.IsSuccess.Should().BeTrue("the normal API pipeline must accept {0}: {1}", request.GetType().Name, result.Error?.Code); + return result.Value!; + } + finally { accessor.Current = previous; } + } + + private static async Task AuthorLocaleProbeAsync(WebApplicationFactory host, SeedTenantContext context) + { + var course = await SendAsync(host, context, new CreateCourseCommand(Guid.CreateVersion7(), "locale-probe", "public")); + course = await SendAsync(host, context, new AddCourseTranslationCommand(course.Id, course.Version, + "en", "Locale probe", null, "locale-probe")); + course = await SendAsync(host, context, new AddCourseTranslationCommand(course.Id, course.Version, + "tr-TR", "Yerel prova", null, "yerel-prova")); + var contentType = Yoga.Curriculum!.ContentType; + const string body = """{"pose":"Probe pose","instruction":"Hold steadily.","breathing":"Breathe slowly."}"""; + var turkish = await SendAsync(host, context, new CreateLessonCommand(Guid.CreateVersion7(), course.Id, 0, + contentType.Key, contentType.SchemaVersion)); + turkish = await SendAsync(host, context, new AddLessonTranslationCommand(turkish.Id, turkish.Version, + "tr-TR", "Yalnız Türkçe", "yalniz-turkce", body)); + await SendAsync(host, context, new PublishLessonCommand(turkish.Id, turkish.Version)); + var english = await SendAsync(host, context, new CreateLessonCommand(Guid.CreateVersion7(), course.Id, 1, + contentType.Key, contentType.SchemaVersion)); + english = await SendAsync(host, context, new AddLessonTranslationCommand(english.Id, english.Version, + "en", "English lesson", "english-lesson", body)); + await SendAsync(host, context, new PublishLessonCommand(english.Id, english.Version)); + await SendAsync(host, context, new PublishCourseCommand(course.Id, course.Version)); + return new LocaleProbe(context.TenantId, course.Id, turkish.Id, english.Id); + } + + private static JsonNode ExpectedSeedCourse(SeedTenant tenant, Guid sourceId, string locale) + { + var curriculum = tenant.Curriculum!; + var course = curriculum.Courses.Single(row => row.Id == sourceId); + var translation = course.Translations.Single(row => row.Locale == locale); + curriculum.Taxonomy.Key.Should().Be(course.LevelTaxonomyKey); + curriculum.Taxonomy.SchemaVersion.Should().Be(course.LevelTaxonomySchemaVersion); + var band = curriculum.Taxonomy.Bands.Single(row => row.Key == course.LevelBandKey); + var alternates = curriculum.Locales.Where(row => row.IsEnabled && row.Locale != locale) + .OrderBy(row => row.Sort).ThenBy(row => row.Locale, StringComparer.Ordinal) + .Select(row => course.Translations.SingleOrDefault(item => item.Locale == row.Locale)) + .Where(row => row is not null).Select(row => new { locale = row!.Locale, slug = row.Slug }).ToArray(); + object? outline = course.ContentAccess == "public" ? new + { + items = course.Lessons.Where(row => row.Status == "Published" && row.Translations.Any(item => item.Locale == locale)) + .OrderBy(row => row.Sort).ThenBy(row => row.Id).Select(row => new + { + slug = row.Translations.Single(item => item.Locale == locale).Slug, + title = row.Translations.Single(item => item.Locale == locale).Title, + sort = row.Sort, + }).ToArray(), + pageInfo = new { nextCursor = (string?)null, previousCursor = (string?)null, hasNext = false, hasPrevious = false }, + } : null; + return JsonSerializer.SerializeToNode(new + { + locale, + course = new + { + slug = translation.Slug, + title = translation.Title, + summary = translation.Summary, + contentAccess = course.ContentAccess, + level = new { state = "ready", label = new { value = band.DisplayName[locale], locale } }, + }, + alternates, + lessons = outline, + })!; + } + + private static string[] SeedCatalogSlugs(SeedTenant tenant, OrganizationId? organization, string locale) => + tenant.Curriculum!.Courses.Where(row => row.Status == "Published" + && (row.OrganizationId is null || row.OrganizationId == organization)) + .Select(row => row.Translations.SingleOrDefault(item => item.Locale == locale)) + .Where(row => row is not null).Select(row => row!.Slug).ToArray(); + + private static string[] Slugs(JsonNode items) => items.AsArray().Select(item => item!["slug"]!.GetValue()).ToArray(); + + private static void AssertFrenchResponses(JsonNode catalog, JsonNode course, JsonNode lesson) + { + var summary = JsonSerializer.SerializeToNode(new { slug = FrenchCourseSlug, title = FrenchCourseTitle, summary = FrenchSummary, contentAccess = "public", level = (object?)null }); + catalog["locale"]!.GetValue().Should().Be("fr"); + catalog["items"]!.AsArray().Should().ContainSingle(); + JsonNode.DeepEquals(catalog["items"]![0], summary).Should().BeTrue(); + course["locale"]!.GetValue().Should().Be("fr"); + JsonNode.DeepEquals(course["course"], summary).Should().BeTrue(); + course["alternates"]!.AsArray().Should().BeEmpty(); + var outline = course["lessons"]!["items"]!.AsArray(); + outline.Should().ContainSingle(); + JsonNode.DeepEquals(outline[0], JsonSerializer.SerializeToNode(new { slug = FrenchLessonSlug, title = FrenchLessonTitle, sort = 0 })).Should().BeTrue(); + lesson["locale"]!.GetValue().Should().Be("fr"); + JsonNode.DeepEquals(lesson["course"], JsonSerializer.SerializeToNode(new { slug = FrenchCourseSlug, title = FrenchCourseTitle })).Should().BeTrue(); + JsonNode.DeepEquals(lesson["lesson"], JsonSerializer.SerializeToNode(new { slug = FrenchLessonSlug, title = FrenchLessonTitle })).Should().BeTrue(); + lesson["alternates"]!.AsArray().Should().BeEmpty(); + var content = lesson["content"]!; + content["state"]!.GetValue().Should().Be("ready"); + content["rendererKey"]!.GetValue().Should().Be("default-card"); + // French body/title are exact; definition labels use the accepted + // requested -> tenant-default -> en fallback independently. + JsonNode.DeepEquals(content["label"], JsonSerializer.SerializeToNode(new { value = "Asana duruşu", locale = "tr-TR" })).Should().BeTrue(); + var expectedFields = JsonSerializer.SerializeToNode(new[] + { + new { name = "pose", label = new { value = "Duruş", locale = "tr-TR" }, value = "Posture stable" }, + new { name = "instruction", label = new { value = "Yönerge", locale = "tr-TR" }, value = "Tenez la posture." }, + new { name = "breathing", label = new { value = "Nefes", locale = "tr-TR" }, value = "Respirez lentement." }, + }); + JsonNode.DeepEquals(content["fields"], expectedFields).Should().BeTrue("the pinned field order and exact French values survive localization"); + } + + private static async Task JsonAsync(HttpClient client, string path) + { + using var response = await client.GetAsync(path); + response.StatusCode.Should().Be(HttpStatusCode.OK, await response.Content.ReadAsStringAsync()); + (response.Headers.CacheControl?.NoStore).Should().BeTrue(); + return (await response.Content.ReadFromJsonAsync())!; + } + + private static async Task NotFoundAsync(HttpClient client, string path) + { + using var response = await client.GetAsync(path); + response.StatusCode.Should().Be(HttpStatusCode.NotFound, await response.Content.ReadAsStringAsync()); + (response.Headers.CacheControl?.NoStore).Should().BeTrue(); + response.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json"); + var problem = (await response.Content.ReadFromJsonAsync())!; + problem["code"]!.GetValue().Should().Be("not_found"); + } + + private static async Task>> MigrationHistoryAsync(string connectionString) + { + // Owner access is limited to migration metadata, which the app role is + // deliberately not granted. Every behavior/isolation read uses the app role. + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + await using (var readOnly = new NpgsqlCommand("SET TRANSACTION READ ONLY", connection, transaction)) + await readOnly.ExecuteNonQueryAsync(); + var discovered = await SchemaQueries.ReadStringsAsync(connection, + "SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename LIKE '__ef_migrations_history_%' ORDER BY tablename", transaction); + discovered.Should().BeEquivalentTo(MigrationChains.HistoryTables); + var histories = new Dictionary>(StringComparer.Ordinal); + foreach (var table in discovered) + { + var rows = new List(); + await using var command = new NpgsqlCommand($"SELECT \"MigrationId\", \"ProductVersion\" FROM \"{table.Replace("\"", "\"\"", StringComparison.Ordinal)}\" ORDER BY \"MigrationId\"", connection, transaction); + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) rows.Add(new HistoryRow(reader.GetString(0), reader.GetString(1))); + rows.Should().NotBeEmpty("each deployed migration chain must be represented"); + histories.Add(table, rows); + } + return histories; + } + + private static async Task CountsAsync(string connectionString, SeedTenantContext context) + { + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, context.TenantId.Value); + await SchemaQueries.SetSettingAsync(connection, transaction, "app.organization_id", context.OrganizationId?.Value.ToString("D") ?? ""); + await SchemaQueries.AssertApplicationRoleAsync(connection); + await using var command = new NpgsqlCommand(""" + SELECT (SELECT count(*) FROM tenant_settings), + (SELECT count(*) FROM audit_log), + (SELECT count(*) FROM audit_log WHERE operation='tenancy.setting.write' AND outcome='success') + """, connection, transaction); + await using var reader = await command.ExecuteReaderAsync(); + (await reader.ReadAsync()).Should().BeTrue(); + return new WriteCounts(reader.GetInt64(0), reader.GetInt64(1), reader.GetInt64(2)); + } + + private static async Task BrandingDenialsAsync(string connectionString, SeedTenantContext context) + { + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, context.TenantId.Value); + await SchemaQueries.SetSettingAsync(connection, transaction, "app.organization_id", context.OrganizationId!.Value.Value.ToString("D")); + await SchemaQueries.AssertApplicationRoleAsync(connection); + await using var command = new NpgsqlCommand(""" + SELECT count(*) FROM audit_log + WHERE operation='tenancy.setting.write' AND outcome='denied' + AND error_key='lockey_resource_scope_violation' AND organization_id=@organization + """, connection, transaction); + command.Parameters.AddWithValue("organization", context.OrganizationId.Value.Value); + return (long)(await command.ExecuteScalarAsync())!; + } + + private sealed record HistoryRow(string MigrationId, string ProductVersion); + private sealed record WriteCounts(long Settings, long Audits, long SuccessfulBrandingAudits); + private sealed record LocaleProbe(TenantId TenantId, Guid CourseId, Guid TurkishLessonId, Guid EnglishLessonId); + private sealed record TranslationRow(Guid TenantId, Guid? OrganizationId, Guid RootId, string Locale, string Slug); + private sealed record TranslationScope(TenantId TenantId, OrganizationId? OrganizationId, + TranslationRow[] Courses, TranslationRow[] Lessons); + + private sealed class TranslationScopeObservation + { + private readonly Dictionary _expected = new(StringComparer.Ordinal); + private int _reads; + public int Reads => Volatile.Read(ref _reads); + + public void Expect(string hostname, SeedTenant tenant, OrganizationId? organization, LocaleProbe? probe) + { + // Raw scope deliberately includes every seeded translation regardless + // of publication/access/locale; these are RLS assertions, not the + // narrower public-eligibility projection. + var courses = tenant.Curriculum!.Courses + .Where(row => row.OrganizationId is null || row.OrganizationId == organization).ToArray(); + var courseRows = courses.SelectMany(course => course.Translations.Select(row => + new TranslationRow(tenant.TenantId.Value, course.OrganizationId?.Value, course.Id, row.Locale, row.Slug))).ToList(); + var lessonRows = courses.SelectMany(course => course.Lessons.SelectMany(lesson => lesson.Translations.Select(row => + new TranslationRow(tenant.TenantId.Value, course.OrganizationId?.Value, lesson.Id, row.Locale, row.Slug)))).ToList(); + if (probe is not null) + { + probe.TenantId.Should().Be(tenant.TenantId); + courseRows.AddRange([ + new TranslationRow(probe.TenantId.Value, null, probe.CourseId, "en", "locale-probe"), + new TranslationRow(probe.TenantId.Value, null, probe.CourseId, "tr-TR", "yerel-prova"), + ]); + lessonRows.AddRange([ + new TranslationRow(probe.TenantId.Value, null, probe.TurkishLessonId, "tr-TR", "yalniz-turkce"), + new TranslationRow(probe.TenantId.Value, null, probe.EnglishLessonId, "en", "english-lesson"), + ]); + } + courseRows.Should().NotBeEmpty(); + lessonRows.Should().NotBeEmpty(); + _expected.Add(hostname, new TranslationScope(tenant.TenantId, organization, courseRows.ToArray(), lessonRows.ToArray())); + } + + public async Task AssertAsync(string hostname, ITenantContext context, IUnitOfWork unit, CancellationToken cancellationToken) + { + var expected = _expected[hostname]; + context.TenantId.Should().Be(expected.TenantId); + context.OrganizationId.Should().Be(expected.OrganizationId); + context.HostScope.Should().NotBeNull(); + context.HostScope!.TenantId.Should().Be(expected.TenantId); + context.HostScope.OrganizationId.Should().Be(expected.OrganizationId); + unit.Mode.Should().Be(TransactionMode.ReadOnly); + unit.HasActiveTransaction.Should().BeTrue(); + unit.IsTenantContextIssuedOn(unit.Transaction).Should().BeTrue(); + await SchemaQueries.AssertApplicationRoleAsync(unit.Connection); + // No WHERE clause, EF query filter, replacement connection, or scope + // announcement: only the request's already enlisted RLS frame filters + // these two complete satellite-table reads. + var courses = await ReadRowsAsync(unit, + "SELECT tenant_id, organization_id, course_id, locale, slug FROM course_translations", cancellationToken); + var lessons = await ReadRowsAsync(unit, + "SELECT tenant_id, organization_id, lesson_id, locale, slug FROM lesson_translations", cancellationToken); + courses.Should().NotBeEmpty(); + lessons.Should().NotBeEmpty(); + courses.Should().BeEquivalentTo(expected.Courses, "raw course translations must match the exact scope of host {0}", hostname); + lessons.Should().BeEquivalentTo(expected.Lessons, "raw lesson translations must match the exact scope of host {0}", hostname); + Interlocked.Increment(ref _reads); + } + + private static async Task> ReadRowsAsync(IUnitOfWork unit, string sql, CancellationToken cancellationToken) + { + await using var command = new NpgsqlCommand(sql, (NpgsqlConnection)unit.Connection, (NpgsqlTransaction?)unit.Transaction); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + var rows = new List(); + while (await reader.ReadAsync(cancellationToken)) + rows.Add(new TranslationRow(reader.GetGuid(0), reader.IsDBNull(1) ? null : reader.GetGuid(1), + reader.GetGuid(2), reader.GetString(3), reader.GetString(4))); + return rows; + } + } + + private sealed class RequestTranslationScopeReader(ObservedPublicConfigurationReader reader, IUnitOfWork unit, + ITenantContext context, IHttpContextAccessor http, TranslationScopeObservation observation) : IPublicTenantConfigurationReader + { + public async Task> ReadAsync(CancellationToken cancellationToken) + { + var result = await reader.ReadAsync(cancellationToken); + await observation.AssertAsync(http.HttpContext!.Request.Host.Host, context, unit, cancellationToken); + return result; + } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/DemoManifestTests.cs b/backend/tests/LearnStack.Tests.Integration/DemoManifestTests.cs new file mode 100644 index 00000000..33902e19 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/DemoManifestTests.cs @@ -0,0 +1,112 @@ +using System.Text.Json; +using FluentAssertions; +using LearnStack.Tools.Seeder; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration; + +public sealed class DemoManifestTests +{ + [Fact] + public void Demo_manifest_covers_every_enabled_seed_lane_and_nine_distinct_real_content_urls() + { + var manifest = DemoManifest.Create(); + manifest.Should().HaveCount(3); + manifest.SelectMany(row => new[] { row.CatalogUrl, row.CourseUrl, row.LessonUrl }) + .Distinct(StringComparer.Ordinal).Should().HaveCount(9); + foreach (var tenant in SeedData.All) + { + var curriculum = tenant.Curriculum!; + var lanes = manifest.Where(row => row.Host == tenant.Host).ToArray(); + lanes.Select(row => row.Locale).Should().BeEquivalentTo(curriculum.Locales.Where(row => row.IsEnabled).Select(row => row.Locale)); + foreach (var lane in lanes) + { + lane.DisplayName.Should().Be(tenant.DisplayName); + var course = curriculum.Courses.Single(row => row.Translations.Any(value => value.Title == lane.CourseTitle)); + course.Status.Should().Be("Published"); + course.ContentAccess.Should().Be("public"); + course.OrganizationId.Should().Be(tenant.MapHostToDefaultOrganization ? tenant.DefaultOrganization.OrganizationId : null); + var translation = course.Translations.Single(row => row.Locale == lane.Locale); + lane.CourseUrl.Should().Be(lane.CatalogUrl + "/" + translation.Slug); + var lesson = course.Lessons.Where(row => row.Status == "Published").MinBy(row => row.Sort)!; + var text = lesson.Translations.Single(row => row.Locale == lane.Locale); + lane.LessonUrl.Should().Be(lane.CourseUrl + "/lessons/" + text.Slug); + lane.LessonTitle.Should().Be(text.Title); + using var schema = JsonDocument.Parse(curriculum.ContentType.JsonSchema); + using var body = JsonDocument.Parse(text.Body); + lane.Fields.Should().NotBeEmpty(); + lane.Fields.Select(row => row.Name).Should().Equal(schema.RootElement.GetProperty("x-fields") + .EnumerateArray().Select(row => row.GetProperty("name").GetString())); + foreach (var field in lane.Fields) + field.Value.Should().Be(body.RootElement.GetProperty(field.Name).GetString()); + lane.Theme.Should().HaveCount(4); + lane.ExcludedTitles.Should().NotBeEmpty().And.NotContain(lane.CourseTitle); + var visibleMarketing = curriculum.Courses.Where(row => row.Status == "Published" + && (row.OrganizationId is null || row.OrganizationId == course.OrganizationId)) + .SelectMany(row => row.Translations.Select(value => value.Title)); + lane.ExcludedTitles.Should().NotIntersectWith(visibleMarketing, + "published restricted courses retain public marketing under ADR-0050"); + lane.ExcludedContent.Should().NotBeEmpty().And.NotIntersectWith(lane.Fields.Select(row => row.Value)); + foreach (var hidden in curriculum.Courses.Where(row => row.ContentAccess != "public") + .SelectMany(row => row.Lessons).SelectMany(row => row.Translations)) + lane.ExcludedContent.Should().Contain(hidden.Title, + "restricted lesson identity is different from published course marketing"); + foreach (var hidden in course.Lessons.Where(row => row.Status != "Published") + .SelectMany(row => row.Translations)) + lane.ExcludedContent.Should().Contain(hidden.Title, "draft lessons stay outside the public outline"); + } + } + manifest.Select(row => row.Fields.Count).Distinct().Should().HaveCount(2, + "the browser proof must observe two genuinely distinct authored shapes"); + } + + [Theory] + [InlineData("Default", "Username=learnstack_platform")] + [InlineData("Migration", "Username=learnstack_app")] + [InlineData("Default", "Host=remote.example")] + [InlineData("Default", "Port=5433")] + [InlineData("Default", "Database=other")] + [InlineData("Default", "Password=stale")] + public void Demo_configuration_refuses_stale_or_privileged_connection_values(string key, string replacement) + { + var values = Configuration(); + DemoDatabaseConfiguration.IsValid(values).Should().BeTrue(); + values[$"ConnectionStrings__{key}"] += ";" + replacement; + DemoDatabaseConfiguration.IsValid(values).Should().BeFalse(); + } + + [Fact] + public void Demo_configuration_refuses_missing_and_malformed_values() + { + DemoDatabaseConfiguration.IsValid(new Dictionary()).Should().BeFalse(); + var values = Configuration(); + values["ConnectionStrings__Default"] = "not a connection string"; + DemoDatabaseConfiguration.IsValid(values).Should().BeFalse(); + } + + private static Dictionary Configuration() + { + var values = new Dictionary(StringComparer.Ordinal) { ["POSTGRES_DB"] = "demo" }; + foreach (var (suffix, role, key) in new[] + { + ("Migration", "learnstack_migration", "LEARNSTACK_MIGRATION_PW"), + ("Default", "learnstack_app", "LEARNSTACK_APP_PW"), + ("PlatformAdmin", "learnstack_platform", "LEARNSTACK_PLATFORM_PW"), + ("OutboxDispatcher", "learnstack_outbox_admin", "LEARNSTACK_OUTBOX_PW"), + }) + { + // Ephemeral parser inputs only: no service uses these credentials. + var password = Guid.NewGuid().ToString("N"); + values[key] = password; + values[$"ConnectionStrings__{suffix}"] = new NpgsqlConnectionStringBuilder + { + Host = "localhost", + Database = "demo", + Username = role, + Password = password, + }.ConnectionString; + } + return values; + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs index 74b7c67d..03737ae8 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs @@ -1105,26 +1105,37 @@ public async Task A_Factory_That_Ignores_Its_Token_Is_Still_Bounded() var cache = new InMemoryCacheService( new FixedClock(Origin), MeterFactory, factoryTimeout: TimeSpan.FromMilliseconds(80)); var runs = 0; + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var overrunning = cache.GetOrSetAsync(Key(), async _ => + var overrunning = cache.GetOrSetAsync(Key(), _ => { Interlocked.Increment(ref runs); - await Task.Delay(TimeSpan.FromMilliseconds(600), CancellationToken.None); - return "late"; + return release.Task; }); - await ((Func)(() => overrunning)).Should().ThrowAsync(); - - // The replacement waits for the overrunning factory rather than starting - // a second one beside it, and is not answered with the first caller's - // timeout for ever. - var replacement = await cache.GetOrSetAsync(Key(), _ => Task.FromResult("fresh")) - .WaitAsync(TestTimeout); - - replacement.Should().Be("fresh"); - runs.Should().Be(1, "no second factory ran alongside the first"); - (await cache.GetAsync(Key())).Should().Be("fresh", - "a result that arrived after its deadline is not cached"); + try + { + // Only the cache's deadline can finish first; two elapsed timers can + // run in reverse order on a busy worker. The outer bound must never + // masquerade as the cache's own timeout if its deadline regresses. + var refusal = await ((Func)(() => overrunning.WaitAsync(TestTimeout))) + .Should().ThrowAsync(); + refusal.WithMessage("The cache factory for '*' did not complete within *s."); + + // A replacement waits for the original factory's real termination. + var replacement = cache.GetOrSetAsync(Key(), _ => Task.FromResult("fresh")); + replacement.IsCompleted.Should().BeFalse("the original factory has not been released"); + release.SetResult("late"); + + (await replacement.WaitAsync(TestTimeout)).Should().Be("fresh"); + runs.Should().Be(1, "no second factory ran alongside the first"); + (await cache.GetAsync(Key())).Should().Be("fresh", + "a result that arrived after its deadline is not cached"); + } + finally + { + release.TrySetResult("late"); + } } [Theory] diff --git a/docs/architecture/01-platform-vision.md b/docs/architecture/01-platform-vision.md index 0f359b3a..bf0ceba8 100644 --- a/docs/architecture/01-platform-vision.md +++ b/docs/architecture/01-platform-vision.md @@ -218,10 +218,14 @@ When the foundation is in place, LearnStack should be able to: hot-reload → tenant resolver mapping. All Hub-orchestrated, ADR-0022. - **Run radically different tenant platforms on the same binary** — English learning + yoga studio + coding bootcamp + driving school — without LearnStack writing any domain - code. Two such tenants exist from - [Phase 02a Packet 7](../roadmap/phase-02a-kernel-tenancy.md) and render side by side in - a browser from [Phase 02d](../roadmap/phase-02d-walking-skeleton.md), so the claim is - tested continuously rather than asserted until the showcase phase. + code. The English school and Yoga studio seeded in + [Phase 02a Packet 7](../roadmap/phase-02a-kernel-tenancy.md) now render their own + catalogs, courses and lessons through the same Phase 02d code: distinct themes, + taxonomies and ordered two-field/three-field lesson shapes, with English and + Turkish Yoga routes. P02d-7 supplies the shared demo and narrow browser/audit + proof; the [Phase 02d record](../roadmap/phase-02d-walking-skeleton.md) owns its + verification, required CI activation and phase-exit state. Studio composition + and the wider tenant capabilities remain with their named roadmap phases. - **Tenant admin defines a custom content type in Admin Studio** (e.g. "BreathTechnique" for a yoga tenant) — JSON Schema authored in a visual editor — and immediately uses it in a course lesson. diff --git a/docs/architecture/04-technical-architecture.md b/docs/architecture/04-technical-architecture.md index 702ee59f..ec3183aa 100644 --- a/docs/architecture/04-technical-architecture.md +++ b/docs/architecture/04-technical-architecture.md @@ -232,8 +232,10 @@ Application projects run **outside** containers during active development for fa P02d-5 delivers G33's paired loopback API and native TLS web ingress, with isolated real-API production-rendering fixtures. The [accepted local setup](../roadmap/phase-02d-walking-skeleton.md#local-setup-and-ingress-details) -owns host, certificate and private configuration preparation. P02d-7 owns G45's -`make demo` lifecycle and browser evidence; that gate remains open. +owns host, certificate and private configuration preparation. P02d-7 delivers +G45's `make demo` lifecycle and browser evidence; the +[Step 4 record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) +owns completed reviews, CI enforcement and phase-exit verification. ## Observability diff --git a/docs/architecture/05-mvp-scope.md b/docs/architecture/05-mvp-scope.md index ae9a8940..7ceaf222 100644 --- a/docs/architecture/05-mvp-scope.md +++ b/docs/architecture/05-mvp-scope.md @@ -27,11 +27,12 @@ Run an end-to-end online English-learning tenant on LearnStack, where: - A second tenant — a **yoga studio** — has existed since [Phase 02a Packet 7](../roadmap/phase-02a-kernel-tenancy.md), holding the same built-in customization rows as the English school, owned per tenant. - [Phase 02d](../roadmap/phase-02d-walking-skeleton.md) gives each tenant its own - taxonomy, content type and branding as tenant data and renders both sites - ([§ Genericity proof](../roadmap/phase-02d-walking-skeleton.md#genericity-proof)). The - substrate-genericity proof is therefore continuous from Phase 02d onward, not a - checkbox at MVP exit. + [Phase 02d](../roadmap/phase-02d-walking-skeleton.md) renders each tenant's + catalog, course and lesson pages from its own taxonomy, content type and branding: + two ordered English-school lesson fields and three Yoga fields, with distinct + English/Turkish Yoga slugs and labels. The same renderer serves both sites + ([§ Genericity proof](../roadmap/phase-02d-walking-skeleton.md#genericity-proof)); + P02d-7's demo and narrow browser/audit proof exercise that delivered path. ## Vertical Slice First @@ -233,15 +234,20 @@ The **second tenant already exists** — the yoga studio seeded in since [Phase 02d](../roadmap/phase-02d-walking-skeleton.md). Phase 10 is therefore a **depth** showcase rather than a breadth one: its job is to exercise *every* customization aggregate against one real tenant, proving the customization surface is -complete. Genericity is already proven, and re-proven on every CI run. - -P02d-5's required integration check renders two hosts through the production ingress, -middleware and configured caller against the real API and PostgreSQL. Its explicitly -test-owned pages prove the transport boundary, not P6's product pages or P7's browser -demo. P02d-6 owns G38(c)'s page cases; P02d-7 owns G20's production genericity guard, -G45's demo/browser evidence and G44's Lighthouse activation after P6. The remaining -harness and assertion decisions stay in the -[Phase 02d decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +complete. The shipped public slice already demonstrates different tenant shapes. + +P02d-5's required integration check renders two hosts through production ingress, +middleware and the configured caller against the real API and PostgreSQL. Its +explicitly test-owned pages prove transport; P02d-6 adds actual product page and +presentation proofs. P02d-7 implements G20's production genericity guard, the +shared `make demo` lifecycle and a narrow Playwright library/Lighthouse proof over +nine seed-derived URLs across the English school's English lane and Yoga's Turkish +and English lanes. These enforcement implementations and local audit receipts +remain distinct from required real-PR CI and protection activation, whose evidence +and phase-exit verdict belong to the [Phase 02d closeout](../roadmap/phase-02d-walking-skeleton.md). +[Phase 06](../roadmap/phase-06-renderer-admin-studio.md) retains the full Playwright +Test/axe suite and Studio expansion; [Phase 11](../roadmap/phase-11-production-hardening.md) +retains field telemetry, load testing and production performance hardening. ## Deferred diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index 33cc9f5a..33606e00 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -431,10 +431,11 @@ This document does not restate them. Studio and Portal have higher budgets because they are authenticated apps and benefit from client-side state. -CI's Lighthouse job over representative public pages remains a disabled placeholder. -Accepted P02d-5 G44 assigns activation to P02d-7 after P6 pages; P7 selects the -harness and assertions. The decision is recorded in -[Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +P02d-7's always-running `public demo + lighthouse` job exercises nine seeded +public pages under ADR-0056. Performance Standards owns its hard budgets and +advisory timing scope; the +[Step 4 record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) +owns real CI and required-check receipts. ## Accessibility diff --git a/docs/decisions/0056-public-demo-browser-and-lighthouse-enforcement.md b/docs/decisions/0056-public-demo-browser-and-lighthouse-enforcement.md new file mode 100644 index 00000000..79098314 --- /dev/null +++ b/docs/decisions/0056-public-demo-browser-and-lighthouse-enforcement.md @@ -0,0 +1,355 @@ +# ADR-0056: Public Demo Browser and Lighthouse Enforcement + +## Status + +> **Delivery navigation — 2026-10-11:** [Amendment 1](#amendment-1--p02d-7-enforcement-delivery-2026-10-11) +> records implemented proofs, passing real PR CI and required-check activation. +> Pending statements below describe the acceptance baseline. + +Accepted + +No implementation, passing audit or gate acceptance is claimed. + +> **Acceptance-baseline clarification — 2026-10-11:** The preceding sentence +> records the state at acceptance, not current delivery status. +> [Amendment 2](#amendment-2--acceptance-and-reviewed-head-navigation-2026-10-11) +> distinguishes that baseline, activation history and reviewed-head evidence. + +**Date:** 2026-10-11 +**Deciders:** @cemil + +## Decision Drivers + +- P02d-7 must demonstrate two tenant sites from one production build and database. +- G44 already assigns Lighthouse activation to P7; its measurements are undecided. +- A fast error page must never satisfy a performance or accessibility check. +- The delivered visitor and peer limiters remain active during the entire proof. +- Reproducible gzip, browser and lab measurements need explicit semantics. +- The evidence must preserve the existing ingress, request-local and TLS boundaries. + +## Considered Options + +1. **Direct Lighthouse with managed Chromium** (chosen). + Pin Lighthouse and the Playwright library/browser; validate the actual audited + page, complete report inventory and explicitly defined budgets. +2. **LHCI with the runner's stock Chrome** (rejected). LHCI supplies useful + assertions but still needs page-identity and gzip checks; stock Chrome drifts. + LHCI 0.15.1 pins Lighthouse 12.6.1, rather than this proposal's 13.5.0. +3. **Human walkthrough or static HTML audit only** (rejected). Neither exercises + the delivered navigation in a browser or closes G44's required full-stack job. + This rejects human evidence as the sole gate; G33's human walkthrough remains. +4. **Make all existing timing targets hard immediately** (rejected). The landing + page and lesson player are absent, and a shared CI lab is not a production + p95 or field-INP baseline. + +## Decision + +LearnStack gates the delivered public catalog, course and lesson pages with a +seed-derived browser walkthrough and pinned Lighthouse audits over the shared +demo lifecycle. Every audit proves the identity of its actual product document; +normalized initial JavaScript gzip size, CLS and automated accessibility are hard +checks, while per-URL median timing measurements are advisory. A required CI job +runs these checks without changing the production anonymous admission budgets. + +## Context + +The maintainer accepted this enforcement decision on 2026-10-11. It +implements P02d-7's remaining G33/G38/G44 evidence; the +[phase package](../roadmap/phase-02d-walking-skeleton.md#p02d-7-decision-package-2026-10-10) +owns G20, G45, implementation steps and phase exit. The maintainer approved that +package's lifecycle and bounded seventh-check activation on 2026-10-11; this +revised ADR is accepted on the same date. G45 names the approved `make demo` +lifecycle and G33 names the approved CI topology in the register. The decision +commit records both dependencies before implementation; ADR acceptance does not +replace the lifecycle decision. It adds no production +API, new request authority, shared representation cache or deployment mode. + +[ADR-0053](0053-trusted-public-server-rendering.md), +[ADR-0054](0054-bounded-public-renderer-admission.md) and +[ADR-0055](0055-public-renderer-bootstrap-failures.md) remain unchanged. Native +HTTPS ingress is mandatory; ordinary product documents cost two API calls. +The 60-call/minute visitor and 600-call/minute peer policies remain registered. + +### Toolchain and page matrix + +Use exact development-tool pins: `lighthouse@13.5.0` and +`playwright@1.64.0` as a library, without a new Playwright Test runner or a +separate axe suite. Install Playwright's managed full Chromium: revision 1248, +version 156.0.8078.4, with the new headless mode. The repository's Node 22.23.1 +satisfies Lighthouse's Node >=22.19 requirement. Record resolved versions and the +OS/runner image; refuse a missing or mismatched browser instead of skipping. +Both tools are Apache-2.0 licensed. Pin them as development tools in +`frontend/package.json` and `frontend/pnpm-lock.yaml`, outside application runtime +dependencies. Record transitive license/security review with implementation. + +Derive the URL/identity/expected-content manifest from `SeedData`; do not maintain +another list of seed slugs, titles, colors or field labels. + +| Lane | Locale | Pages | +|---|---|---| +| English tenant-wide host | en | Catalog, public course, first public lesson | +| Yoga default-organization host | tr-TR | Catalog, public course, first public lesson | +| Yoga default-organization host | en | The same three resource kinds in English | + +The exact seeded organization-scoped Yoga course supplies both Yoga lanes. This +is nine URLs and three cold audits per URL: exactly 27 JSON/HTML report pairs. +The browser opens each catalog, then follows rendered course and lesson links. +It checks host/locale, expected headings, ordered authored fields, distinct field +sets, computed theme colors, and exclusion of foreign/draft/restricted content. +It uses the same running API, web build and database across all lanes. +Keycloak is started for the existing seed gate, then stopped in the CI-owned +project before these fresh anonymous navigations. + +These flows supplement the existing HTML/Flight and schema-replacement proofs. +[Phase 06](../roadmap/phase-06-renderer-admin-studio.md) still owns the broader +Playwright/axe golden-flow suite. Existing P6 manual/VoiceOver evidence keeps its +recorded scope and date; this ADR establishes neither a new manual pass nor full +WCAG conformance. + +### Actual audited document and report admission + +Before scoring, each run requires the expected URL and a successful live product +preflight. The audit additionally checks its own final URL, main-document status, +tenant/locale/content markers and original document/DOM. A healthy preflight +alone is insufficient. Inspect the audited target or its gathered artifacts +without a second post-audit navigation. A bounded custom gatherer is permitted. + +Reject controlled error/noindex/fallback documents, wrong-tenant content, missing +expected fields, unexpected redirects and any API 429, including a content +refusal rendered as HTTP 200. Observe API responses as well as browser responses; +a genuine product DOM cannot turn a hidden API failure into a clean receipt. + +Each report must belong to this invocation's commit, build, URL, run index, +configuration and browser. Missing, duplicate or stale reports, runtime errors, +audit errors, missing categories and null/nonfinite budgeted measurements fail +independently of the hard/advisory split. Both tenants and every locale/run must +pass; results are never averaged across URLs. + +### Lab profile and budgets + +Run only `performance` and `accessibility` from the pinned default configuration, +explicitly skipping `bf-cache`, whose gatherer navigates away and back. Preserve +the complete pinned accessibility audit/weight set and the four budgeted numeric +audits: `cumulative-layout-shift`, `largest-contentful-paint`, +`server-response-time` and `total-blocking-time`. The selected performance +category also gathers and scores FCP and Speed Index in Lighthouse's report; +neither has a P7 threshold, and the performance category score is not asserted. +Assert the resolved audit/artifact census and refuse missing or changed scoring +entries. Do not select `seo`, `best-practices` or `agentic-browsing`; no active +secondary-navigation probes run. Product-identity/gzip gatherers observe the +initial navigation and introduce no fetch or navigation of their own. + +Use Lighthouse 13.5's mobile simulated slow-4G profile, recorded explicitly: +412 × 823 CSS pixels, DPR 1.75, 150 ms RTT, 1638.4 Kbps throughput and 4× CPU +slowdown. Record resolved settings in every run manifest. This is not the +fast-4G landing-page profile in Performance Standards. + +| Measurement | Threshold | Verdict | +|---|---|---| +| `initial-js-gzip-bytes`, defined below | Strictly < 200000 bytes | Hard, every run | +| `cumulative-layout-shift.numericValue` | Strictly < 0.05 | Hard, every run | +| `categories.accessibility.score` | Exactly 1 | Hard, every run | +| `largest-contentful-paint.numericValue` | Median of three < 1500 ms | Advisory, per URL | +| `server-response-time.numericValue` | Median of three < 300 ms | Advisory, per URL | +| `total-blocking-time.numericValue` | Median of three < 200 ms | Advisory, per URL | + +The advisory thresholds are P7 lab baselines across the nine delivered pages. +Main-document server response is not full-navigation TTFB; TBT is not INP. +No landing-page, lesson-player, production p95 or load-test result follows. +The 250 KB forbidden line does not replace the 200 KB route budget. +Accessibility score 1 covers Lighthouse's automated scored checks, not every +accessibility criterion. + +### Normalized initial JavaScript gzip size + +`initial-js-gzip-bytes` adopts an explicit, potentially stricter interpretation +of the existing public-route gzip budget in +[Performance Standards](../standards/15-performance.md#bundle-size): + +- Use a cold navigation with no interactions or speculative prefetch. The + collection window is that audit's navigation through collection completion. +- Inventory every actual external Script response in that window, same-origin + and cross-origin, including async/lazy scripts loaded by initial scripts + without interaction. Count each delivered response once; gzip its complete + decoded UTF-8 source with Node `gzipSync` at level 9 and sum the byte lengths. +- Add the gzip length of original-document inline executable script bodies, + concatenated in document order with one newline between bodies. Include Next's + inline Flight push scripts; exclude inert JSON/data blocks and external-script + elements already counted above. Scripts first requested after collection + completion or through later user interaction are outside this measurement. +- Record source URL, digest, decoded byte length and gzip length, plus inline + count/digest/lengths. Reconcile the network inventory with collected sources. +- Missing, undecodable or truncated bodies, inventory disagreement and an + unexpectedly empty script census fail; unavailable sources never count as zero. + +This is a deterministic normalized payload measurement using decimal KB. It is +neither actual wire bytes nor Next's build-time First Load JS. Lighthouse's +Script `transferSize` may use another content encoding; it cannot substitute for +this budget. Evaluation-generated code is not a delivered source response. +The collection window and inline/async coverage can reject a route that passed +a narrower build-time or entry-chunk-only reading. This tightening is intentional; +the enforcing Standards 15 update links this exact scope and its exclusions. + +### Pacing, runtime and artifacts + +Keep one running API throughout the proof. Use exactly four sequential batches: +one batch containing all three browser click paths, followed by three audit +batches, one per host/locale lane, each containing nine audits. Use one worker: +preflight/audit pairs execute serially, with at most one active browser target +and no overlapping audits. Record the runner's CPU count and OS/image; do not +parallelize Chromium to meet a deadline on a shared runner. +Leave at least 61 seconds after a batch completes before starting the next. +With one navigating preflight per audit, each audit batch expects 36 API calls; +the click paths expect 18. Count readiness and other documents separately. + +Cap each batch at 48 observed API calls and refuse any 429 or unexplained extra +traffic. Do not spoof visitors, raise quotas, reset the API/limiter, retry 429s or +silently audit a refusal page. Prove the counting observer has a positive control. +Startup checks also consume quota: finish them before the first 61-second quiet +interval. Real counts, not the arithmetic alone, own the passing evidence. + +The proof has one 12-minute wall-clock deadline after setup/build, starting +before the first quiet interval and ending after report validation. All four +61-second quiet intervals consume it: 244 seconds, leaving at most 476 seconds +for browser paths, all preflights, 27 audits and validation. Audit/preflight +pairs must therefore average less than 17.6 seconds even before deducting the +browser paths and validation; no measured feasibility is claimed yet. + +Navigation and audit timeouts are upper bounds, not reserved time allocations: +each navigation gets at most 15 seconds and each audit at most 60 seconds, +both shortened to the remaining proof deadline. Exhaustion aborts the active +operation, refuses unstarted work and fails the run; partial evidence cannot +pass. In particular, 27 audits taking 60 seconds each cannot pass this deadline. +Owned cleanup has a separate maximum of 30 seconds. The 20-minute CI job ceiling +includes setup/build, proof, cleanup and artifact handling; record each duration +and reserve cleanup/artifact time within that ceiling. + +The first full serial run is a feasibility gate before required-check activation. +If the complete run cannot meet these limits, record a failed result and obtain +a revised decision for changed time ceilings or execution topology. Do not +silently run parallel audits, reduce the 27-run census or weaken checks to fit. + +Keep reports and sanitized receipts in GitHub workflow artifacts only: +all 27 JSON/HTML pairs, expected/run census, metrics verdicts, version/config/ +commit manifest, browser screenshots, API status/count totals and cleanup result. +Upload bounded failure evidence on failure. Never archive private environments, +raw process logs, browser profiles, certificates or keys, and never upload to a +public Lighthouse service. + +### Trust and required-check activation + +Use the shared lifecycle's ownership and verified TLS contract. Human local use +retains explicit manual hosts/CA/leaf setup. CI uses disposable certificates, +certificate-specific browser SPKI trust, resolver mappings and independent Node +CA/hostname verification with wrong-key/untrusted-certificate controls. +Global certificate bypass or system hosts/trust modifications are forbidden. + +The job is named `public demo + lighthouse`, runs on the normal PR/push triggers +without the deferred condition, and fails if any proof or cleanup is incomplete. +After its first real successful PR run, add that exact GitHub Actions context as +the seventh required check. Read back protection and preserve all six existing +contexts, strict freshness and every unrelated setting. Acceptance authorizes +only this bounded activation after the successful run; it does not claim activation. + +## Consequences + +### Positive + +- Browser navigation, product identity and budgets become repeatable merge gates. +- Error pages, missing metrics and empty/stale evidence cannot produce green CI. +- Seed-derived expectations preserve the same-code/different-data claim. + +### Negative + +- Managed Chromium and Lighthouse increase dependency and CI setup costs. +- Real-window pacing adds deliberate waiting; browser/lab maintenance remains work. +- The normalized gzip collector and audit-page admission need their own controls. + +### Neutral + +- Performance becomes Active only for implemented hard checks, in the enforcing PR. +- Broader performance baselines and telemetry remain Phase 11. +- Local demo lifecycle details remain G45 phase decisions, rather than a new runtime. + +## Implementation Notes + +Acceptance precedes runtime/dependency/CI changes. Reconcile Testing 06's current +“Phase 02d … gates on a human opening them, not on a Playwright run” statement +when the narrow P7 browser job ships; preserve Phase 06's broader suite ownership. +Make the public-route size unit explicit in Standards 15 as decimal KB +(`< 200000` bytes), alongside its normalized-gzip window, same-/cross-origin +scope, exclusions and potentially stricter interpretation. + +Register the canonical rules below in Standards 21 on acceptance as +**Kind: behavioural**, owned by the planned +Node entrypoint `frontend/apps/web/scripts/verify-public-demo.mjs` and the +`public demo + lighthouse` job; these are not C# architecture-test methods. +Mark them Implemented only with named, executed enforcing tests. The phase +package owns four implementation steps and two fresh independent review rounds +per step. Update Standards 06/15/16, the +standards index, CI, CONTRIBUTING and test/setup skills alongside enforcement. +Acceptance registers proof obligations, not passing tests or delivered enforcement. + +## Architecture Tests + +- `Public_Demo_Uses_Seeded_Production_Pages`: real link navigation and page/role/ + theme/locale observations, with wrong-page and error-page controls. +- `Public_Lighthouse_Reports_Enforce_Budgets`: exact census and hard/advisory + validation, with missing/stale/error/budget offenders and clean reports. +- `Public_Initial_JavaScript_Gzip_Budget_Is_Measured`: real source inventory and + byte accounting, with omitted/empty/malformed sources and over-budget controls. +- `Public_Demo_Preserves_Admission_And_Cleanup`: real quotas, Keycloak-stopped + reads, TLS refusal and owned lifecycle, with observer and cleanup controls. + +These are registered behavioral obligations for that Node/CI proof owner, not +existing scripts or C# test methods. + +## Amendments + +### Amendment 1 — P02d-7 enforcement delivery (2026-10-11) + +The four-step implementation and both independent review rounds per step are +complete. All four behavioral rules above are Implemented with named executable +owners. The shared demo lifecycle supplies the actual linked nine-page browser +proof and 27 serial cold Lighthouse audits, bounded report/source admission, +hard gzip/CLS/automated-accessibility checks, real quota accounting and confirmed +owned cleanup. Per-URL timing medians remain advisory. + +The first real [PR run](https://github.com/HodeTech/LearnStack/actions/runs/38106505383) +passes all seven jobs. Its complete sanitized artifacts and the subsequent +authorized seventh-context activation/full protection read-back are verified in +the [CI and exit closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-7-ci-and-phase-exit-closeout-2026-10-11). +PR #28 remains subject to maintainer review and merge. This is delivery navigation; +the acceptance metadata, decision and feasibility obligations remain unchanged. +Existing P6 human/VoiceOver evidence and Phase 06/11 scope retain their limits. + +### Amendment 2 — Acceptance and reviewed-head navigation (2026-10-11) + +The original no-implementation sentence describes acceptance-time state. +Amendment 1 describes later delivery and the first real run used for activation. +The [reviewed-head verification](../roadmap/phase-02d-walking-skeleton.md#reviewed-implementation-head-verification-2026-10-11) +separately records source `d47ef17` and its downloaded successful CI artifacts. +Subsequent heads remain subject to all seven required checks. This clarification +changes no decision, tool version, budget or original acceptance metadata. + +## References + +- [P02d-7 package](../roadmap/phase-02d-walking-skeleton.md#p02d-7-decision-package-2026-10-10). +- [Performance Standards](../standards/15-performance.md). +- [Testing Standards](../standards/06-testing.md#end-to-end-tests). +- [Accessibility Standards](../standards/16-accessibility.md#tooling). +- [Lighthouse 13.5.0 package and Node requirement](https://github.com/GoogleChrome/lighthouse/blob/v13.5.0/package.json). +- [Lighthouse programmatic use](https://github.com/GoogleChrome/lighthouse/blob/v13.5.0/docs/readme.md#using-programmatically). +- [Lighthouse mobile profile selection](https://github.com/GoogleChrome/lighthouse/blob/v13.5.0/core/config/constants.js). +- [Pinned slow-4G upstream constants](https://github.com/ChromeDevTools/devtools-frontend/blob/v1.0.1684555/front_end/models/trace/lantern/simulation/Constants.ts#L12-L18), matching Lighthouse's trace-engine 0.0.65 dependency. +- [Lighthouse default audit set](https://github.com/GoogleChrome/lighthouse/blob/v13.5.0/core/config/default-config.js). +- [Lighthouse audit/artifact filtering](https://github.com/GoogleChrome/lighthouse/blob/v13.5.0/core/config/filters.js). +- [Lighthouse Script source collection](https://github.com/GoogleChrome/lighthouse/blob/v13.5.0/core/gather/gatherers/scripts.js). +- [Lighthouse resource-summary transfer semantics](https://github.com/GoogleChrome/lighthouse/blob/v13.5.0/core/audits/resource-summary.js). +- [Lighthouse TBT](https://developer.chrome.com/docs/lighthouse/performance/lighthouse-total-blocking-time). +- [Lighthouse accessibility scoring](https://developer.chrome.com/docs/lighthouse/accessibility/scoring). +- [Playwright library](https://playwright.dev/docs/library). +- [Playwright 1.64.0 package/license](https://github.com/microsoft/playwright/blob/v1.64.0/packages/playwright/package.json). +- [Playwright 1.64.0 browser manifest](https://github.com/microsoft/playwright/blob/v1.64.0/packages/playwright-core/browsers.json). +- [Playwright browser installation](https://playwright.dev/docs/browsers). +- [LHCI 0.15.1 dependency](https://github.com/GoogleChrome/lighthouse-ci/blob/v0.15.1/packages/cli/package.json). diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 6083b93f..edc108fa 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -71,6 +71,7 @@ an amendment is not a lifecycle status change. | 0053 | [Trusted Public Server Rendering](0053-trusted-public-server-rendering.md) | Accepted 2026-10-08: P02d-5 delivers native ingress, configured transport, dynamic entry and source/runtime proofs. [ADR-0054](0054-bounded-public-renderer-admission.md) replaces bounded accounting/native/query contracts. P02d-5 is complete and merged through PR #26 on 2026-10-09; the [merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09) owns final verification ([Amendment 8](0053-trusted-public-server-rendering.md#amendment-8--merge-closeout-2026-10-09)); P6/P7 remain separate | | 0054 | [Bounded Public Renderer Admission](0054-bounded-public-renderer-admission.md) | Accepted 2026-10-09: coordinated anonymous accounting, native GET/HEAD/production-upgrade admission and equivalent redirect-query encoding are delivered. All five remediation steps are complete and merged through PR #26; the [merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09) owns final verification ([Amendment 6](0054-bounded-public-renderer-admission.md#amendment-6--merge-closeout-2026-10-09)) | | 0055 | [Handle Public Renderer Bootstrap Failures in Middleware](0055-public-renderer-bootstrap-failures.md) | Accepted 2026-10-10: one live middleware bootstrap and an immutable native same-request snapshot; exact bootstrap 404/429/503, bounded renderer Retry-After and two/one/three-call accounting implemented. Production replacement proofs pass; all three steps completed both review rounds and focused fix verification. [Step 3](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) owns delivery evidence, completed reviews and verified fixes; five new catalogue rules are Implemented | +| 0056 | [Public Demo Browser and Lighthouse Enforcement](0056-public-demo-browser-and-lighthouse-enforcement.md) | Accepted 2026-10-11: pinned Lighthouse/Playwright Chromium, seed-derived nine-page browser proof, 27 serial audits, actual-page admission, normalized gzip/CLS/accessibility hard checks and advisory timings delivered; four proof rules Implemented, real PR CI/artifacts pass and seventh required context activated ([Amendment 1](0056-public-demo-browser-and-lighthouse-enforcement.md#amendment-1--p02d-7-enforcement-delivery-2026-10-11)); PR #28 review/merge pending | ## Proposed ADRs diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index e9e837d8..1a1c4937 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -45,7 +45,8 @@ not deferred to the showcase phase. - [Phase 01: Repository, Tooling, and Local Infrastructure](phase-01-repository-tooling.md) — **complete** - [Phase 02a: Platform Kernel, Multi-Tenancy, Organization, and Foundation Sockets](phase-02a-kernel-tenancy.md) — **complete** (packets 0–3, 3b and 4–10 shipped) - [Phase 02d: Two-Tenant Walking Skeleton](phase-02d-walking-skeleton.md) — - **in progress**; P02d-1 and P02d-2 complete and merged; the + **implementation and exit verification complete; PR #28 review/merge pending**. + P02d-1 and P02d-2 complete and merged; the [P02d-2 closeout](phase-02d-walking-skeleton.md#p02d-2-merge-and-closeout-2026-10-02) records final verification. P02d-3 read internals are implemented; the [decision package](phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) @@ -77,8 +78,19 @@ not deferred to the showcase phase. single-bootstrap admission and passing production replacement proofs. All three steps completed both independent review rounds; the [Step 3 record](phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) - owns completed Step 3 reviews, verified fixes and delivery evidence. Maintainer - PR review and merge remain pending. P7 owns the browser/demo. + owns completed Step 3 reviews, verified fixes and delivery evidence. P02d-6 is + complete and merged through [PR #27](https://github.com/HodeTech/LearnStack/pull/27) + on 2026-10-10; its + [merge closeout](phase-02d-walking-skeleton.md#p02d-6-merge-and-closeout-2026-10-10) + records final verification. P02d-7's demo, Lighthouse and phase-exit + [decision package](phase-02d-walking-skeleton.md#p02d-7-decision-package-2026-10-10) + and ADR-0056 were approved on 2026-10-11. All four steps completed both independent + review rounds. The shared demo, genericity fences and seeded browser/Lighthouse + proof pass real CI; the seventh required context is active. The + [Step 4 record](phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) + owns CI enforcement, reviews and the positive phase-exit verdict. + [PR #28](https://github.com/HodeTech/LearnStack/pull/28) awaits maintainer review + and merge. P02b-0 re-verification and decision pass are next afterward. - [Phase 02b: Events, Background Jobs, Identity, and Session](phase-02b-events-auth.md) - [Phase 03: Identity Domain, Authorization, and Admin Foundation](phase-03-identity-admin.md) - [Phase 04: Headless CMS, Page Builder, and Media Library](phase-04-cms-media-pages.md) @@ -182,10 +194,12 @@ retrofit**, and **thin where it is not**: - Module boundaries stay clear; architecture tests enforce them from Phase 02a, including `Core_Modules_HaveNo_DomainSpecific_Names`, which keeps every name the platform ships free of the forbidden domain terms. That rule reads names and strips - literals, so it cannot show that no production code branches on which tenant it - serves. What mechanically backs that claim is open as G20 in - [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register), - and the pass that closes G20 edits this bullet with its answer. + literals. P02d-7's + [G20 controls](phase-02d-walking-skeleton.md#p02d-7-genericity-enforcement) + add typed exact identity matching across nonempty backend/frontend/JSON subjects, + compiled/restore-resolved Seeder-reference bans and planted dirty/clean controls. + The production schema-replacement proof complements that source fence; + dynamically assembled tenant comparisons remain a behavioral/review boundary. - CMS and education catalog capabilities work together against tenant-defined content types, blocks, lesson items, taxonomies, and scoring / completion rules ([ADR-0018](../decisions/0018-tenant-driven-customization-model.md)). diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 595c849e..0c2e64c9 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1,10 +1,18 @@ # Phase 02b: Events, Background Jobs, Identity, and Session -**P02d-5 handoff — 2026-10-08.** Accepted ADR-0053 supplies native ingress -provenance and a server-only public caller, with no anonymous cookies or credential -forwarding. G12/G14 must re-verify that boundary before adding BFF/session behavior; -auth routes may not shadow enabled `/{locale}/courses...` paths. P5 implementation -and P6 pages remain separately recorded; this note accepts no Phase 02b gate. +**Phase 02d handoff — 2026-10-11.** P02b-0 re-verifies the delivered `/api/v1` +public contracts, native HTTPS on retained `*.learnstack.local` seed hosts, +manual hosts/trust setup and the shared private hop-secret source. ADR-0053/0054 +supply visitor and peer admission; ADR-0055 supplies one middleware-owned site +bootstrap per native request: two API calls per normal product document, one per +fixed status document and three for a followed missing detail. HEAD, RSC and +prefetch retain their separately proved accounting. Seed authoring distinguishes +tenant-wide rows from default- and sibling-organization rows; typed settings remain +uncached. Anonymous public reads retain no cookies or forwarded credentials. +The [Phase 02d delivery record](phase-02d-walking-skeleton.md) +owns verification and the CI/phase-exit state. G12/G14 re-verify these premises +before adding BFF/session behavior; auth routes may not shadow enabled +`/{locale}/courses...` paths. This handoff accepts no Phase 02b auth or BFF gate. ## Goal @@ -260,7 +268,7 @@ premise a row cites is re-verified at that pass rather than trusted. | G9 | Which record decides the background-job runtime — its storage under the closed four-role model, the single tenant-context writer, the job frame's transaction owner, the enqueue-site rule *and who may enqueue whose job type*, the retry contract (whose class, slug and posture adopt **G7**'s), the tenantless platform-job class a purge needs, the ADR-0035 classification, the queue grammar, and the package pins with their licence verdict? | One record answering all of it; a job reaches the database only through `ISender`, which leaves ADR-0040's closed setter set at eight | **ADR-0047** + a dated ADR-0003 Amendment for the storage + a dated **ADR-0036 Amendment** (or an erratum) if the job-path writer is not the `JobActivator` its § Rules names in the closed four-caller set, then Standards 05 and Standards 20 | P02b-4 | | G10 | Does the active tenant travel as a scalar `tenant_id` claim, a `memberships` array, or both — and what value does `UserId` hold? | The scalar claim, whose 02b source is an admin-set user attribute; a second UUID-valued `user_id` attribute for the actor, with `sub` staying Keycloak's subject | Phase-doc: [ADR-0036 § What is out of scope](../decisions/0036-tenant-resolution-trusted-inputs.md#what-is-out-of-scope-and-what-is-not) hands the shape to this phase by name. Reconcile `architecture/13`'s array sketch in the same change | P02b-5 | | G11 | Which [ADR-0036 matrix](../decisions/0036-tenant-resolution-trusted-inputs.md#the-reconciliation-matrix) rows resolve in this phase, given that row 10 consults a reader that denies everyone — and what row covers a signature-valid token carrying no `tenant_id` claim on a tenant host? | Either mint `organization_id` scoped to the host's organization, which makes row 10 need no membership read, or declare the organization host's authenticated 404 and test it as the expected outcome | A dated **ADR-0036** amendment plus errata beside § Staging across packets and the 2026-09-02 erratum, both of which assign row 10 to this phase without noting that its `M covers (T, O)` term is denied until Phase 03 — which groups it with rows 7 and 14 rather than with 6 and 9 | P02b-5 | -| G12 | Which Keycloak client does the BFF exchange the code with, where does sign-in happen across two tenant hosts, **over what transport** — the seed hosts are `*.learnstack.local` and the frontend's dev script serves plain HTTP, which no browser will return a `Secure` cookie over — and how do the client scope, the mappers and the seed users reach a Keycloak database that has already consumed the import? | A confidential BFF client with one redirect URI per seed host; local TLS for those hosts with a trust step, or hosts under `localhost`, which browsers exempt; and an idempotent reconciler inside `make seed` — a mapper added only to the import reaches no existing workstation | Phase-doc + the realm JSON + `scripts/seed.sh` + `infra/keycloak/README.md`; a new ADR only if the reconciler becomes ADR-0004's `IIdentityProvider` | [Phase 02d](phase-02d-walking-skeleton.md#the-decision-register) G32 (the dev transport and hostnames, closed in the decision pass of the 02d packet that row blocks), P02b-5 (which client the BFF redeems the code with: the API pins `azp` against it), P02b-6 | +| G12 | Which Keycloak client does the BFF exchange the code with, where does sign-in happen across two tenant hosts, and how do the client scope, the mappers and the seed users reach a Keycloak database that has already consumed the import? Native HTTPS on retained `*.learnstack.local` hosts, with manual hosts/trust setup, is delivered by Phase 02d and re-verified by P02b-0 | A confidential BFF client with one redirect URI per seed host, retaining native HTTPS and `Secure` cookies; and an idempotent reconciler inside `make seed` — a mapper added only to the import reaches no existing workstation | Phase-doc + the realm JSON + `scripts/seed.sh` + `infra/keycloak/README.md`; a new ADR only if the reconciler becomes ADR-0004's `IIdentityProvider` | P02b-0 re-verifies [Phase 02d G32](phase-02d-walking-skeleton.md#the-decision-register), ADR-0053/0054 and ADR-0055; P02b-5 (which client the BFF redeems the code with: the API pins `azp` against it), P02b-6 | | G13 | What is the BFF session — its custody of the refresh token, its store, its idle and absolute lifetimes, its refresh serialisation under concurrent requests, its terminal `invalid_grant` behaviour, its CSRF control and its logout — and which document owns the access-token lifetime? | The `HttpOnly` cookie Security Standards already describes, encrypted by the session adapter, read only by server code, with single-flight refresh and one authority for the TTL | Phase-doc + one reconciled [Security Standards § Authentication](../standards/11-security.md#authentication); a **new ADR** only for a server-side session store | P02b-6 (which document owns the access-token lifetime: the realm export and its reconciler write it), P02b-7 | | G14 | What bounds the request types reachable over HTTP while `AuthorizationBehavior` is `return next()`, and what is the shape of the token-keyed rate-limit stage behind a shared BFF connection? | An enumerated read-only routable set held by a catalogue-registered rule, and a limiter stage after `UseAuthentication` keyed on the validated subject. The early anonymous stage already runs before authentication: P02d-5 delivers 60/min per canonical direct or authenticated-hop visitor IP plus 600/min peer acquisitions/allocations under ADR-0053 and ADR-0054. Exhausted known visitors refuse before peer debit; unknown visitor allocation remains peer-gated. These remain IP/peer quotas, not token quotas; a request they reject never reaches the token stage. Re-verify the [P5 remediation decision](phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09) before choosing authenticated accounting, preserving pre-classification protection against unknown-host abuse | Phase-doc + catalogue registration; a dated ADR-0032 Amendment for a deny-by-default step instead | P02b-5 (the token-keyed stage and the early stage's treatment of validated callers); G34's anonymous-hop policy and ADR-0054's bounded accounting replacement are Accepted and delivered in P02d-5 | | G15 | What initiates a replay in this phase? | A dispatcher-level operation on the dispatcher's own connection, with the operator surface deferred | Phase-doc; the URL space is its own decision record, per ADR-0036 | — (answered in § The consumer side; recorded so the question travels with the deferral) | @@ -677,20 +685,15 @@ settles it; what the phase ships either way: mutating route. `SameSite=Lax` does not separate hosts under one registrable domain, which is exactly the SaaS subdomain shape, and no CSRF rule exists in the catalogue today. -- **Login topology**, under **G12**: login is served on the tenant host the visitor is - already on, which needs one registered redirect URI per seed host — the realm - registers only `localhost` today, and the product is reached on two tenant hosts. It - also needs a transport the session cookie can survive: `Secure` is a secure-channel - attribute, the seed hosts are `*.learnstack.local`, and the frontend's dev script - serves plain HTTP, so the cookie this phase sets would be accepted and never sent - back. Which transport and hostnames serve the seed tenants — local TLS with its trust - step, or the dev hosts moved under `localhost` — is **G32** in - [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register), - which closes before this phase starts; the pass that closes it edits this text with - its answer, and `P02b-0` re-verifies it. This gate settles the callback and - post-logout URLs that answer needs, and neither gate may drop `Secure`. The login - transaction's `state`, `nonce` and `code_verifier` live in a host-only transaction - cookie, and return URLs are validated. +- **Login topology**, under **G12**: login is served on the visitor's tenant host, + requiring one registered redirect URI per seed host. Phase 02d retains + `demo-english.learnstack.local` and `demo-yoga.learnstack.local` under the native + HTTPS launcher; hosts entries and certificate trust remain manual prerequisites. + `P02b-0` re-verifies [G32](phase-02d-walking-skeleton.md#the-decision-register) + and the ADR-0053/0054/0055 ingress, visitor/peer and single-bootstrap boundaries. + G12 still settles the BFF client, callback and post-logout URLs; it preserves + `Secure`. The login transaction's `state`, `nonce` and `code_verifier` live in a + host-only transaction cookie, and return URLs are validated. - **Logout**, which [Identity and Auth § Logout](../architecture/13-identity-and-auth.md#logout) describes and Phase 03 expects: the session cleared, the end-session endpoint called, and @@ -704,6 +707,16 @@ settles it; what the phase ships either way: imports `auth` from a package that does not exist and a second calls `auth()` with no import at all; both are corrected here. +### Tenant settings eager invalidation + +This phase owns `learnstack.tenancy.settings`: Tenancy publishes supported settings +changes through the durable outbox, and settings readers consume eager invalidation, +as declared by the [Tenancy event catalogue](../modules/tenancy/README.md#integration-event-catalogue). +Its producer/payload, consumer scope and failure/replay behavior are settled in the +owning event packet's decision pass before implementation. P02d-3's +`ITenantSettingsAccessor` uses uncached ambient scoped reads; this handoff introduces +no settings cache, staleness window or organization branding override. + ### Audit coverage wiring The audit pipeline lit up in @@ -907,6 +920,8 @@ criterion says *not skipped*, per assembly. [Phase 11](phase-11-production-hardening.md) picks the purge interval that honours it. - `IOutbox` and the `OutboxFlushBehavior` body, with the producer write model G2 settles. +- The `learnstack.tenancy.settings` producer and eager-invalidation consumers, + with tenant/organization scope preserved and delivery/replay behavior tested. - A durable `OutboxProcessor` implementing the specified lease: a fenced claim, a per-delivery deadline, bounded retry, a terminal producer state, and the migration that adds `locked_by` / `locked_until` **and** extends `learnstack_outbox_admin`'s @@ -948,8 +963,9 @@ criterion says *not skipped*, per assembly. with its full validation contract written into [Security Standards § Authentication](../standards/11-security.md#authentication), and token-keyed rate-limit partitions going live with authentication — including the - change **G14** settles to the existing pre-auth peer stage, which today caps every - caller behind one BFF at the anonymous budget before any token is read. + change **G14** settles to the existing pre-auth floor: 60/min per canonical + visitor and 600/min peer acquisitions/allocations under ADR-0053/0054. These + anonymous visitor/peer quotas remain distinct from the token-keyed budget. - The rejection record carrying a source, an actor and a correlation id; the recorder writing `metadata.assertionSource` and populating `actor_user_id` on the authenticated tier; and the resolver's refusal path calling the recorder rather than only logging. @@ -1183,12 +1199,19 @@ shape depends on an open gate say so, and are written when that gate is Accepted with admin-only view and edit; a variant export marking either attribute user-editable fails the case; and a seed user's own token cannot write either through Keycloak's account REST surface. -- Two principals behind one BFF do not share a quota: through the **whole** middleware - chain, from one peer address, one exhausts the authenticated budget and the other's - next request is not 429 — a case that fails today, because the pre-auth peer stage - rejects at the anonymous budget first. Anonymous abuse from an unknown host stays - bounded. The 429 carries the one Problem Details shape and `Retry-After`, and repeated - mismatches from one token reach 429 and stop producing audit rows. +- Authenticated and id-addressed routes have tenant and organization isolation + cases as `learnstack_app`: an identifier from another tenant or an invisible + sibling organization reveals no resource, and each route has a visible-resource + positive control. Host/JWT cross-check cases remain independently asserted. +- A supported settings change enqueues `learnstack.tenancy.settings` with the + correct scope; its consumers invalidate only that scope, and replay follows the + inbox contract without repeating the effective side effect. +- Two principals behind one BFF do not share an authenticated quota: through the + **whole** middleware chain, from one peer address, one exhausts that budget and + the other's next request is not 429. G14 settles the delivered pre-auth visitor/ + peer floor's treatment of validated callers; anonymous unknown-host abuse stays + bounded. The 429 carries the one Problem Details shape and `Retry-After`, and + repeated mismatches from one token reach 429 and stop producing audit rows. - A second `make seed` against a Keycloak database created from the pre-mapper realm JSON leaves every existing account and the `learnstack-hub` realm intact, applies the client scope and the mappers, and yields a token carrying a UUID-valued `tenant_id`. A @@ -1200,8 +1223,8 @@ shape depends on an open gate say so, and are written when that gate is Accepted `SameSite=Lax`, `Path=/` with no `Domain`; and no response body or script-readable cookie carries the refresh token. Asserting the header is not enough: on each real seed host a browser-level case shows the cookie **stored and sent back** on the next - request, which is what fails if the dev transport Phase 02d's G32 settles is not in - place. + request through the retained native HTTPS launcher and manual hosts/trust setup + that `P02b-0` re-verifies. - Two concurrent requests with a near-expiry token produce exactly one token request at the issuer and one logical session version — counted as versions, not as `Set-Cookie` headers, because a session adapter may split one cookie across several and a correct @@ -1247,13 +1270,14 @@ shape depends on an open gate say so, and are written when that gate is Accepted amendment written after the code it governs is a record nobody can disagree with — the lesson Packet 9 recorded for its own readings. The mitigation is the decision pass each packet opens with: its decisions are its first commit, and its code follows them. -- **A gate is answered before the ground it stands on exists.** Several premises the - register cites are code [Phase 02d](phase-02d-walking-skeleton.md) has not yet - written: the first `/api/v1/*` endpoints, which **G14** and **G20** bound; the local - hosts **G12**'s transport serves; and the socket-peer limiter G14 describes, which - Phase 02d's server-rendered pages reach through one peer. Earlier packets move later - premises the same way: the dispatcher's keyed data source can change the connection - allow-list before **G9** decides the job storage's entry in it. The mitigation is +- **A gate relies on a premise that an earlier packet has moved.** + [Phase 02d](phase-02d-walking-skeleton.md) delivered public GET/HEAD endpoints, + retained native HTTPS tenant hosts and coordinated canonical visitor/physical-peer + admission. **G14**, **G20** and **G12** must use those delivered contracts, including + single-bootstrap rendering and exact peer accounting, rather than the former + socket-peer-only assumption. Earlier packets move later premises the same way: + the dispatcher's keyed data source can change the connection allow-list before + **G9** decides the job storage's entry in it. The mitigation is [Roadmap § Decision Timing](README.md#decision-timing): `P02b-0` re-verifies the register once Phase 02d exits, and each gate closes in the decision pass of the first packet it shapes. diff --git a/docs/roadmap/phase-02d-exit-evidence.md b/docs/roadmap/phase-02d-exit-evidence.md new file mode 100644 index 00000000..54a9c1c3 --- /dev/null +++ b/docs/roadmap/phase-02d-exit-evidence.md @@ -0,0 +1,274 @@ +# Phase 02d — individual exit evidence + +**Reconciliation complete — 2026-10-11.** All four P7 steps completed both +independent review rounds. The real PR check receipts, verified artifacts, +seventh-check activation and positive exit verdict are recorded in the +[CI and exit closeout](phase-02d-walking-skeleton.md#p02d-7-ci-and-phase-exit-closeout-2026-10-11). +This document is an evidence index, not a new architecture decision or a merge +approval. PR #28 awaits maintainer review and merge. + +The rows enumerate all 36 [deliverables](phase-02d-walking-skeleton.md#deliverables), +79 [completion criteria](phase-02d-walking-skeleton.md#completion-criteria), +39 [exclusions](phase-02d-walking-skeleton.md#explicitly-not-in-this-phase) and +nine [phase-exit obligations](phase-02d-walking-skeleton.md#phase-exit-decision), +in source order. Requirement locators abbreviate the canonical text; the linked +phase document owns the complete contract and every subordinate condition. + +## Receipt basis + +| Code | Required receipt and present boundary | +|---|---| +| B | Real PR `backend` passes: Release build/format, 1,705 unit, 203 architecture, four contract and 180 Docker-free integration; zero failures/skips and execution guards. | +| I | Real PR `backend integration (Testcontainers)` passes all 872 cases, including the three locale/branding/combined-scope cases and physical app-role/RLS/read-only production proofs; zero failures/skips. | +| F | Real PR guarded `frontend` passes 1,444 web + 60 SDK cases, zero failures/skips/todos, plus lint, typecheck, builds and controls. | +| O | Real PR `openapi diff` and generated SDK drift pass with retained planted incompatibility controls. | +| J | Real PR `public demo + lighthouse` passes with verified nine-page/27-audit artifacts and confirmed cleanup; authorized seventh-context activation and complete protection read-back pass. | +| M | Named source/document inspection and explicit future-owner reconciliation in Step 4; both fresh review rounds and verified fixes complete. | +| H | Attributed carried-forward P6 manual evidence below; no new final-head human/WCAG claim. | +| N | Conditional branch made inapplicable by its Accepted decision, as named below. | + +Named backend classes live under `backend/tests/`; frontend tests live under +`frontend/apps/web/src/` or `frontend/scripts/`. Partial-class filenames are +identified separately where needed. P1–P6 refer to the merged packet records in +[Phase 02d](phase-02d-walking-skeleton.md); Step 1–3 refer to P7's records. +[Run 38108622191](https://github.com/HodeTech/LearnStack/actions/runs/38108622191) +supplies B/I/F/O/J for reviewed implementation source `d47ef17`, tested merge +`ba44563`; the [exact-head verification](phase-02d-walking-skeleton.md#reviewed-implementation-head-verification-2026-10-11) +records full identities and artifact validation. The earlier run owns +seventh-check activation history. The +[PR rollup](https://github.com/HodeTech/LearnStack/pull/28/checks) must also pass +on subsequent heads before merge; historic counts do not replace it. + +## Local and manual evidence boundaries + +Step 3's latest complete local lifecycle uses source-copy commit +`39cee01438c48c290ba557bc22de85b8b21269bb`, build `d0bzXjXoImUdH317X83rK`, invocation +`825982bee2ed568f43208368f99f332d`: setup 60,374 ms, proof 585,644 ms and cleanup +10,719 ms. It produced 27 JSON/HTML pairs, nine screenshots and 24 planted +visibility refusals. All 134 API observations were 200; none were 429, malformed +or pending. Normalized gzip was 106,383–106,574 bytes, CLS 0 and automated +accessibility 1 throughout. This is local feasibility, not receipt J or an +exact final-head CI result. The subsequent shared shutdown-bound fix has its own +causal focused proof; the final CI lifecycle must include it. + +H retains PR #27's original manual attribution: commit `180dde0`, macOS 27.0.1, +Brave 154.1.96.61, both seeded hosts and EN/TR routes. The +[P6 closeout](phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) +records keyboard/focus, 320 CSS-pixel reflow and long-string observations, +calculated contrast, and the maintainer's VoiceOver catalog → course → lesson, +missing-page and invalid-link recovery smoke. Seeded contrast ratios were +17.740/7.557/6.702 (English), 14.628/7.357/6.876 (Yoga) and +17.853/4.759/4.634 (defaults). This is not full WCAG, mobile-device or zoom +conformance, nor a rerun on P7's final head. P7's real browser and Lighthouse +checks supplement that record; they do not manufacture new human evidence. + +## Deliverables + +| ID | Requirement locator | Named evidence | Receipts | +|---|---|---|---| +| D01 | `Course` and `Lesson` in `LearnStack.Modules.Education`, in the aggregate shape **G2** records, with migrations, EF configurations, query filters and Row Level Security policies, including each … | P1; EducationPersistenceTests, EducationIsolationTests, CourseContentAccessMigrationTests. | B, I | +| D02 | `course_translations` and `lesson_translations`, conforming to Database Standards § Translation satellite tables, with the accepted slug grammar in the Education spec. | P1; EducationConstraintTests, EducationStructureTests; Education spec Localization and URL identity. | B, I, M | +| D03 | The organization immutability trigger on all four tables, with a function that works on a table without `id` (**G8**); an index supporting every Education … | EducationParentScopeTests, EducationIsolationTests, EducationStructureTests, OrgScopeSchemaTests/OrgScopeMigrationTests. | I | +| D04 | The four tables' rows in Database Standards § GRANT matrix, written by the migration that creates each table, with the exact-grant test and `SchemaFixture.KnownTables` … | EducationStructureTests, SchemaFixture.KnownTables; Database GRANT matrix and creating migrations. | I, M | +| D05 | The structural guards accepted by **G8**, each Implemented with a companion that plants its violation. | P1; tenant-FK, organization-immutability and Pattern-A catalogue guards, EducationStructureTests and planted companions. | B, I, M | +| D06 | Education isolation tests connected as `learnstack_app`, at schema level and at request level, over the four tables, across tenants and across organizations, per Testing … | EducationIsolationTests, PublicEducationHttpTests, TenantIsolationHttpTests, SeederTests. | B, I, M | +| D07 | The Education module spec under `docs/modules/education/` (`README.md`, `audit.md`, `permissions.md`), with its state diagram and the eligibility rule **G29** records, and `education` added to `Every_Module_Has_An_AuditCoverage_Matrix`'s … | docs/modules/education/{README,audit,permissions}.md; AuditCoverageTests. | B, M | +| D08 | The Education write commands (**G11**) and the Tenancy commands raising `tenancy.locale.write` and `tenancy.setting.write`; each removes its matrix row's `(planned)` marker and registers its catalogue … | EducationWriterTests, TenancyWriterTests, AuditCoverageTests, CompositionRootCatalogueTests. | B, I, M | +| D09 | The `Customization.Application.Contracts` surface — an interface or a query, as **G12** records — through which Education obtains the content-type revision a lesson body is … | Customization.Application.Contracts exact-definition surface; EducationWriterTests.Exact_active_bindings_and_existing_deprecated_pins_use_their_own_schema. | B, I | +| D10 | An Education `IAuditCatalogSource` registered in the API and seeder composition roots, and the Education Application assembly in both roots' handler scans, with a request-level … | CompositionRootCatalogueTests; EducationWriterTests and SeederTests exercise both roots. | B, I | +| D11 | The seeder's acts, ownership checks, ordering and contexts (**G14**, **G15**), and each tenant's own content type, taxonomy, locales, branding, courses and lessons. Every seeded … | SeederTests.Complete_inventory_has_exact_states_pins_translations_and_null_or_exact_write_scope plus repeat/race/refusal cases. | I | +| D12 | The `[PiiSensitive]` record on `TenantSetting.Value` (**G17**) and the branding token contract (**G16**), with its detail in Frontend Architecture Standards § Tenant Branding. | TenancyWriterTests.Whole_json_value_is_redacted_in_the_capture_and_durable_setting_audit; BrandingThemeTests. | B, I, M | +| D13 | The customization read path per Tenant Customization Model § 8.2: the Customization contract, classified wherever it adds request types, and the generation-keyed projection with … | CustomizationProjectionTests, CustomizationProjectionTests (composition partial), CustomizationProjectionTests (cache partial); InMemoryCacheServiceTests. | B, I, M | +| D14 | The typed tenant and organization settings accessor over `tenant_settings`, internal to the backend, with its interface name and glossary headword, key composition, loader and … | TenantSettingsAccessorTests, TenantSettingsTests; Tenancy spec/glossary; G23 selects no settings cache. | B, I, M | +| D15 | The anonymous public reads — the Education reads in § Read API and whatever **G25** adds — each with its `[PublicSurface]` marker, its row … | PublicSiteHttpTests, PublicEducationHttpTests, PublicSurfaceTests, OpenApiContractTests; API Public surface table. | B, I, M | +| D16 | The two unimplemented legs of `PublicSurface_Marker_Set_Is_Enumerated`, each with a companion that proves it can fail, and the catalogue entry's Status updated (**G28**). | PublicSurfaceTests, PublicReadTransactionTests; P4 physical write-refusal plus successful writable control and structural offenders. | B, I | +| D17 | The `@learnstack/sdk` types regenerated from that document (`src/generated/schema.d.ts`) and the SDK surface that compiles against them (**G31**). | frontend/packages/sdk/src/generated/schema.d.ts; SDK tests and apps/web typecheck. | F, O | +| D18 | The CI gates the corpus assigns here: the OpenAPI breaking-change check over a committed snapshot and the SDK drift gate (**G31**), each activated through … | P1/P4 dated activation/planted controls; six retained required contexts. Step3 local Lighthouse proof is preliminary. | O, J | +| D19 | The two seed host rows Phase 02a Packet 7 wrote, reachable from a browser and from `make demo` over the hostnames and transport **G32** … | P5 G32 retained *.learnstack.local; Step1 seeded demo readiness; Step3 nine-page browser feasibility. | J, M | +| D20 | The server-only configured transport in `frontend/apps/web/src/server/configured-public-client.ts`, stating the verified visitor's host over the trusted hop (**G35**). The injected GET wrappers in `frontend/packages/sdk/src/server.ts` remain authority-free. | configured-public-client.test.ts, public-boundaries.test.ts, PublicServerRenderingTests. | I, F | +| D21 | `frontend/apps/web/src/middleware.ts` no longer answering the scaffold's `503`, writing the raw host as a tenant id or carrying TODOs that assign the work to Phase … | public-entry.test.ts, public-admission*.test.ts, PublicAdmissionRenderingTests. | I, F, M | +| D22 | A development trusted-hop configuration the API and the Next.js server agree on, with every variable it adds listed in `.env.example` (Infrastructure Standards); placement per … | P5 configuration/private launchers; Step1 manifest/configuration tests; .env.example and web env example. | B, I, J, M | +| D23 | The hop runtime evidence **G33** chooses, and the dated ADR-0036 amendment if G33 or **G34** requires one. | P5 trusted-hop/native integration, ADR-0036 dated amendment, Step3 accepted loopback/native CI topology. | I, J, M, H | +| D24 | The anonymous limiter's treatment of trusted-hop traffic as **G34** decides, including a decision to leave it unchanged; any rule change ships with its carriers … | RateLimitingHttpTests and anonymous admission/lifecycle/request-identity tests; ADR-0053/0054. | B, I, J, M | +| D25 | The `(public)` routes **G25** and **G40** fix in `frontend/apps/web`, tenant-branded, rendering from customization data, with link navigation from catalog to lesson. | P6 public route/link tests and product fixture; Step3 actual linked three-lane browser feasibility. | I, F, J | +| D26 | The lesson-body renderer: the composite **G18** chooses, registered in `composites.ts` under the containment rule; primitive components for the implemented subset, in the home **G41** … | P6 lesson renderer tests, public-rendering-assertions.test.ts, production presentation fixture. | I, F, M | +| D27 | Theming token injection in the `(public)` layout per **G42**, emitting only the `--ls-*` vocabulary. | public-ui.test.ts, public-boundaries.test.ts, production product fixture; Step3 computed colors. | I, F, J | +| D28 | The UI message layer and catalogue at the location **G39** records. | WEB i18n/messages/{en,tr}/public.json, i18n/ICU controls, Accepted ADR-0027. | F, M | +| D29 | Frontend tests for the code this phase adds, with the case set **G38** decides. The harness exists (`vitest.config.ts`, jsdom, Testing Library), with Packet 3b's … | P5/P6 frontend and actual-production HTML/Flight tests; ADR-0055 admission proof; Step3 narrow browser proof. | I, F, J, M | +| D30 | The required `frontend` job refusing a Vitest run that reports a skipped or todo case in every workspace package that carries tests, proven with … | Guarded workspace runner and planted skip/todo/empty/omitted-workspace controls; No_Architecture_Test_Is_Skippable. | B, F | +| D31 | The accessibility review Accessibility Standards requires of every new screen, recorded in the renderer pull request's description: a manual keyboard walkthrough of each public … | PR27 description preserves attributed prior manual smoke and links Original manual evidence to P6 Step4; P6 manual/VoiceOver closeout. | H | +| D32 | The tenant-branching check **G20** selects, Registered in the first pass that uses it and Implemented with its companion before exit. | Step2 DomainGenericityTests.DemoIdentities.cs, demo-genericity.test.ts, Production_Projects_Do_Not_Reference_The_Seeder, schema-replacement fixture. | B, I, F, M | +| D33 | `make demo` and its stop behaviour per **G45**, with README § Quickstart, the closing output of `scripts/seed.sh` and the `local-dev-setup` and `seed-tenant` skills updated … | Step1 demo-lifecycle controls/shared supervisor; README, seed output and local-dev/seed/test skills updated to the delivered shared lifecycle. | J, M | +| D34 | The full-stack Lighthouse job and its committed configuration, if **G44** activates it here. | Step3 pinned browser/Lighthouse configuration, gatherer/report/gzip/admission controls and local 27-report feasibility. | J | +| D35 | The standards index rows this phase makes stale — the notes on Frontend Coding, Frontend Architecture and Accessibility Standards, the table count on Database … | Database counts updated previously; Accessibility already Active. Performance is Active for implemented hard P7 checks; all four behavioral P7 rules are Implemented. Permissions remains Adopted. | B, F, J, M | +| D36 | The delivery record at exit names the Education model shipped, the preservation obligations G2 recorded, the seed fixtures Phase 05's migration must carry, and … | Phase05 inherited baseline already preserves rows/IDs/URLs/order/scope/body/pins/policy. | M | + +## Completion criteria + +| ID | Requirement locator | Named evidence | Receipts | +|---|---|---|---| +| C01 | Opening host A shows the English school's catalog, and host B the yoga studio's. Each shows its own branding with `NullEntitlementProvider` registered, which **G16** … | P6 manual/product record; Step3 identity/computed-theme/field feasibility; NullEntitlementProvider default retained. | I, J, H | +| C02 | On either host, a visitor starting at the catalog reaches a lesson through rendered links alone, without typing a URL, and the lesson renders … | P6 route/link tests and manual record; Step3 actual linked navigation in three lanes. | I, F, J, H | +| C03 | Both hosts are answered by the same API build and the same `apps/web` build against one database and one schema; no build or deployment … | Step1 shared API/web/database lifecycle; Step3 receipt/build observations. | J | +| C04 | The two lesson pages render **different field sets**, driven by each tenant's `TenantContentType` — not the same template with different strings — each field … | SeederTests distinct schema/taxonomy inventory; Step2 orderedLessonFields controls and successor/rebinding product fixture; P6 locale/label tests; Step3 ordered browser fields. | B, I, F, J | +| C05 | Neither seeded lesson page, served by the running stack, renders a fallback or placeholder block (the full-stack job, **G44**; otherwise **manual**). | Step3 actual-page/lesson field admission refuses placeholder/wrong pages. | J | +| C06 | `make demo` on a clean checkout — no `.env`, no `frontend/apps/web/.env.local`, no compose volumes, and no host configuration beyond **G32**'s host step — leaves … | Step1 clean isolated lifecycle/repeat/stop controls; Step3 shared lifecycle feasibility. | J | +| C07 | If **G32** moves the seed hosts, re-running the documented seed or demo path against a database seeded with the old host names leaves both … | G32 Accepted retained *.learnstack.local; no host-name move occurred. | M, N | +| C08 | After seeding, with the compose `keycloak` service stopped, the catalog and a lesson page on both hosts still render anonymously — the baseline Phase … | Step3 asserts CI-owned Keycloak stopped before fresh browser navigation on every lane. | J | +| C09 | Two courses in one tenant cannot both hold one `(locale, slug)` pair; the second insert is rejected by the database, and the rejection also … | EducationConstraintTests.Localized_slug_uniqueness_spans_parents_and_organizations and Localized_slug_namespace_is_independent_between_tenants_and_locales; exact-locale HTTP cases. | I | +| C10 | Read directly, each of `courses`, `lessons`, `course_translations` and `lesson_translations` returns no foreign tenant's rows, tenant-wide ones included, and no sibling organization's rows; each has … | EducationIsolationTests, four-table WITH CHECK coverage and raw satellite reads. | I | +| C11 | Under tenant A, inserting a lesson whose course key names tenant B's row, or a translation whose parent names tenant B's row, is refused … | EducationIsolationTests and EducationParentScopeTests. | I | +| C12 | A child row whose organization differs from its parent's — including a null-organization child planted under another organization's course and an organization child under … | EducationParentScopeTests, EducationIsolationTests, OrgScopeSchemaTests. | I | +| C13 | Changing `organization_id` on a row of each of the four tables raises the immutability error from a session the restrictive guard admits (**G8**). | EducationIsolationTests four-table immutable organization cases. | I | +| C14 | `Every_Foreign_Key_Has_A_Supporting_Index` passes with the four tables in its swept set, and `Unique_Indexes_On_Soft_Deletable_Tables_Exclude_Deleted_Rows` with each of them that carries `deleted_at` under the mapping **G2** records; … | EducationStructureTests, MigrationRollbackTests, MigrationChainCoverageTests and structural planted companions. | B, I, M | +| C15 | Before the first Education migration merges, **G2**'s row is closed, the Education spec's data model names `Course`'s and `Lesson`'s aggregate roles, and the lessons … | P1 accepted G2 decision/delivery/merge record and Education data model. | M | +| C16 | A publication-state value outside the `CHECK` set **G3** records is rejected by the database as `learnstack_app`. | EducationConstraintTests publication-state CHECK cases. | I | +| C17 | `docs/modules/education/audit.md` exists, and `Every_Module_Has_An_AuditCoverage_Matrix` names `education` (`backend` job). | Education audit.md and AuditCoverageTests pinned module sweep. | B | +| C18 | After the seed, each seed tenant has one committed MUST-class audit row per published course and per `tenant_settings` write, whose entity id matches the … | SeederTests and TenancyWriterTests durable redaction; EducationWriterTests.Publication_requires_durable_must_audit_and_rolls_back_on_audit_failure. | I | +| C19 | No Tenancy or Education matrix row whose command ships still carries `(planned)`, and `Every_Shipped_Request_Is_Registered` and `CompositionRootCatalogueTests` pass for both roots. | AuditCoverageTests, CompositionRootCatalogueTests and Tenancy/Education matrices. | B, I, M | +| C20 | A second seed run exits 0 and changes no Education, `tenant_locales` or `tenant_settings` row; each seeded row carries the `organization_id` **G14** names; each seed … | SeederTests.Running_the_seed_twice_changes_nothing_and_still_succeeds, exact inventory/process-repeat cases; Step1 audit-neutral repeat. | I, J | +| C21 | The Phase 02a request-level suite (`TenantIsolationHttpTests`) still names the exact rows it expects: its customization cases assert `BeEquivalentTo` an enumerated per-tenant set that includes … | TenantIsolationHttpTests enumerated exact seed sets; SeederTests exact generations/counts; P2 writer derivation record. | I, M | +| C22 | With migration history unchanged, a request-level test adds a third locale — a `tenant_locales` row through the Tenancy locale command plus translation rows through … | PublicLocaleEvolutionHttpTests.Third_locale_authored_through_api_commands_serves_exact_new_urls_without_changing_existing_responses_or_migrations; local focused case passed; downloaded real PR receipt I passed. | I, M | +| C23 | An Education translation write for a locale absent from, or disabled in, the tenant's `tenant_locales` behaves as **G13** records, and a refused write commits … | EducationWriterTests.Locale_slug_lifecycle_and_stale_refusals_preserve_the_root_and_reserve_slugs_on_insert; Seeder exact canonical inventory. | I | +| C24 | A lesson write whose body fails its bound revision returns `validation_failed` with details keyed by JSON pointer and persists no lesson or translation row; … | EducationWriterTests.Exact_active_bindings_and_existing_deprecated_pins_use_their_own_schema; EducationWriterValidationTests; Seeder exact non-card bindings. | B, I | +| C25 | A duplicate slug written through the command **G11** names returns `Result.Fail(business_rule_violation)` and does not throw. A slug outside the shape **G9** settles is refused … | EducationWriterTests, EducationWriterValidationTests, EducationConstraintTests. | B, I | +| C26 | A publication-state transition **G3** forbids answers `Result.Fail(business_rule_violation)` from the command, does not throw, and writes nothing (`backend integration`, plus a command or aggregate unit … | EducationAggregateTests/EducationWriterTests forbidden lifecycle and no mutation. | B, I | +| C27 | A branding value outside **G16**'s grammar — `red;}body{background:url(//x)}`, a string containing `` — never reaches a rendered document, and a refused write commits neither … | TenancyWriterTests branding refusal/atomicity, BrandingThemeTests, product theme defenses. | B, I, F | +| C28 | An organization-scoped branding command is refused without setting mutation; its Denied audit is retained, and the live organization host keeps the tenant palette. | PublicLocaleEvolutionHttpTests.Organization_branding_writer_is_refused_with_denied_audit_and_mapped_host_keeps_tenant_palette; local focused case passed; one expected Denied audit, no successful setting mutation; downloaded real PR receipt I passed. | I, M | +| C29 | If **G19** includes a write-time rule, a lesson write holding a disallowed URL is refused with details naming the JSON pointer, and nothing is … | G19 Accepted text profile introduces no active URL sink or scheme write-time rule. | N | +| C30 | Whatever **G18** refuses at save — which may include a presentation entry naming a property absent from `properties`, a malformed label map or a … | TextCardPresentationTests, customization command/schema validation tests. | B | +| C31 | A course write naming a taxonomy revision or band key the tenant does not declare, including one only the other tenant declares, returns `validation_failed` … | EducationWriterTests and EducationWriterValidationTests taxonomy/band binding validation. | B, I | +| C32 | Under **G15**'s answer, `SeedRunner` either no longer announces its own tenant context or appears in ADR-0040's setter table, and a planted caller fails any … | SeederConventionTests and contextual ownership verification/caller fence with planted offenders. | B | +| C33 | With the API running, publishing a successor content type or taxonomy for one tenant through its command changes what the next read returns for … | CustomizationProjectionTests/cache suites and production presentation successor/rebinding fixture. | B, I | +| C34 | A second warm read for the same tenant and generation issues no definition-set query; a publish that rolls back after its generation bump leaves … | CustomizationProjectionTests (cache partial), composition/publication concurrency tests. | B, I | +| C35 | None of the public reads, and no definition or settings read, invokes `IJsonSchemaValidator`. | CustomizationProjectionTests and architecture read-path guards. | B, I | +| C36 | If **G23** caches settings, then after that cache is warmed in the yoga studio's default organization, a read in the sibling organization's context and … | G23 selects no cache; TenantSettingsAccessorTests/TenantSettingsTests prove ambient scoped reads/current values. | B, I, N | +| C37 | Every cache family this phase adds appears in the cheat sheet, the Observability Standards family list and the `cache.name` mapping, and none of its … | InMemoryCacheServiceTests and three named cache-family carrier lists. | B, M | +| C38 | For a request in an enabled `tr-TR`, a label authored only under `tr` resolves as the chain **G24** records, and a value missing in … | PublicEducationDisplayTests and locale-resolution/projection tests; P3/P4 fallback records. | B, I | +| C39 | The out-of-band setter guard stays green: either the setter table is unchanged, or the ADR-0040 amendment and Security Standards row land with the loader. | Out-of-band setter guards and P2/P3 approved setter decisions. | B, M | +| C40 | In one test, a course slug that exists only in tenant B returns `200` with that course on tenant B's host and `404` on … | PublicLocaleEvolutionHttpTests.Public_host_locale_and_organization_contracts_hold_together_with_exact_request_connection_translation_scope pairs foreign-only/shared slugs with full seed-derived payloads. | I | +| C41 | In the same test, a course returns `200` at its slug in a locale it is translated into and `404` for that slug under … | The same PublicLocaleEvolutionHttpTests case pairs enabled/untranslated French with translated EN/TR course and lesson lists. | I | +| C42 | On the organization host, a sibling organization's course is absent from the catalog and its slug returns `404`, while a tenant-wide course and its … | The same PublicLocaleEvolutionHttpTests case proves three host scopes and exact raw satellite row sets on each of its 17 HTTP requests' own enlisted connection. | I | +| C43 | A draft course is absent from the catalog, and its detail slug and every lesson requested under it answer per **G29**; a lesson of … | PublicEducationHttpTests.Course_draft_branch_foreign_and_unknown_are_uniformly_hidden and Lesson_masks_draft_parent_draft_restricted_scope_wrong_parent_and_foreign_rows. | I | +| C44 | Each of the reads without `locale` returns `400` `validation_failed` naming `locale`, never a `500` or a defaulted locale; sending a different `X-Locale` or `Accept-Language` … | PublicEducationHttpTests query/detail/unsupported-locale cases including Well_formed_unsupported_locale_is_400_and_headers_do_not_replace_explicit_locale. | I | +| C45 | A cursor value the catalog list cannot read — malformed or truncated — returns `400` `validation_failed` naming `cursor`, never `500`. Walking `nextCursor` with a … | PublicEducationHttpTests query-shape/cursor/seek cases, PublicReadProtocolTests and OpenAPI exact parameter controls. | B, I, O | +| C46 | A course's embedded lesson list returns the same order on every read, including for any sort values **G2**'s invariant permits to tie. | PublicEducationStoreTests and PublicEducationHttpTests catalog/outline seek-order cases. | I | +| C47 | A lesson whose bound `(key, schema_version)` revision cannot be resolved (a fixture removes it) answers as **G12** records — never a `500`, and never … | PublicEducationHttpTests/PublicEducationDisplayTests and product presentation fault mode. | B, I | +| C48 | A stored band that the resolved taxonomy revision does not declare yields the state **G5** records, never a `500` and never another tenant's band … | PublicEducationDisplayTests, PublicEducationHttpTests and product band-fault mode. | B, I | +| C49 | On each seed host, each shipped anonymous read returns `200` under a host-only context, and the same request on an unknown host returns the … | PublicSiteHttpTests/PublicEducationHttpTests; PublicSurfaceTests/PublicReadTransactionTests runtime refusal/positive control and structural dispatch offenders. | B, I, M | +| C50 | Accepted **G28** records `Off`; serving anonymous reads on both hosts adds no `audit_log` rows. | PublicEducationHttpTests.Catalog_is_exact_locale_host_scoped_and_only_marketing_with_no_normal_audit and PublicSiteHttpTests. | I | +| C51 | Every anonymous response — a catalog `200`, a detail `404`, a bad-cursor `400` and an unmapped-host `404` — carries the directive **G27** decides, and … | Public HTTP no-store/validator cases and PublicServerRenderingTests A/B/A product fixture. | I | +| C52 | The committed OpenAPI document lists exactly the public operations the register decides, each with `locale` required on the Education reads, and a planted extra … | OpenApiContractTests and PublicEducationHttpTests exact public operation/DTO/status/bounded-list/alternate cases. | B, I, O | +| C53 | P02d-4 proves SDK wrapper coverage of all four logical GET contracts and HTTP coverage of all eight GET/HEAD operations, with a planted missing-operation companion. … | P4 SDK missing-operation/wrapper tests and eight GET/HEAD HTTP coverage; P5 configured/P6 every logical-page consumers. | B, I, F | +| C54 | The contract suite fails when the served `/openapi/v1.json` differs from the committed snapshot, shown with a companion; the breaking-change job fails on a planted … | OpenApiContractTests served snapshot/deny-list controls; P4 pinned oasdiff policy/offender suite; SDK drift/typecheck controls. | B, F, O | +| C55 | Each activated check has no `(deferred …)` suffix in `ci.yml`, appears in CONTRIBUTING's required list and in the live required contexts, and its skip … | P1-P6 dated merge/check/protection receipts exist. | O, J | +| C56 | `Handlers_Return_Result` stays green with Education's handlers counted, and an Education handler changed to return a raw DTO fails the build (`backend`). | Handlers_Return_Result with nonempty Education handlers and raw-DTO companion. | B | +| C57 | On both seed hosts the catalog renders through the Next.js server while no server-to-API call carries `X-Tenant-Id`, each host showing only its own tenant's … | PublicServerRenderingTests/hop HTTP tests and P6 manual walkthrough; Step3 browser feasibility. | I, J, H | +| C58 | Neither the built client assets nor rendered HTML contain the hop secret value, and no `NEXT_PUBLIC_` variable carries it. Only `frontend/apps/web/src/server/configured-public-client.ts` sets hop headers … | public-boundaries.test.ts/configured-public-client.test.ts and real production client-import/build probes. | I, F | +| C59 | Client-supplied `x-tenant-id`, `x-organization-id`, `x-locale`, `x-learnstack-host` and `x-learnstack-hop-secret` never reach an SDK call's outbound headers unchanged; under the production build and mandatory native launcher, public … | public-entry/admission/runtime tests and PublicAdmissionRenderingTests. | I, F, M | +| C60 | With private hop configuration kept outside version control, ordinary no-hop API startup works under committed Development settings with no `.env` present, and every Development-environment … | TrustedHopConfigurationTests and ordinary Development fixture baseline. | B, I, M | +| C61 | A non-hop peer is still limited per socket peer, getting `429` with `Retry-After` over budget, and rotating `X-Forwarded-For` or any header **G34** introduces buys … | RateLimitingHttpTests, anonymous request identity/admission/lifecycle and unknown-host cache tests. | B, I, J | +| C62 | If **G35** places it here, a server-to-API call carries a `traceparent` whose trace id matches the API's Problem Details `traceId`. | PublicServerRenderingTests transport mode and configured-public-client.test.ts W3C propagation/refusal controls. | I, F | +| C63 | On a production build, one `(public)` path requested on host A, then B, then A returns each tenant's own markers every time; the build … | PublicServerRenderingTests.Production_rendering_is_host_isolated_fresh_and_private_through_the_real_public_API and frontend forbidden-cache offenders. | I, F | +| C64 | Each rendered public page's `` equals the locale in its path, on both locales of the bilingual tenant, and `dir` follows that locale. | P6 lang/dir product/route assertions; Step3 actual product-document checks. | I, F, J | +| C65 | Each tenant-host page has one `
`, no skipped heading level, a skip link and a descriptive `` rather than a fixed platform string, and … | P6 public-ui/route/metadata/lint-rule tests and product assertions. | I, F, H | +| C66 | Catalog to lesson works keyboard-only on both hosts with focus always visible, each page reflows at 320 CSS px, and the text and UI-component … | P6 manual record on 180dde0: Brave154.1.96.61/macOS27.0.1, keyboard English/Turkish flow, visible focus, 320CSSpx desktop reflow, calculated palettes; maintainer VoiceOver confirms both flows. PR27 description preserves attribution and links original manual evidence. | H | +| C67 | On the two-locale tenant, every visible platform-authored string resolves through the message layer **G39** records, in both locales; Localization Standards § Strings in Code, … | ADR-0027 i18n catalogue/ICU controls and WEB messages. | F, M | +| C68 | Tenant B's hidden course URL on host A returns a local HTTP `307` to the fixed `/{locale}/status/not-found` URL, followed by visible localized HTTP `404` … | public-route/resource/admission tests; PublicServerRenderingTests/PublicAdmissionRenderingTests fixed redirect/status and fault modes. | I, F | +| C69 | A page whose course or lesson carries a band the resolved revision does not declare renders the state **G5** records, without throwing and without … | P6 UI and product band-fault tests. | I, F | +| C70 | Field values containing `<script>`, an `<img onerror>` fragment and a `javascript:` URL render inert, and a `data:` or `http:` URL is rendered only if … | P6 inert plain-text/fallback renderer tests, shared ordered-field predicate and product presentation fault modes. | I, F | +| C71 | A branding token value containing `;`, `}`, `url(` or `</style>` never reaches rendered HTML, and the theme is emitted only as `--ls-*` custom properties … | Theme and public-boundaries tests; real production fixture and Step3 network/product checks. | I, F, J | +| C72 | A non-branding setting present for a tenant (the fixture's `tz` row) appears in no `[PublicSurface]` response body and in no rendered HTML, on either … | PublicSiteHttpTests/PublicEducationHttpTests and product rendering privacy checks. | I | +| C73 | A change that adds `it.skip`, `describe.skip` or `it.todo` to any Vitest file under `frontend/` fails the required `frontend` check, and each predicate in **G38**'s … | Guarded frontend runner planted controls; P5/P6 product predicates; Step3 browser hard admission. | I, F, J, M | +| C74 | No production code branches on which tenant it serves. The mechanism is **G20**'s: registered in the catalogue, Implemented and green in a required check … | Step2 DomainGenericityTests.DemoIdentities.cs, demo-genericity.test.ts, Seeder-reference fence and production presentation successor companion. | B, I, F | +| C75 | No Education type, member, column, key or DTO name carries a forbidden domain term, and Education references Customization only through its `Application.Contracts` assembly (`backend`). | DomainGenericityTests and module-dependency guards. | B | +| C76 | If **G44** activates it in this phase, the Lighthouse job runs real steps under a name without "(deferred to Phase 02d)", audits the URL … | Step3 complete local nineURL x threeRun feasibility, report/gzip/admission/offender controls and zero429 positive observer. | J | +| C77 | Every register row is closed in the form **G1** chose; every Deliverable is observed by at least one criterion; no catalogue row whose Phase … | Four P7 behavioral entries are Implemented with Node owners, planted controls and the always-running public-demo job; CorpusConsistencyTests enforces status/count alignment. | B, I, F, J, M | +| C78 | Every row in § Explicitly not in this phase names a phase whose own document carries that capability in its scope, its deliverables or … | Owner audit X01–X39 below and synchronized current output/retained seed-host carriers. | M | +| C79 | The documentation Deliverables are in the tree at exit: the Education spec's `README.md` and `permissions.md` beside `audit.md`, with its state diagram and the eligibility … | Existing Education/Customization/Testing/schema/skill files and Phase05 preservation baseline. | M | + +C07 is conditional: G32 retains `*.learnstack.local`, so no seed-host migration +is required. C29 is conditional: P6 introduces no authored URL/media sink; +Phase 04/05/06 retain the named future contracts. C36 is conditional: G23 selects +uncached settings, so no settings-cache eviction rule is claimed. Other named +rows retain their full canonical conditions even when a locator is abbreviated. + +The three `PublicLocaleEvolutionHttpTests` cases passed locally with zero skips. +They use the normal API-root pipeline and actual mapped-host HTTP, under +`learnstack_app`, physical READ ONLY and non-BYPASSRLS. French-only new roots are +authored while Draft; published seeded roots are not edited to create the proof. +All five migration histories and complete pre-existing EN/TR DTOs remain equal. +The organization writer refusal commits exactly its expected Denied audit; +public reads remain audit Off. Downloaded receipt I includes all three Passed cases. + +## Exclusions and future owners + +These are explicit phase boundaries, not unfinished P7 implementations. The +named owners' documents carry the handoff; no future decision gate is accepted +by listing its phase here. + +| ID | Exclusion locator | Named owner | Evidence and boundary | +|---|---|---|---| +| X01 | Authentication, sessions, login | [Phase 02b](phase-02b-events-auth.md) | Phase02b P02b-5/6/7, Identity/session scope and criteria. | +| X02 | Token-keyed authenticated and write rate-limit budgets, and what the pre-authentication limiter does once a caller is validated | [Phase 02b](phase-02b-events-auth.md) (G14) | Phase02b G14/P02b-5 and whole-chain two-principal quota criterion. | +| X03 | Keycloak realm reconciliation (a seed step that writes to Keycloak) | [Phase 02b](phase-02b-events-auth.md) (G12) | Phase02b G12/P02b-6 and warm-realm repeat-seed criteria. | +| X04 | The `learnstack.tenancy.settings` eager invalidation event | [Phase 02b](phase-02b-events-auth.md), per [the Tenancy spec](../modules/tenancy/README.md) | Phase 02b Event Bus scope, deliverable and completion criterion explicitly own learnstack.tenancy.settings; the Tenancy catalogue names the same owner. | +| X05 | Isolation tests through authenticated or id-addressed routes | [Phase 02b](phase-02b-events-auth.md) | Phase 02b completion criteria explicitly include authenticated and id-addressed tenant/organization isolation with app-role denial and successful controls. | +| X06 | Identity domain, roles, permissions, invitations; permission keys for public reads; the rule that every endpoint carries `[Authorize]` or `[AllowAnonymous]` | [Phase 03](phase-03-identity-admin.md) | Phase03 Identity/authorization/invitations scope and deliverables. | +| X07 | A tenant-scope read across all organizations on a tenant host (the `app.scope` carrier) | [Phase 03](phase-03-identity-admin.md) | Phase03 Authorization explicitly owns app.scope and tenant-wide cross-organization carrier. | +| X08 | CMS editing, page builder, media library and tenant media origins | [Phase 04](phase-04-cms-media-pages.md) | Phase 04 Media security owns the tenant media-origin allow-policy, active URL sinks and corresponding delivery/validation criteria. | +| X09 | Per-locale publish readiness workflow, `tenant_route_slugs` registry | [Phase 04](phase-04-cms-media-pages.md) | Phase04 Page Model/Localization and tenant_route_slugs deliverable. | +| X10 | Studio authoring of translated field values (titles, bodies, slugs) and the view of untranslated gaps — where it lives is an open question | [Phase 04](phase-04-cms-media-pages.md#admin-studio-cms-screens); its screen row goes in [Phase 06 § Admin Studio — screen ownership](phase-06-renderer-admin-studio.md#admin-studio--screen-ownership) | Phase04 Admin Studio CMS Screens explicitly registers question of translated-field/gap UI placement and points to Phase06 screen ownership table. | +| X11 | `Accept-Language` negotiation for API-returned messages; the per-tenant locale fallback chain; the `/{locale}/{slug}` page routing shape | [Phase 04](phase-04-cms-media-pages.md) | Phase04 Localization scope explicitly lists per-tenant fallback, /{locale}/{slug}, publish readiness and Accept-Language API-message negotiation. | +| X12 | Authoring and translating a content type's field order and labels; the closed set of built-in primitive field types (number, boolean, date/time, select) | [Phase 04](phase-04-cms-media-pages.md) | Phase 04 Content Type System explicitly owns field order and localized label authoring; built-in field types remain a platform release boundary. | +| X13 | The schema-version migration path for stored instances; deleting a content-type revision after a zero-instance count | [Phase 04](phase-04-cms-media-pages.md) | Phase04 exact revision deletion requires zero-instance count; lazy/bulk schema migration explicit. | +| X14 | Customization deprecate and revise commands, and per-band taxonomy editing | [Phase 04](phase-04-cms-media-pages.md) and [Phase 05](phase-05-education-learning-content.md), consolidated in [Phase 06](phase-06-renderer-admin-studio.md) | Phase 04 owns deprecate/revise commands; Phase 05 owns per-band taxonomy editing, with Phase 06 consolidating Studio screens. | +| X15 | Tenant-authored page composition (`TenantPageBlock` rows and their cache family) | [Phase 04](phase-04-cms-media-pages.md); the block resolver is [Phase 06](phase-06-renderer-admin-studio.md)'s | Phase04 TenantPageBlock aggregate/migration/editor/renderer deliverable; Phase06 complete block resolver. | +| X16 | Deciding whether a customization change owes an integration event | [Phase 04](phase-04-cms-media-pages.md), per [the Customization spec § Integration-event catalogue](../modules/customization/README.md#integration-event-catalogue) | Phase04 Content Type System explicitly owns whether customization change owes integration event. | +| X17 | The `Redirect` model the CMS auto-creates when a published slug changes | [Phase 04](phase-04-cms-media-pages.md) | Phase04 Page Model/Localization Redirect auto-creation and shared namespace. | +| X18 | Search — the `ITenantSearch` port and its PostgreSQL default | [Phase 04](phase-04-cms-media-pages.md) | Phase04 Search port/PostgreSQL default section. | +| X19 | Search — the Meilisearch adapter behind that port | [Phase 09](phase-09-billing-integrations-analytics.md) | Phase09 Search/Meilisearch adapter deliverables and trigger. | +| X20 | Course versioning, programs, lesson items, completion rules; richer publish-readiness validation and versioned publication; catalog visibility separate from publication | [Phase 05](phase-05-education-learning-content.md) | Phase05 Catalog/Course versioning/lesson items/completion/runtime scope. | +| X21 | The migration that brings this phase's courses and lessons under course versions and modules | [Phase 05](phase-05-education-learning-content.md) | Phase05 What Phase02d supplies explicitly preserves existing rows/IDs/URLs/order/scope/translated bodies/exact pins/access policy in migration; re-seeding forbidden. | +| X22 | The authenticated course and lesson authoring surface, with the commands that edit a course or lesson or reorder lessons after the seed | [Phase 05](phase-05-education-learning-content.md) | Phase 05 owns authenticated post-seed edit/reorder/unpublish/delete lifecycle and its decisions; no such writer is claimed by P7. | +| X23 | The `Level` aggregate and the catalog API's levels resource; the `TenantLevelTaxonomy` editor | [Phase 05](phase-05-education-learning-content.md) | Phase05 Catalog Level/resource inheritance and Studio TenantLevelTaxonomy editor explicit. | +| X24 | The `TenantLessonItemType` read, the batched reference walk and the measured customization cost model; the `embed-html` sanitisation contract | [Phase 05](phase-05-education-learning-content.md) | Phase05 inherited TenantLessonItemType read, batched reference/cost model and embed-html contract. | +| X25 | Admin Studio, navigation menus, SEO metadata beyond what G40 settles for translated public pages, full block registry | [Phase 06](phase-06-renderer-admin-studio.md) | Phase06 renderer/Studio/block registry/navigation/full SEO scope. | +| X26 | The full `UnknownVersionBlock` / `UnknownBlock` placeholders and per-block error boundary; tenant-authored error pages; redirect handling | [Phase 06](phase-06-renderer-admin-studio.md) | Phase06 renderer explicit UnknownVersionBlock/UnknownBlock/per-block boundary/tenant404/redirects. | +| X27 | Branding configuration surface — tenant-admin editor, theme preview, logo, header and footer settings; the per-organization branding override (`OrganizationBranding`) and the token merge | [Phase 06](phase-06-renderer-admin-studio.md) | Phase06 Tenant branding/own Studio work explicit logo/header/footer/preview/OrganizationBranding merge. | +| X28 | Preview of draft courses and lessons through the production renderer | [Phase 06](phase-06-renderer-admin-studio.md) | Phase06 own Studio work explicitly preview published/draft through production renderer. | +| X29 | Browser end-to-end flows (Playwright) and axe checks through Playwright | [Phase 06](phase-06-renderer-admin-studio.md), per [Testing Standards § End-to-End Tests](../standards/06-testing.md#end-to-end-tests); any narrow smoke this phase adopts is G38 | Phase 06 owns full Playwright Test/axe flows; the narrow P7 Playwright-library browser and Lighthouse proof is explicitly separated. | +| X30 | Enrollment, learner portal, progress tracking; Course Access — whether content published anonymously here stays on the public path once enrollment exists | [Phase 07](phase-07-enrollment-learner-portal.md) | Phase07 enrollment/portal/progress/CourseAccess and public-path-vs-enrollment boundary explicit. | +| X31 | Live classroom | [Phase 08c](phase-08c-classroom.md) | Phase08c Live classroom/ILiveClassProvider/LiveKit deliverables. | +| X32 | Billing | [Phase 09](phase-09-billing-integrations-analytics.md) | Phase09 billing scope/deliverables. | +| X33 | Hub, entitlement gating | [Phase 02c](phase-02c-hub-foundation.md) | Phase02c Hub-backed entitlement/provider/enforcement scope. | +| X34 | APISIX as the edge gateway for a non-development deployment; gateway or CDN caching; edge rate limiting | [Phase 11](phase-11-production-hardening.md#security), on [ADR-0035](../decisions/0035-demand-gated-infrastructure.md)'s trigger | Phase 11 explicitly owns gateway/CDN response caching, authority keys, invalidation, private-content exclusion and its demand trigger. | +| X35 | Plan-differentiated API rate limits (`LimitKeys.ApiRatePerMinute`) | [Phase 11](phase-11-production-hardening.md#security) for the key's gate; [Phase 02c](phase-02c-hub-foundation.md) for its enforcement path | Phase11 Security explicitly owns LimitKeys.ApiRatePerMinute gate and links Phase02c generic enforcement path. | +| X36 | The L2 cache tier behind `ICacheService` (default `InMemoryCacheService`) | [Phase 11](phase-11-production-hardening.md), on [ADR-0035](../decisions/0035-demand-gated-infrastructure.md)'s trigger | Phase11 explicit Valkey-backed ICacheService/L1-L2 trigger. | +| X37 | Content-Security-Policy and the other secure headers | [Phase 11 § Security](phase-11-production-hardening.md#security), per the [standards index](../standards/README.md#honest-status-today) row for Security Standards | Phase11 Security explicit secure headers/CSP. | +| X38 | `GET /readyz`; the production Dockerfile and image pipeline; the performance baseline and load tests against Performance Standards | [Phase 11](phase-11-production-hardening.md) | Phase11 readiness/deployment images/performance/load-test scope/deliverables. | +| X39 | Custom-domain TLS automation | [Phase 11](phase-11-production-hardening.md), on [ADR-0035](../decisions/0035-demand-gated-infrastructure.md)'s trigger | Phase11 explicit custom-domain TLS ACME/DNS/renewal trigger. | + +## Phase-exit reconciliation + +| ID | Obligation | Required evidence | +|---|---|---| +| E01 | Every Completion Criterion holds with named/manual evidence | All C01-C79 rows, the three locale/branding/combined-scope HTTP/pipeline cases; actual B/I/F/O/J/H receipts linked at exact final head where applicable. | +| E02 | Every register row closed through G1 vehicle and carriers, including G32 development transport | Dated closure/link census; Accepted ADRs retained, implementation receipts distinct from decisions; Phase02b native HTTPS carriers reconciled. | +| E03 | Phase02a and Education schema/request isolation green as app role, zero skips, required | Exact final backend/backend-integration TRX/rollup, role/isolation evidence and skip guard. | +| E04 | OpenAPI/SDK/Lighthouse required and shown able to fail; live list/date; no deferred job names | O/J offender controls, successful final real PR rollup, dated seven-context strict protection read-back, all other settings preserved. | +| E05 | Clean make demo prints both working addresses; sites render with Keycloak stopped | J clean lifecycle/readiness/Keycloak status/browser/artifact/cleanup receipt; H human context retained. | +| E06 | G20 Implemented and green required | B/F nonempty census and dirty/clean controls, I schema-replacement companion, Step2 record. | +| E07 | Accessibility and renderer PR record | D31/C66 PR27 link and original manual attribution plus F/I/J automated controls; no inferred new manual pass. | +| E08 | Zero 02d Registered/Awaiting catalogue rows and standard/index alignment | Four P7 promotions with actual behavioral owners and hard CI, computed counts and Standard_Status_Headers_Match_The_Index exact-head receipt. | +| E09 | Delivery record names moved P02b-0 premises | /api/v1 contract, *.learnstack.local native HTTPS/manual trust, visitor+peer quotas, seed default-org/tenant scopes, no-cache settings/eager event owner, single bootstrap; do not Accept auth/BFF future gates. | + +The [Step 4 preservation and P02b-0 handoff](phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) +names the retained Education data, live API/host/trust/limiter/bootstrap premises +and future settings-event ownership. Phase 05 migrates the existing records; it +does not treat reseeding as migration. Course Marketplace ADR-0049 and Phase 09a +remain Proposed and are outside this exit. + +All E01–E09 obligations pass against the receipts and named evidence above, as +recorded in the positive CI/exit closeout. Maintainer PR review and merge are +separate actions; subsequent PR heads retain the seven required checks. diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index e7073dd5..76887cdd 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -1,6 +1,9 @@ # Phase 02d: Two-Tenant Walking Skeleton -> **Status (2026-10-10).** Phase 02d **in progress**. The kickoff, `P02d-0`, ships this +> **Status (2026-10-11).** Phase 02d implementation and exit verification are +> **complete**; [PR #28](https://github.com/HodeTech/LearnStack/pull/28) awaits +> maintainer review and merge. The [CI and exit closeout](#p02d-7-ci-and-phase-exit-closeout-2026-10-11) +> records the evidence and live protection read-back. The kickoff, `P02d-0`, ships this > plan — the inherited baseline, the packet table, the decision register, criteria that > name their evidence, and the corrections to the documents that contradicted the phase > — and no code. Every later packet opens with its decision pass and updates its own @@ -14,8 +17,8 @@ > | P02d-3 | Read internals | ✅ complete and merged — 2026-10-03; [merge closeout](#p02d-3-merge-and-closeout-2026-10-03) | > | P02d-4 | Public read API and contract checks | ✅ complete and merged — 2026-10-08; [merge closeout](#p02d-4-merge-and-closeout-2026-10-08) | > | P02d-5 | Server-rendering path | ✅ complete and merged — 2026-10-09; [merge closeout](#p02d-5-merge-and-closeout-2026-10-09) | -> | P02d-6 | Public renderer | ✅ implementation complete — 2026-10-10; all four steps and both review rounds per step complete; scoped browser/VoiceOver smoke passed; [packet closeout](#p02d-6-packet-closeout-2026-10-10); maintainer PR review/merge pending | -> | P02d-7 | Demo, full-stack CI and exit | not started | +> | P02d-6 | Public renderer | ✅ complete and merged — 2026-10-10; [merge closeout](#p02d-6-merge-and-closeout-2026-10-10) | +> | P02d-7 | Demo, full-stack CI and exit | ✅ implementation and exit verification complete — 2026-10-11; all four steps completed both independent review rounds; [CI and exit closeout](#p02d-7-ci-and-phase-exit-closeout-2026-10-11); PR #28 review/merge pending | **Acceptance update — 2026-10-02.** P02d-1 remains merged. The maintainer accepted ADR-0050/0051 and the [P02d-2 package](#p02d-2-decision-package-2026-10-02), including @@ -126,6 +129,13 @@ manual browser observations and the maintainer's passing VoiceOver smoke. P02d-6 awaits maintainer PR review and merge. P02d-7's demo, browser/Lighthouse harness and phase-exit decision pass are next; they have not started. +**P02d-6 merge complete — 2026-10-10.** The preceding notes record pre-merge +milestones. P02d-6 is now closed through +[PR #27](https://github.com/HodeTech/LearnStack/pull/27); its +[merge closeout](#p02d-6-merge-and-closeout-2026-10-10) records the final head, +verification and merge. Phase 02d remains in progress. P02d-7's demo, Lighthouse +and phase-exit decision pass is next; implementation has not started. + ## Goal Put a working education site in a browser — twice, on two hosts, for two tenants in @@ -426,7 +436,7 @@ premise a row cites is re-verified at that pass rather than trusted. | G17 | Does `TenantSetting.Value` carry `[PiiSensitive]`? [Phase 03](phase-03-identity-admin.md) sequences the decision before the first command writing `tenant_settings`, and this phase ships that command | Not marked, provided `tenancy.setting.write` admits only G16's closed key set, so the answer cannot stretch to keys a tenant invents; modelling a sensitive part as its own property stays open to Phase 03 | Contract: a dated phase-doc statement, reflected in `TenantSetting.cs`, the Tenancy spec and `audit.md`. Whole-value redaction of `jsonb` is settled by [ADR-0044](../decisions/0044-audit-write-path.md) Amendment 4 § 1 | P02d-2 (the first MUST-class settings audit row is written by the seed, and rows cannot be redacted retroactively); closes with G16 (a) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): generic whole-value PII redaction before the writer | | G18 | How is a tenant content type presented? `json_schema` is `jsonb`, which keeps no key order, and the schema profile collects only `x-renderer`, `x-taxonomy` and `x-language`. How are field order, a label per enabled locale and a composite's field roles carried; which registered composite draws a lesson for each seeded type; which primitives does this phase implement, and does `markdown` render; how do types with no primitive row (`integer`, `number`, `boolean`, enums) map; may a rendered type declare a field outside the subset; and is a presentation entry naming a missing property refused at save? | A LearnStack extension — `x-order` and `x-label`, or one ordered `x-fields` list — carrying Pattern B labels, resolved at write like `x-taxonomy`; one composite already in both registries; the reviews split on the subset — `text`, `list` and `link`, with `markdown` without raw HTML, or a placeholder until Phase 05's sanitiser; the seed uses only the subset | Contract: a dated ADR-0043 amendment for a keyword or a save-time refusal; a dated ADR-0018 amendment for a presentation column; a phase-doc statement for `title` plus `required`, which cannot carry two locales. Detail: [Tenant Customization Model § 2](../architecture/32-tenant-customization-model.md) and § 8.1, the Customization spec, the profile's extension and reference-graph skip lists, `composites.ts` | P02d-2 (the seed publishes both content types as `schema_version` 1 with their renderer keys and field kinds; a later answer needs successor revisions) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): ADR-0051 profile and first-render subset. Component placement/fallback remains G41 | | G19 | URL and markup policy for tenant-authored values on an anonymous page: which schemes (`https` only, or `http` too), credentials and `target`, which media origins, whether the rule is enforced on write — in the Education command, or as a validation gate Phase 04's entries share — whether the public API filters too, and whether URLs inside markdown fall under it. The write-time check constrains structure, not schemes: `format: uri` admits `javascript:` and `data:` | The reviews split on `http`; all refuse `javascript:`, dangerous `data:` and credentials; checked on write by a LearnStack rule and again on render; no third-party media in the seed | Detail: one home for the scheme list — [Security Standards § XSS & Output Encoding](../standards/11-security.md#xss--output-encoding) or [Frontend Architecture Standards § Security](../standards/07-frontend-architecture.md#security), not both; the Education spec's write rules; Tenant Customization Model § 8.1 if checked on write. Contract: a dated ADR-0043 amendment if it becomes a shared validation gate | P02d-2 (the lesson command's validation and the seed values; the render-time check reuses the answer) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): no active sink in the seeded text profile; future URL/markup contracts precede Phase 04/05 sinks | -| G20 | What mechanically backs "no production code branches on which tenant it serves"? The shipped domain-term scan strips literals and exempts seed data. (a) The mechanism and its literal source; (b) its subjects, matching and the platform built-ins; (c) its exemptions, including development hosts in frontend or infrastructure configuration; (d) whether a ban on production references to `LearnStack.Tools.Seeder` and a behavioural same-code, different-data test accompany it | A Standards 21 sibling row scanning production backend and `frontend/` sources, comments stripped, for exact identity literals read from `SeedData` (slugs, ids, hosts, display names, customization keys), built-ins excluded, with planted offenders; plus the behavioural test. The exemption policy is the owner's judgement | Detail: a Standards 21 row Registered in the first pass that uses it and Implemented before exit; a phase-doc statement in § Genericity proof. No ADR | P02d-2 (a: every seed literal lives where the source reads it), P02d-5 (c: the first host outside `SeedData`), P02d-6 (b: frontend subjects), P02d-7 (Implemented and required) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): (a) SeedData literal source and Registered guard. [Accepted — 2026-10-08](#p02d-5-accepted-answers): (c) exemptions; [Accepted — 2026-10-09](#p02d-6-accepted-answers): (b) frontend subjects; implementation and behavioral proof remain P7 | +| G20 | What mechanically backs "no production code branches on which tenant it serves"? The shipped domain-term scan strips literals and exempts seed data. (a) The mechanism and its literal source; (b) its subjects, matching and the platform built-ins; (c) its exemptions, including development hosts in frontend or infrastructure configuration; (d) whether a ban on production references to `LearnStack.Tools.Seeder` and a behavioural same-code, different-data test accompany it | A Standards 21 sibling row scanning production backend and `frontend/` sources, comments stripped, for exact identity literals read from `SeedData` (slugs, ids, hosts, display names, customization keys), built-ins excluded, with planted offenders; plus the behavioural test. The exemption policy is the owner's judgement | Detail: a Standards 21 row Registered in the first pass that uses it and Implemented before exit; a phase-doc statement in § Genericity proof. No ADR | P02d-2 (a: every seed literal lives where the source reads it), P02d-5 (c: the first host outside `SeedData`), P02d-6 (b: frontend subjects), P02d-7 (Implemented and required) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): (a) SeedData literal source and Registered guard. [Accepted — 2026-10-08](#p02d-5-accepted-answers): (c) exemptions; [Accepted — 2026-10-09](#p02d-6-accepted-answers): (b) frontend subjects; implementation and behavioral proof remain P7; [Accepted — 2026-10-11](#p02d-7-decision-package-2026-10-10): matching, canonical built-ins, Seeder-reference fence and behavioral ownership; Step 2 implements the required fences and schema-replacement companion | | G21 | Does the anonymous public path set any cookie — the [Frontend Architecture Standards § Tenant Resolution](../standards/07-frontend-architecture.md#tenant-resolution) flowchart sets them — and may a public page load any cross-origin subresource, such as the CDN-hosted logo and font assets Frontend Architecture describes? | No cookies, since the locale is already in the path and a locale-less request redirects ([Localization Standards § URL Strategy](../standards/08-localization.md#url-strategy)); same-origin subresources only; both asserted by a check. Whether tenant branding may point visitors' browsers at third-party hosts is a data-protection choice for the owner | Detail: a phase-doc statement; the Standards 07 flowchart and Frontend Architecture § Theming reconciled in the deciding pass | P02d-2 (subresources, if G16 admits a URL-valued token), P02d-5 (cookies: the middleware replacement is the first code that could set one) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): subresources. Cookies remain open for P02d-5; [Accepted — 2026-10-08](#p02d-5-accepted-answers): cookies; prior subresource answer retained | | G22 | How does the customization definition projection load and stay correct? In the request's ambient transaction, or as a ninth out-of-band tenant-context setter (ADR-0040's set is closed at eight)? In what order are the generation and the rows read; what does an absent generation row mean; how is a cache filled inside a transaction that bumped and rolled back kept unreachable, when the bump is an upsert increment that can reissue a number; what does an absent definition set return; which families are registered, and how does the adapter's exact-tuple `cache.name` mapping match generation-embedded names; what do the TTLs bound; and is the contract batched so a public read issues a bounded number of statements? | Load in the ambient transaction; read the generation first, then the rows; fill only from non-bumping transactions; treat cache faults as misses; restate the module's cache-hit budget; a batched contract, with statement-count assertions cold and warm | Contract: the Customization spec § Primary read flow and a [Tenant Customization Model § 8.2](../architecture/32-tenant-customization-model.md#82-cache-strategy) statement on how a request learns the generation; a dated ADR-0040 amendment and a setters row only if the loader is out-of-band. Detail: the [Infrastructure Stack Standards](../standards/20-infrastructure-stack.md) cache table, the `cache.name` mapping, the Observability Standards metrics family list | P02d-3 | [Accepted — 2026-10-02](#p02d-3-decision-package-2026-10-02) | | G23 | The typed settings accessor and its freshness. With no `learnstack.tenancy.settings` event until Phase 02b and the seed writing from its own process, what bounds staleness: a TTL with a stated bound, a writer-coupled Tenancy settings generation counter, or no settings cache here? What are the accessor's name and glossary headword; how is a cached read keyed so tenant-wide and organization rows never cross organizations — a settings read depends on `app.organization_id` today, and the policy's tenant-scope read gains a carrier in Phase 03; and does its loader run in the ambient transaction? | The reviews split on freshness — a TTL bound until 02b, a counter, or no cache. For keys: tenant-wide rows loaded with an explicit `organization_id IS NULL` predicate under `CacheKey.ForTenant`, each organization's overrides under `CacheKey.ForOrganization`, merged in memory; an ambient loader. The documented tenant-only key is rejected, because it would serve one organization's overrides to another | Detail: if settings are cached, the Infrastructure Stack Standards cheat-sheet rows and `cache.name` mapping; the Tenancy spec's event row and budget; a glossary headword. Contract only for a counter (the Tenancy spec, Database Standards § Table classes and § GRANT matrix) or an out-of-band loader (an ADR-0040 amendment) | P02d-2 (a counter is bumped inside the setting command's transaction), P02d-3 (name, keys, loader) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): no settings cache in P02d-2/3. [Accepted — 2026-10-02](#p02d-3-decision-package-2026-10-02): typed ambient accessor/scoped merge | @@ -439,19 +449,19 @@ premise a row cites is re-verified at that pass rather than trusted. | G30 | The locale error matrix and transport. On each read, what answers an empty, repeated, malformed, over-length, non-canonical, not-enabled or enabled-but-untranslated locale? Does a not-enabled locale answer the Active `unsupported_locale` `400` or the not-found body? Is `X-Locale`, which [Frontend Architecture Standards § Locale Resolution](../standards/07-frontend-architecture.md#locale-resolution) still names as the API carrier, withdrawn, so that locale reaches the API only as the query parameter? | Missing or malformed → `400` `validation_failed` naming `locale`; untranslated → an empty catalog page; the query parameter only. The reviews split on not-enabled — not-found, amending the Error Handling row, or the Active `400`; a uniform answer after the enabled check hides pre-launch rows under either | Detail: a phase-doc statement; the [Error Handling Standards](../standards/09-error-handling.md) table only if not-found; Standards 07 § Locale Resolution; the Frontend Architecture SDK sketch; the API Standards § Pagination example gains `locale` | P02d-4 (validators, OpenAPI parameters and responses), P02d-5 (the server SDK's header set) | [Accepted — 2026-10-03](#p02d-4-accepted-answers): query/error matrix; trusted server header transport remains P02d-5; [Accepted — 2026-10-08](#p02d-5-accepted-answers): header transport; prior API query-locale answer retained | | G31 | Contract checks and the SDK surface. The committed OpenAPI snapshot's path, and how the contract suite proves it equals the served document; how the base copy is read; how the first run behaves; which `oasdiff` version and fail level, and which ADR-0024 rows that level detects (a tightened validator or a changed status may be invisible to any diff); what is uploaded on failure. The drift gate's source — the committed snapshot through `LEARNSTACK_OPENAPI`, or a running API — and its job. Whether an activated deferred job keeps its `if: vars.ENABLE_*` condition, given that GitHub treats a skipped required job as passing, or loses it as the integration job's did. And what the SDK surface becomes when regeneration makes `paths` non-empty: a hand-written transport over `paths` or a typed client library, the fate of `createClientSdk`, and whether the package root keeps re-exporting the server entry | A committed snapshot the contract suite asserts equal, diffed against the base ref's copy with `oasdiff` pinned; drift generated from the snapshot in the required `frontend` job; the condition removed on activation; a thin hand-written transport, and no root re-export of the server entry | Detail: [Testing Standards § API Contract Tests](../standards/06-testing.md#api-contract-tests), the API Standards § OpenAPI links, `ci.yml`, [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) (its activation procedure follows the answer), [Frontend Architecture Standards § SDK](../standards/07-frontend-architecture.md#sdk); a package pin with its licence verdict if a client library is chosen | P02d-4 (the first operation, its snapshot and assertion, the drift gate, and the regenerated types the factories must compile against) | [Accepted — 2026-10-03](#p02d-4-accepted-answers): snapshot, SDK, diff/drift and approved required-check activation | | G32 | **The development-transport part of [Phase 02b](phase-02b-events-auth.md#the-decision-register)'s G12.** Which development hostnames and transport serve the two seed tenants — keep `*.learnstack.local`, with a hosts-file step and local TLS with a trust step, or move the seed hosts under `*.localhost`? What does a reviewer do between a clean checkout and both sites, and which carriers move, including host rows already on warm databases, where the host is the primary key and the seeder removes no mapping? | `*.localhost` over HTTP, provided the pass verifies in each browser the team uses and in CI's Chrome that both hosts resolve with no hosts entry and that a `Secure` cookie set on them is stored and returned; otherwise `*.learnstack.local` with local TLS and a named trust step. Tenants are never told apart by port: the effective host strips it | Detail: a dated phase-doc statement in [§ Host-based tenant resolution, end to end](#host-based-tenant-resolution-end-to-end), with `SeedData`, `scripts/seed.sh`, the README Quickstart, the `seed-tenant` and `local-dev-setup` skills and `apps/web`'s dev script and Next configuration in the same packet; Infrastructure Standards only if a TLS proxy publishes a port. No ADR | P02d-5 (the development transport, and the hop configuration a TLS proxy would change), or the first earlier packet that writes a seed-host literal outside `SeedData` | [Accepted — 2026-10-08](#p02d-5-accepted-answers): local hosts and TLS | -| G33 | The server-rendering topology and its evidence. Where do Next.js and the API run relative to each other — the workstation loopback, containers, gated APISIX; which networks are trusted; how does one hop secret reach both processes; what is the server-only API origin — and the same for the CI job that renders the pages? What evidence discharges ADR-0036's "Phase 02d's browser test" and its matrix rows "a direct socket bypassing the hop" and "the Phase 02d anonymous two-host browser render", given that Testing Standards gate this phase on a human? And ADR-0036 § Consequences says the root refuses to start outside Development when the secret list is empty or short, while the shipped rule, in every mode, refuses a half-configured hop, a network entry that is not CIDR and a blank secret or one under 32 characters (characters, not bytes), and admits both lists empty: is that recorded or restored, and is non-development hop configuration this phase's or Phase 11's? | Both processes on the loopback, networks `127.0.0.1/32` and `::1/128`, one generated secret from a single source and never under `NEXT_PUBLIC_`, with networks and secrets arriving together (committing networks without secrets breaks every Development-environment fixture); the API called directly; a per-run secret in CI. Evidence: request-level hop tests as `learnstack_app` — no `X-Tenant-Id`, a non-hop peer, a wrong secret, a repeated header — beside the human walkthrough, with one review adding an automated browser smoke. Record the shipped startup rule; non-development hop configuration is Phase 11's | Contract: one dated [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) amendment if the evidence departs from the ADR's words, recording the startup rule, and carrying G34 if G34 changes the key or budget. Detail: `.env.example`, `apps/web/.env.local.example`, `appsettings.Development.json` or the demo recipe; a Standards 21 row for the hop runtime test; a Testing Standards § End-to-End Tests sentence only if ownership moves; a Phase 11 scope row | P02d-5 (the hop configuration and the fixture shape — in-process test hosts have no socket peer), P02d-7 (the CI part) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): local topology, shared source and ingress | +| G33 | The server-rendering topology and its evidence. Where do Next.js and the API run relative to each other — the workstation loopback, containers, gated APISIX; which networks are trusted; how does one hop secret reach both processes; what is the server-only API origin — and the same for the CI job that renders the pages? What evidence discharges ADR-0036's "Phase 02d's browser test" and its matrix rows "a direct socket bypassing the hop" and "the Phase 02d anonymous two-host browser render", given that Testing Standards gate this phase on a human? And ADR-0036 § Consequences says the root refuses to start outside Development when the secret list is empty or short, while the shipped rule, in every mode, refuses a half-configured hop, a network entry that is not CIDR and a blank secret or one under 32 characters (characters, not bytes), and admits both lists empty: is that recorded or restored, and is non-development hop configuration this phase's or Phase 11's? | Both processes on the loopback, networks `127.0.0.1/32` and `::1/128`, one generated secret from a single source and never under `NEXT_PUBLIC_`, with networks and secrets arriving together (committing networks without secrets breaks every Development-environment fixture); the API called directly; a per-run secret in CI. Evidence: request-level hop tests as `learnstack_app` — no `X-Tenant-Id`, a non-hop peer, a wrong secret, a repeated header — beside the human walkthrough, with one review adding an automated browser smoke. Record the shipped startup rule; non-development hop configuration is Phase 11's | Contract: one dated [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) amendment if the evidence departs from the ADR's words, recording the startup rule, and carrying G34 if G34 changes the key or budget. Detail: `.env.example`, `apps/web/.env.local.example`, `appsettings.Development.json` or the demo recipe; a Standards 21 row for the hop runtime test; a Testing Standards § End-to-End Tests sentence only if ownership moves; a Phase 11 scope row | P02d-5 (the hop configuration and the fixture shape — in-process test hosts have no socket peer), P02d-7 (the CI part) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): local topology, shared source and ingress; [Accepted — 2026-10-11](#p02d-7-decision-package-2026-10-10): shared CI lifecycle/topology; ADR-0056 measurement Accepted on the same date; Step 3 local proof passes; Step 4 owns real CI receipts | | G34 | How does the pre-classification anonymous limiter treat a request arriving over the authenticated trusted hop? Every server-rendered call reaches the API from the renderer's peer, so every visitor of both tenants shares one partition, and one client sending random `Host` values through the renderer can starve both sites. The partition key, the budget and how the renderer derives any visitor identity it states — while unknown-host floods stay bounded before database work, and a direct peer is still limited per peer | The reviews differ: a visitor address stated over the hop, in a dedicated single-valued header or through `X-Forwarded-For` with the peer captured first, as ADR-0036 anticipates; a separate hop budget with limiting in the renderer; or an explicitly sized shared quota. A per-host ceiling as the only backstop multiplies under a random-`Host` flood | Contract: a dated ADR-0036 amendment if the hop changes the key or the budget, stating the new input's trust rule and whether it may be logged or audited; otherwise a phase-doc statement. Detail: API Standards § Request and Response Limits, [Security Standards § Rate Limiting](../standards/11-security.md#rate-limiting), the catalogue's per-peer rule. `P02b-0` re-verifies Phase 02b's G14 against the answer | P02d-5 | [Accepted — 2026-10-08](#p02d-5-accepted-answers): authenticated visitor and peer budgets | | G35 | The server SDK transport. Its options — visitor host, locale, an optional assertion, never a tenant id as selector; where the host comes from; how the API origin is configured; the server-only guard, timeouts and cancellation; and whether it forwards W3C `traceparent`. With it: which parts of [Observability Standards § Frontend Observability](../standards/10-observability.md#frontend-observability) — Next.js error capture, web vitals — ship here, and which phase owns the rest, since no phase names them | Host and locale plus an optional assertion; hop headers and origin from server configuration, never from request input; a `server-only` guard; Problem Details mapped to the `AppError` union [Error Handling Standards](../standards/09-error-handling.md) already fixes; `traceparent` forwarded; Next.js error capture and web vitals assigned to Phase 11, whose § Observability lists error tracking | Detail: Frontend Architecture Standards § SDK and the Frontend Architecture SDK sketch; a pin and licence verdict if a guard package is added; an exclusion row naming frontend observability's owner, with an Observability Standards sentence | P02d-5 (the transport's headers and configuration read); the ownership half closes by exit, because a deferral names its phase | [Accepted — 2026-10-08](#p02d-5-accepted-answers): configured caller, deadlines and observability owners | | G36 | The edge middleware and entry behaviour. Does the middleware resolve anything (with G25)? What does it carry inward, and under which header name — the Frontend Architecture sketch reuses `x-learnstack-host`, the hop header's own name? Which inbound internal headers are removed or overwritten, including `x-organization-id` when resolution has none — a deny-strip or an allowlist rebuild? What does the matcher exclude? What do `/` and a locale-less path answer, with which status, target and default-locale source; what do a disabled, malformed or non-canonical locale segment (G6 c), a platform host and an unknown host answer; does this run in middleware or the route tree, and may an i18n library own the middleware? Does this phase build the locale-less redirect [Localization Standards § URL Strategy](../standards/08-localization.md#url-strategy) requires, or keep the standards index's i18n-runtime carve-out while Phase 06 claims redirect handling? | Normalise the host; strip every client-supplied `x-tenant-id`, `x-organization-id`, `x-locale` and `x-learnstack-*`; drop the scaffold's `503` guard and TODOs; `/` redirects to the tenant's default locale; a disabled locale is a `404` before any content call; a platform or unknown host gets a `404` with no platform text; the redirect built minimally here, with Phase 06's rows reworded to "deepens" | Detail: a phase-doc statement; the Standards 07 § Tenant Resolution flowchart and the Frontend Architecture middleware sketch; Phase 06's rows in the same diff; if less is built, a dated narrowing of the standards index row for Localization Standards naming the owning phase | P02d-5 (the middleware replacement rewrites the scaffold's locale fallback, so every placement answer changes it first) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): membership-first entry and redirects | | G37 | How do tenant-varying `(public)` routes render, and which Next.js caches may hold tenant data — the full-route cache, the fetch data cache, `unstable_cache`, `generateStaticParams` — so one host's page is never served on the other? A public URL carries no tenant, and both tenants send the same request line over the hop, so a path-keyed cache leaks. What freshness does a page have after a customization or content write? | All three reviews: dynamic rendering with uncached SDK fetches — no `revalidate`, no `generateStaticParams`, no `unstable_cache` — relying on the API's generation-keyed cache; no ISR here | Detail: [Frontend Architecture Standards § Public Site Renderer](../standards/07-frontend-architecture.md#public-site-renderer), which prescribes `revalidate` today, and [Frontend Architecture § Rendering Strategies](../architecture/14-frontend-architecture.md#rendering-strategies), rewritten with the `add-frontend-route` skill; [Performance Standards § Caching](../standards/15-performance.md#caching) if the answer caches; an ADR if the pass judges the Standards 07 change non-trivial | P02d-5 (one mechanism: a host-bearing rewrite target is middleware code and fetch cache options are transport code, both written before the renderer) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): dynamic no-store renderer policy | -| G38 | The frontend test set. (a) Which predicates of the middleware and the server SDK does Vitest cover, and in which packages — `pnpm -r test` runs only packages with a test script, which `packages/sdk` lacks? (b) How are async Server Component pages and field components covered below the browser, given vendor guidance that Vitest does not render async Server Components? (c) What automated evidence, if any, re-proves the page-level two-host claim after exit — an HTTP smoke against `next start`, one narrow Playwright smoke pulled forward from Phase 06, or a dated manual record? (d) By what mechanism does the `frontend` job refuse skipped and todo cases, which it does not today, since `No_Architecture_Test_Is_Skippable`'s runner leg reads only the backend `.trx` files? | (a) host normalization and header stripping, locale parsing and entry answers, hop headers with no tenant selector, `not_found` mapping — each with an inversion companion; (b) async pages covered through their synchronous children; (c) the reviews split three ways; (d) a reporter-output check or a lint ban on disabled tests, failing also on a package with tests and no script, proven with a planted skip | Detail: a phase-doc statement; the test files; the Standards 21 entry `No_Architecture_Test_Is_Skippable` extended rather than a second name; `ci.yml`. Contract for (c) only if a browser smoke moves: a Testing Standards § End-to-End Tests edit citing this row, with its carriers | P02d-5 (a; d at the latest), P02d-6 (b, c), P02d-7 (c's job) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): (a,d) tests and non-skippability; [Accepted — 2026-10-09](#p02d-6-accepted-answers): (b,c) synchronous tests and required production HTML/RSC product proof; P7 owns the browser/demo job | +| G38 | The frontend test set. (a) Which predicates of the middleware and the server SDK does Vitest cover, and in which packages — `pnpm -r test` runs only packages with a test script, which `packages/sdk` lacks? (b) How are async Server Component pages and field components covered below the browser, given vendor guidance that Vitest does not render async Server Components? (c) What automated evidence, if any, re-proves the page-level two-host claim after exit — an HTTP smoke against `next start`, one narrow Playwright smoke pulled forward from Phase 06, or a dated manual record? (d) By what mechanism does the `frontend` job refuse skipped and todo cases, which it does not today, since `No_Architecture_Test_Is_Skippable`'s runner leg reads only the backend `.trx` files? | (a) host normalization and header stripping, locale parsing and entry answers, hop headers with no tenant selector, `not_found` mapping — each with an inversion companion; (b) async pages covered through their synchronous children; (c) the reviews split three ways; (d) a reporter-output check or a lint ban on disabled tests, failing also on a package with tests and no script, proven with a planted skip | Detail: a phase-doc statement; the test files; the Standards 21 entry `No_Architecture_Test_Is_Skippable` extended rather than a second name; `ci.yml`. Contract for (c) only if a browser smoke moves: a Testing Standards § End-to-End Tests edit citing this row, with its carriers | P02d-5 (a; d at the latest), P02d-6 (b, c), P02d-7 (c's job) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): (a,d) tests and non-skippability; [Accepted — 2026-10-09](#p02d-6-accepted-answers): (b,c) synchronous tests and required production HTML/RSC product proof; P7 owns the browser/demo job; [Accepted — 2026-10-11](#p02d-7-decision-package-2026-10-10): narrow browser-job scope; ADR-0056 measurement Accepted on the same date; browser proof delivered in Step 3; CI receipts owned by Step 4 | | G39 | Is [ADR-0027](../decisions/0027-frontend-i18n.md), the frontend i18n library, Accepted in this phase rather than Phase 04, or do this phase's pages meet a library-neutral message contract under the standards index's carve-out? Where does the one UI string catalogue live — the carriers name three paths? | The reviews differ: Accept at first use (`next-intl` composed with the tenant middleware, with its pin and licence verdict), or move only the minimum slice — catalogue loading, lookup, layout locale, `lang`. A third option, no platform-authored text, is hard for a skip link or a not-found page | Contract: ADR-0027 Accepted, with the decisions index row, Phase 04's ADR-0027 lines and the standards index row in the same diff — which, under [the decisions index SLA](../decisions/README.md#open-adr-drafts), makes it an exit blocker here — or a narrowed carve-out plus a phase-doc statement. Detail: [Localization Standards § Strings in Code](../standards/08-localization.md#strings-in-code), [Localization § UI String Catalogue](../architecture/12-localization.md#ui-string-catalogue), the `add-i18n-key` and `add-frontend-route` skills | P02d-6 (the first platform string, message loading and catalogue files) | [Accepted — 2026-10-09](#p02d-6-accepted-answers): ADR-0027, exact pin and one checked UI catalogue home | | G40 | Route files, page states and site chrome. What happens to `(public)/page.tsx`'s platform placeholder, and to `/studio` and `/portal` on tenant hosts; is the `courses` segment fixed, and which phase owns localized section names? Does this phase ship `(public)` loading, error and not-found files — [Frontend Architecture Standards § Routing](../standards/07-frontend-architecture.md#routing) requires `loading.tsx` and `error.tsx` per route group, and a not-found page is this phase's own choice; how do SDK `not_found`, a cursor `validation_failed`, unavailable and `429` map to page states; what do an empty catalog and a course with no eligible lesson show; does the catalog render a next-page link? Does a minimal chrome ship? Are `hreflang`, canonical and `og:locale`, which [Localization Standards § SEO](../standards/08-localization.md#seo) requires on translated public pages, built here or carved out? | The placeholder leaves the public tree; a fixed `courses` segment with the section-name owner named; minimal state files in tenant tokens; `not_found` → an HTTP `404` page; unavailable or `429` → the route group's error page without disclosure; an explicit empty state; a next-page link over enough seeded courses; in-page links rather than chrome, keeping Phase 06's navigation rows true | Detail: a phase-doc statement; Phase 06's § What Phase 02d already shipped rows reworded; a dated carve-out in the standards index row for Frontend Architecture Standards or Localization Standards, naming the owning phase, for whatever this phase builds less of | P02d-6 | [Accepted — 2026-10-09](#p02d-6-accepted-answers): product routes, approved status/URL chain, page states and metadata | | G41 | How does the lesson page draw what the subset does not implement — an out-of-subset `x-renderer`, a missing optional field, an array of objects, a stored value whose type differs from its declaration, an `x-taxonomy` value and its missing band? And where do composite and primitive components live — Frontend Architecture Standards names `packages/blocks`, Frontend Architecture names `components/blocks/`, and the registry sits in `apps/web/src/lib/customization/`? | A safe placeholder, never an exception and never raw JSON; a missing optional field renders nothing; `integer`, `boolean` and enums as text; an `x-taxonomy` value as the band's display name in the requested locale. No review addresses the component home | Detail: Tenant Customization Model § 2 (the implemented subset, not a second list) and § 8.1; Frontend Architecture Standards § Public Site Renderer and the Frontend Architecture tree. A new primitive would be an ADR-0018 release, which this row must not assume, since Phase 04 owns the field-type set | P02d-6 | [Accepted — 2026-10-09](#p02d-6-accepted-answers): ordered plain-string components and bounded fallbacks | | G42 | How do validated branding tokens reach the server-rendered HTML — a `style` attribute on the root element, which needs `unsafe-inline` or `unsafe-hashes`; a nonce-compatible `<style>` element built from validated values; or a per-tenant stylesheet route — without constraining [Security Standards § HTTP Headers](../standards/11-security.md#http-headers)' nonce-based target before Phase 11 documents it per surface? | A `<style>` element built only from registry-validated values, emitting only the `--ls-*` vocabulary, never a `style` attribute; a nonce would force dynamic rendering (G37) | Detail: Frontend Architecture Standards § Tenant Branding for the mechanism; Frontend Architecture § Theming | P02d-6 (the layout's token injection; closes with G37's answer and G16's grammar) | [Accepted — 2026-10-09](#p02d-6-accepted-answers): atomic four-token style-element injection | | G43 | Which accessibility checks on this phase's pages fail a build — route tests asserting `lang`, one `<main>`, the heading outline, a skip link and a descriptive title; `jsx-a11y` at error severity, where most of the shipped config's rules warn; or jsdom axe, which Testing Standards puts through Playwright in Phase 06? Is catalog → lesson a critical flow that needs [Accessibility Standards § Testing](../standards/16-accessibility.md#testing)' screen-reader smoke test? | Route tests in the `frontend` job for the checkable semantics; keyboard, focus and 320 CSS px reflow in the manual record; failing `jsx-a11y` with a planted companion. No review addresses the screen-reader question | Detail: a phase-doc statement; [Accessibility Standards § Tooling](../standards/16-accessibility.md#tooling) if lint severity or component axe becomes a rule; the lint configuration. Whether failing lint enforces the standard couples with G44; its index row changes only in the enforcing pull request | P02d-6 (page components and their tests) | [Accepted — 2026-10-09](#p02d-6-accepted-answers): failing lint/semantics and mandatory manual assistive-technology proof | -| G44 | The Lighthouse job. Does it activate in this phase, and on what full-stack harness — a migrated stack, a seed written as `learnstack_app`, the API as a process over the hop with a per-run secret, `next start` on a production build, both seed hosts reachable from CI's browser, a readiness and tenant-marker check before the audit — or does activation move to the phase that brings a browser harness? What does it assert: the URL set and which [Performance Standards](../standards/15-performance.md) row each page answers to (its LCP row names a landing page this phase does not ship); which rows, under which throttling preset; the 200 KB budget or the 250 KB forbidden line; hard or warning, run count and aggregation; categories, including the accessibility audit [Accessibility Standards § Tooling](../standards/16-accessibility.md#tooling) requires; the runtime ceiling, tool install and report destination; and what the standards index rows for Performance and Accessibility say afterwards? | Activate here, reusing the `make demo` entrypoint; catalog, course and lesson on both hosts plus the bilingual tenant's second locale; hard assertions on deterministic audits (script transfer size, CLS, the accessibility category or contrast over both palettes) and median-of-N timings as warnings, with TBT rather than a lab INP; tooling from the lockfile; reports to workflow artifacts only; Accessibility promoted if asserted hard, Performance kept Adopted as a pre-baseline check unless a hard budget makes it Active for what ships. The budget authority is settled: [Frontend Architecture Standards § Performance](../standards/07-frontend-architecture.md#performance) names Performance Standards | Contract: a phase-doc statement plus a committed Lighthouse configuration. Detail: `ci.yml`, CONTRIBUTING's activation edits, the `run-tests-locally` skill; Performance Standards only if a number or lab profile changes; the standards index rows and status headers in the enforcing pull request. Moving activation edits `ci.yml`, CONTRIBUTING and the skills, and leaves Phase 01's and 02a's records as written | P02d-5 (whether it activates, as an input to G32: the hosts must work in CI's browser), P02d-7 (the harness and assertions) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): whether: activate in P7; harness details stay P7 | -| G45 | What does `make demo` start and guarantee on a clean checkout? The process model — a foreground supervisor over detached compose, or detached processes with a stop target; the web app's mode; how environment reaches host processes, given `dotnet run` reads no `.env` and nothing creates `apps/web/.env.local`; readiness waits before Phase 11's `/readyz`; stale-`.env` detection; re-run and stop behaviour; whether its seed step keeps `make seed`'s wait on every default-profile service and both Keycloak realms; and what it prints, including the bilingual tenant's second-locale URLs | The reviews differ on attached versus detached processes; both reject a destructive reset, wait on both processes, keep re-runs idempotent and have CI reuse the entrypoint | Detail: the `Makefile` targets with help lines and a dated phase-doc statement; README § Quickstart, `scripts/seed.sh`'s closing output and the `local-dev-setup` and `seed-tenant` skills in the same packet; Infrastructure Standards § Healthchecks if the seed's gate narrows. No ADR | P02d-7 (with G44, whose job invokes the same entrypoint; if G44's harness moves earlier, this row moves with it) | Open | +| G44 | The Lighthouse job. Does it activate in this phase, and on what full-stack harness — a migrated stack, a seed written as `learnstack_app`, the API as a process over the hop with a per-run secret, `next start` on a production build, both seed hosts reachable from CI's browser, a readiness and tenant-marker check before the audit — or does activation move to the phase that brings a browser harness? What does it assert: the URL set and which [Performance Standards](../standards/15-performance.md) row each page answers to (its LCP row names a landing page this phase does not ship); which rows, under which throttling preset; the 200 KB budget or the 250 KB forbidden line; hard or warning, run count and aggregation; categories, including the accessibility audit [Accessibility Standards § Tooling](../standards/16-accessibility.md#tooling) requires; the runtime ceiling, tool install and report destination; and what the standards index rows for Performance and Accessibility say afterwards? | Activate here, reusing the `make demo` entrypoint; catalog, course and lesson on both hosts plus the bilingual tenant's second locale; hard assertions on deterministic audits (script transfer size, CLS, the accessibility category or contrast over both palettes) and median-of-N timings as warnings, with TBT rather than a lab INP; tooling from the lockfile; reports to workflow artifacts only; Accessibility promoted if asserted hard, Performance kept Adopted as a pre-baseline check unless a hard budget makes it Active for what ships. The budget authority is settled: [Frontend Architecture Standards § Performance](../standards/07-frontend-architecture.md#performance) names Performance Standards | Contract: a phase-doc statement plus a committed Lighthouse configuration. Detail: `ci.yml`, CONTRIBUTING's activation edits, the `run-tests-locally` skill; Performance Standards only if a number or lab profile changes; the standards index rows and status headers in the enforcing pull request. Moving activation edits `ci.yml`, CONTRIBUTING and the skills, and leaves Phase 01's and 02a's records as written | P02d-5 (whether it activates, as an input to G32: the hosts must work in CI's browser), P02d-7 (the harness and assertions) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): whether: activate in P7; harness details stay P7; [Approved — 2026-10-11](#p02d-7-approval-and-exit-boundary): bounded seventh-check activation after real success; [Accepted — 2026-10-11](#p02d-7-accepted-answers): ADR-0056 measurements and serial feasibility gate; local enforcement passes; live CI/protection receipts owned by Step 4 | +| G45 | What does `make demo` start and guarantee on a clean checkout? The process model — a foreground supervisor over detached compose, or detached processes with a stop target; the web app's mode; how environment reaches host processes, given `dotnet run` reads no `.env` and nothing creates `apps/web/.env.local`; readiness waits before Phase 11's `/readyz`; stale-`.env` detection; re-run and stop behaviour; whether its seed step keeps `make seed`'s wait on every default-profile service and both Keycloak realms; and what it prints, including the bilingual tenant's second-locale URLs | The reviews differ on attached versus detached processes; both reject a destructive reset, wait on both processes, keep re-runs idempotent and have CI reuse the entrypoint | Detail: the `Makefile` targets with help lines and a dated phase-doc statement; README § Quickstart, `scripts/seed.sh`'s closing output and the `local-dev-setup` and `seed-tenant` skills in the same packet; Infrastructure Standards § Healthchecks if the seed's gate narrows. No ADR | P02d-7 (with G44, whose job invokes the same entrypoint; if G44's harness moves earlier, this row moves with it) | [Accepted — 2026-10-11](#p02d-7-demo-lifecycle): shared foreground lifecycle; implemented with both review rounds complete in Step 1 | Gates that define one mechanism are answered against each other. Where their parts shape different packets' code, the mechanism is split as @@ -5553,6 +5563,949 @@ transformation syntax, one shared-constant listener, adjacent source anchors, registration order and cleanup. Both rounds are read-only and start no runtime services. The PR description owns final exact-head CI after this record commit. +### P02d-6 merge and closeout (2026-10-10) + +The maintainer merged [PR #27](https://github.com/HodeTech/LearnStack/pull/27) at +2026-10-10 17:07:50 UTC. GitHub records final PR head +`46ed971bf976514fb451a162cc9450d147ed0441` and merge commit +`378346126949c90bb286b5217c24513fc2044f06`. The merge's second parent is that +head, and both commits have the identical tree. Development is fast-forwarded to +the verified merge without changing branches or rewriting history. The final +head includes the testing-standard correction marking both ADR-0055 Step 3 +review rounds complete. + +- All six required checks pass on the + [final PR head](https://github.com/HodeTech/LearnStack/actions/runs/38070332107) + and the [merge commit](https://github.com/HodeTech/LearnStack/actions/runs/38070479860). + Independently downloaded merge-run TRX confirms **2,946 passed**, zero failures + or skips: 1,705 unit, 198 architecture, four contract, 172 Docker-free + integration and 867 Docker integration cases. +- Merge-run frontend logs confirm **1,402 passed** (1,342 web, 60 SDK), successful + typecheck, lint, production build and SDK drift verification. Native production + ingress verification and all **19** setup/cleanup and negative controls pass. + Release builds report zero warnings and errors. These are merge-run receipts; + the documentation closeout does not rerun the full runtime suites. +- Read-only verification confirms `strict: true` and the same six required Actions + contexts, including OpenAPI diff and Docker integration. No protection setting + changes. Lighthouse remains disabled with activation owned by P02d-7. + +**P02d-6 is closed.** Localized catalog, course/outline and ordered plain-text +lesson pages, safe four-color themes, exact-locale metadata, scoped pagination +and controlled public states are delivered. ADR-0055 adds middleware-owned single +bootstrap admission, immutable request-local reuse and production lifecycle/ +isolation proofs. All implementation steps and focused fixes retain their +recorded independent review rounds. The +[packet closeout](#p02d-6-packet-closeout-2026-10-10) owns scoped keyboard, focus, +reflow and contrast observations and the maintainer's VoiceOver smoke; this +merge adds no new manual observation or full WCAG-conformance claim. + +**Next: P02d-7 — Demo, full-stack CI and exit.** Its decision pass and +implementation have not started. Resolve the remaining G20/G33/G38/G44/G45 parts +against the delivered renderer before harness implementation: + +- Define `make demo` and stop/re-run ownership on a clean checkout, environment + projection, readiness, non-destructive seeding and the printed host/locale URLs + (G45). Local use and CI reuse the selected entrypoint. +- Select Lighthouse's tools, both-host/locale URL set, assertions, budgets, + aggregation and artifacts; activate its currently disabled job and reconcile + standards/required-check carriers with the chosen enforcement (G44). +- Complete the shared full-stack/browser demo and trusted-hop CI evidence + (G33/G38), including anonymous catalog-to-lesson flows with Keycloak stopped. +- Implement and prove G20's remaining production/seed and frontend genericity + fences in required checks, preserving tenant-data-driven differences. +- Reconcile every exit criterion and register row with its evidence and record + the Education/seed preservation obligations and premises `P02b-0` re-verifies. + [Phase 02b](phase-02b-events-auth.md) follows only after the Phase 02d exit + decision, not this packet merge. + +Phase 06 retains full Studio/portal, authored composition and Playwright/axe; +Phase 11 retains broader production hardening and telemetry. Course Marketplace +commerce is not delivered by this closeout. Earlier dated delivery records, +including their pre-merge pending statements, remain historical and unchanged. + +**Documentation-only closeout validation.** All **198** local Release architecture +cases and seven ADR lifecycle/workflow controls pass, with zero failures/skips. +The six changed Markdown files resolve **873** relative links and **371** fragments +with zero errors in the local audit. Added prose wrapping and diff checks pass. +Earlier dated phase records are byte-preserved; no Accepted ADR or production +code changes. + +### P02d-7 decision package (2026-10-10) + +**Accepted — 2026-10-11; package and revised ADR-0056 approved.** Read-only +inspection starts from `f857437` on `development`, after the P6 merge closeout. +The maintainer approved the lifecycle, genericity, four-step review plan and +bounded seventh-check activation after real success. No implementation, passing +P7 test, enforcement delivery or phase exit is claimed. The user requires new +ADRs to be reviewed and approved before dependent work. Accepted +[ADR-0056](../decisions/0056-public-demo-browser-and-lighthouse-enforcement.md) +owns the new browser/Lighthouse measurement and required-check contract. + +P7 completes the walking skeleton's operational proof: a reviewer starts the +actual seeded sites with one command; the same lifecycle supplies CI's browser +and audit job; genericity and every phase-exit obligation receive named evidence. +Education writes, public DTOs, tenant authority, native ingress, anonymous quotas, +and request-local admission retain their Accepted contracts. + +#### P02d-7 inspection and document actions + +Three independent read-only review lenses covered demo/orchestration, +browser/Lighthouse, and genericity/exit. Source inspection was cross-checked with +the current branch and official tool metadata. This preparation executes no +runtime suite and does not reuse P6's counts as P7 results. + +| Area reviewed | Current premises and required action | +|---|---| +| README, CLAUDE, platform vision, MVP scope, roadmap index, principles and glossary | P1–P6 are merged; P7 is the remaining exit packet. Preserve the white-label/data-driven boundary and Proposed marketplace status. Keep current packet surfaces synchronized without rewriting dated delivery history. | +| ADR-0018/0035/0036/0040/0043/0048/0050/0051/0052/0053/0054/0055 and ADR-0027 | Existing content, RLS, read-only, exact-pin, TLS/provenance, quota and one-bootstrap contracts survive. G20/G45 implementation detail needs no ADR; new measurement/tool enforcement is Proposed ADR-0056. | +| Standards 03/06/07/08/11/12/13/14/15/16/20/21, CI and CONTRIBUTING | The Lighthouse job is still disabled. Qualify Testing 06's “None exist / first browser flow Phase 06” when P7's narrow flow ships; retain full Phase 06 Playwright/axe ownership. Update Performance status only with hard enforcement and distinguish CI targets from timeouts. | +| Makefile, compose dev/e2e, seed/public-local launchers, ingress/configuration and public server | No demo target exists. `make seed` runs migrations before the script's readiness gate. Wait for PostgreSQL before migration; parameterize one compose selection through start/wait/migrate/seed/stop. Health endpoints alone cannot prove seeded readiness or listener ownership. | +| SeedData, source-reader/name guards, module-reference guards and frontend source census | The existing source proof reads all strings, not just identities. Derive a typed identity vocabulary for G20 and canonical built-ins; add the whole-production Seeder-reference fence. Existing HTML/Flight schema-replacement proof supplies a behavioral companion, rather than a duplicate production mutation surface. | +| Production renderer fixture, admission browser, cleanup helpers, seed/local-dev/integration/test skills, compose and web READMEs | Reuse owned-process/TLS/private-output controls. Correct stale seeder activation, P6 merge and raw API restart guidance. Narrow old “CI uses Testcontainers only” and human-only browser statements when the compose-backed proof ships. | +| Phase 02b/05/06/11 and module handoff documents | Fix Phase 02b's mutable plain-HTTP/open-G32 premise; carry native HTTPS, visitor/peer accounting and single-bootstrap inputs into P02b-0 re-verification. Preserve Phase 05 migration obligations and Phase 06/11 ownership. | + +No accepted ADR body or prior delivery record is rewritten. Current contradictions +outside the new package receive bounded carrier fixes in the owning step; the exit +matrix verifies they are all resolved. + +**Preparation review — 2026-10-10.** Independent GPT-6-astra and GPT-6.1-sol +reviews verified the proposal against current contracts/source. Corrections +cover the package heading, complete seeded-ID vocabulary, actual Lighthouse +audit/navigation set and live status carriers. The maintainer's external review +additionally exposed the ADR lifecycle parser incompatibility; it was reproduced +and fixed. Metric/batch wording, tool pin/license placement, decimal gzip units +and the exact Testing 06 carrier action are now explicit. Earlier dated +“not started” statements describe their record time; this package and the top +packet table own current state, and Step 4 verifies that navigation at closeout. + +Documentation preparation passes all 198 Release architecture cases and all +seven ADR lifecycle/workflow controls, with zero failures/skips. These results +validate the draft corpus, not the unimplemented browser/Lighthouse harness. + +**Additional maintainer review — 2026-10-11.** The review identified ambiguity +between per-audit timeouts and the total deadline. Revised ADR-0056 specifies +one serial worker, nested deadlines and the 244-second quiet-window cost; the +first complete run must prove feasibility before activation, with no silent +parallelism or reduced census. It makes the potentially stricter gzip window, +origin coverage and exclusions explicit, names the four budgeted audits and +keeps FCP/Speed Index unasserted. The source-verified category name replaces the +imprecise agentic term. Tool ownership, behavioral script-rule ownership and +G33's retained human evidence are explicit. The revised ADR remains Proposed. + +An independent GPT-6-astra review found no unresolved revision finding. +Read-only upstream verification confirmed the audit/filter selection and the +pinned DevTools source for slow-4G constants. Revision validation passes 198 +Release architecture cases, seven ADR lifecycle/workflow controls and the +changed-document relative-link/anchor, wrapping and diff checks. No browser run +or timing feasibility is claimed by these preparation checks. + +#### P02d-7 accepted answers + +The maintainer approved the package on 2026-10-11. G20 detail, G33 CI topology, +G38's narrow browser-job scope and G45 lifecycle are Accepted planning decisions; +their implementation and proof remain pending. The maintainer subsequently +approved revised ADR-0056 on the same date, closing G44's measurement decision. +The seventh-check activation is authorized only after the approved ADR's complete +real PR run succeeds. The combined decision commit precedes implementation. + +| Gate | Approved scope / remaining decision authority | +|---|---| +| G20(b/c/d) | Typed exact identity matching, bounded canonical built-ins/exemptions and a whole-production Seeder-reference ban; the section below owns the detail. Implement the existing Registered rule and adopt the real schema-replacement companion. | +| G33 CI part | Same workstation-style loopback API/native TLS topology in the CI runner, one per-run secret, real migrated database and `learnstack_app`. Disposable CI certificates/resolver mapping; no system-trust mutation or additional trusted transport. | +| G38(c) job | A narrow managed-Chromium library script follows real catalog → course → lesson links in the three seed host/locale lanes. Existing product HTML/Flight proofs remain required; full Playwright/axe stays Phase 06. | +| G44 remaining detail | Accepted ADR-0056 owns direct pinned Lighthouse/Chromium, nine URLs × three runs, actual-page admission, hard gzip/CLS/accessibility checks, advisory timings, serial real-window pacing and artifacts. Its revised measurement contract was approved on 2026-10-11. Bounded activation of `public demo + lighthouse` as the seventh required check is approved only after the complete approved proof succeeds on a real PR, with protection read-back. | +| G45 | Foreground production-demo supervisor, shared full seed gate, persistent non-destructive local data and owned-process cleanup; the lifecycle below owns the details. | + +#### P02d-7 demo lifecycle + +`make demo` is the local entrypoint; `make demo DEMO_MODE=ci` selects isolated +automation through the same lifecycle. The mode selects environment/compose +ownership and the proof driver, not different product code or a bypass. Make +arguments are validated; startup, readiness, migration, seeding and cleanup share +one explicit compose project/file/env selection. + +1. Check installed prerequisites and refuse concurrent ownership or occupied + API/web listeners; never adopt or kill a foreign process. Restore locked + frontend, .NET and tool dependencies without changing Git hook configuration. + Prepare missing private root configuration from `.env.example`, validate + required values and refuse stale/disagreeing projections rather than overwrite + user values. Generate a missing hop secret through the existing preparation. +2. Local hosts/CA/leaf preparation remains the explicit manual G32 prerequisite. + Verify host resolution, certificate/key match, validity and trust with + actionable sanitized errors before infrastructure starts. Never edit system + hosts or install a root CA automatically. CI creates private disposable + configuration/certificates in its owned checkout and uses ADR-0056's scoped + browser trust and independent Node TLS checks. +3. Start the existing seven-service default compose stack, wait for PostgreSQL + before any migration, then retain the existing full default-service and both + Keycloak-realm seed gate. Apply all current migration chains with the migration + role and seed via the normal contextual request path as `learnstack_app`. + No destructive reset, overwrite or direct seed SQL is added. +4. Build the web production bundle and API, then launch the real API and mandatory + native HTTPS web listener in owned process groups. Use existing paired private + configuration, role validation and child-environment boundaries. These are + production builds with the accepted local Development deployment mode; + stock `next start` is not substituted for native ingress. +5. Declare ready only after owned-child readiness, verified TLS and positive + host/locale/content checks. Print catalog/course/lesson URLs for English + `en` and Yoga `tr-TR`/`en`, derived from the seed declaration. Static + `/healthz` or `/api/healthz` success alone is insufficient. +6. Ctrl+C, parent/control closure, child failure or a startup error stops and + joins owned API/web process trees. Local compose/data remains available; + `make down` is the explicit infrastructure stop. Re-run after shutdown + converges without reset; simultaneous invocations fail clearly. +7. CI uses the existing e2e overlay and a unique owned project. Stop its Keycloak + after successful seeding and assert the stopped state before fresh browser + navigation. On every exit, remove only that invocation's processes, project + and private files. Confirm child exit before deleting sensitive trees; fail + and preserve private diagnostics if cleanup cannot be established. + +Browser/Lighthouse uses actual limiter windows under ADR-0056. No API restart, +quota inflation, forged visitor or 429 retry is part of a successful proof. +API status/call observation must have a nonempty positive control; missing or +truncated observation fails. Reuse structured host completion events where +possible, retaining only sanitized counters/receipts in artifacts. + +#### P02d-7 genericity enforcement + +The typed `SeedData` declaration remains the single identity source. Build the +G20 vocabulary from every declared entity ID, including built-in row and theme +IDs; tenant/organization slugs, hosts and display names; course/lesson keys and +localized slugs/titles; customization keys, item keys, schema property names and +localized definition/field/band labels. Do not classify +ordinary body prose, locale codes, publication/access values or JSON-schema +keywords as tenant identities simply because the existing reader sees them. + +Compare complete decoded string values ordinally; compare GUID literals by +their parsed GUID value. This is an exact-literal fence, not a claim to prove +every possible dynamically assembled branch. Read C# literals, frontend TS/JS +literals and JSON values with syntax-aware readers, excluding comments. Fail on +unreadable/empty identity sources or production subjects. Preserve the frontend +census's refusal of unsupported source forms; no silent extension/import gap. + +Canonical platform built-in keys/labels come from their owning declarations/registries, +not copied into an expanding arbitrary allow-list. Preserve the accepted G20(c) +bounded exemptions: `SeedData`, explicitly identified seed/development launch +data, operational documentation and test-owned fixtures/assertions. Production +middleware, ingress, transport, renderer and message catalogues are subjects. +No blanket `scripts`, configuration or catalogue directory exemption is added. + +Additionally forbid every non-test production project except the Seeder itself +from referencing `LearnStack.Tools.Seeder`, both through compiled references and +restore-resolved project references. This catches identity comparisons through +`SeedData` symbols that contain no local literal. + +Planted failing controls cover backend identity/customization-ID comparisons, +renderer/transport branches, a shared frontend helper, message JSON values and +Seeder references. +Passing controls cover comments, canonical platform built-ins and specifically +allowed development/test data. Prove the actual production census is nonempty. +Re-run the existing real API/PostgreSQL/HTML/Flight presentation successor-revision +proof as the behavioral companion, with a field-assertion failure control; retain +original-pin and unaffected-tenant checks. The new browser proof observes +distinct ordered authored fields, without adding a production mutation endpoint. + +#### P02d-7 implementation plan + +Work remains on `development`; no alternate branch or worktree. Each step lands +its implementation commit, then two fresh independent agent review rounds. +Verify findings against current code, fix valid findings and commit before the +next round/step. A new architectural decision pauses only its dependent work. + +| Step | Delivery | Validation before completion | +|---|---|---| +| 1 — Shared demo lifecycle | Compose selection, pre-migration wait, locked restoration, private configuration, foreground ownership, production build/start, seeded readiness, non-destructive repeat/stop and local guidance. | Clean-checkout preparation with the documented manual TLS prerequisite; repeat convergence; stale/missing configuration; port collision/foreign listener; child failure, signal/parent closure and owned cleanup controls. | +| 2 — Genericity and exit foundations | Existing G20 production literal rule, whole-production Seeder-reference fence, canonical built-ins/exemptions, behavioral companion and preliminary criterion/evidence inventory. | Nonempty backend/frontend/JSON census; planted dirty/clean controls; architecture/unit/contract and relevant integration tests; existing exact-pin/schema-replacement product proof. | +| 3 — Browser and audit proof | Pinned tools/browser; seed-derived nine-URL manifest; Keycloak-stopped linked navigation; computed themes/ordered fields; actual audited-page admission, gzip collector, metrics/census validator, rate pacing and sanitized artifacts. | Real same-build/same-database run; no API 429; positive call observer; wrong/error page, invalid report, over-budget and missing-source controls; TLS and failure cleanup; all 27 report pairs. | +| 4 — Required CI and phase exit | Always-running seventh job, bounded protection activation after real success, standards/carrier alignment, complete evidence matrix and P02b-0/Phase 05 handoff. | All backend/frontend suites with zero skips; real CI and live protection read-back; links/anchors/wrapping; every gate/deliverable/criterion reconciled; zero 02d Registered/Awaiting rows; two review rounds and explicit exit verdict. | + +The new job does not replace any existing required suite or fixture. Preserve +the accepted P6 keyboard/focus/reflow/contrast and maintainer VoiceOver record, +with its actual limits; record new automated observations separately. Promoting +Performance to Active is conditional on delivering the proposed hard checks, +not on accepting this package. Its index/header/count change lands with the +enforcer; Accessibility is already Active for its delivered public-page scope. + +#### P02d-7 approval and exit boundary + +The complete lifecycle and G20/G33/G38/G45 answers, four-step review plan and +bounded seventh-check activation were approved on 2026-10-11. Revised ADR-0056's +tools/measurement/browser contract was subsequently approved on the same date. +Its register dependencies are recorded in the combined decision commit before +implementation. +No other unresolved product choice was found in this inspection. Unexpected +architectural changes need a separate reviewable decision; implementation details +inside the approved package proceed without further approval. + +At closeout, map every Deliverable and Completion Criterion to current named +evidence, including carried-forward evidence and its limits. Reconcile current +Phase 02b native-HTTPS/quotas/single-bootstrap premises without accepting its +auth/BFF gates. Record independent Course/Lesson roots, stable IDs/URLs, ordering, +scope, translated bodies, exact pins and access policy as Phase 05 preservation +obligations; re-seeding is not a migration. + +Do not declare Phase 02d exited merely because P7 code or its PR is complete. +The exit verdict must state which obligations passed, identify evidence/CI/protection +receipts and leave no unresolved 02d-owned gate or Registered/Awaiting rule. The +maintainer's PR review and merge remain separate from implementation completion. + +### P02d-7 Step 1 — shared demo lifecycle (2026-10-11) + +**Complete — both review rounds and their fixes verified.** +The foreground `make demo` supervisor shares explicit Compose selection, +PostgreSQL-before-migration readiness, full seven-service/both-realm admission, +contextual seeding, production builds and owned process cleanup. Local data is +retained; CI uses a unique project, disposable private configuration and the e2e +overlay. Its browser/audit driver remains Step 3 work and is not claimed here. + +`DemoManifest.Create` derives all three host/locale lanes, nine URLs, ordered field +expectations, palettes and excluded course titles from `SeedData`. Its export +requires no database. Configuration validation uses Npgsql's real parser to +refuse stale role/password/database/host/port combinations before infrastructure. +The runtime environment helper is shared with the existing paired API launcher; +migration and worker credentials are excluded. TLS prerequisites do not modify +hosts or system trust. The exclusive demo lock never kills a recorded foreign PID. + +**Initial verification.** The Release Seeder build has zero warnings/errors. +All 1,705 unit, 198 architecture, four contract, nine focused integration and +42 Seeder integration cases pass with zero failures/skips. Focused integration +includes eight manifest/configuration cases and the real-API/PostgreSQL local +launcher proof without a PlatformAdmin credential. The guarded frontend runner +passes 1,413 cases without skips/todos; the final 13-case focused suite also +covers structured API-listener readiness and an invocation-owned native cache. +Lint and typecheck pass. Node controls cover +explicit selection, readiness refusal, runtime credential isolation, foreign +listeners, bounded bodies, certificate mismatch, real process timeout/cancellation +and owned trees. These initial checks precede the complete startup and review +evidence below. + +**Complete isolated lifecycle proof.** An owned temporary checkout copy starts +all seven services with the e2e overlay, passes both realm gates, applies every +migration chain and seeds through the application role. Its owned production +API/native renderer passes positive lesson checks in all three seed host/locale +lanes. Re-running the ordinary Seeder leaves the nonempty audit count unchanged. +Teardown confirms process/project absence, removes its private configuration, +certificate tree and lock, and releases the API/web listeners. This is a Step 1 +startup/neutrality/cleanup proof; it is not Step 3 browser/Lighthouse evidence. + +**Workstation port prerequisite.** The first attempt correctly refused occupied +5080/5432 ports. After explicit maintainer authorization on 2026-10-11, the two +foreign containers were stopped only for validation and restarted afterward; +both returned healthy. LearnStack volumes remain intact. Its local PostgreSQL +and Keycloak cannot run concurrently while the foreign database retains 5432; +no port mapping or foreign project configuration was changed. + +**Step 1 review round 1 — `3160714..82459cb`.** Fresh GPT-6-astra and +GPT-6.1-sol reviewers independently covered the full diff through lifecycle/ +security and evidence/corpus lenses. Four distinct findings were verified: +selected-env migration fallback, terminal hangup cleanup, split UTF-8/byte-bound +capture and restricted-course marketing exclusions in the demo manifest. +Corrections add actual Make/configuration, process/signal/parent, split-output +and marketing-disjoint controls. Combined seeding now rejects disagreeing ambient +credentials before stack/migration actions. Default-service enumeration is shared +as reinforcement; the suspected gated-port defect was not reproduced. The +suspected parallel prerequisite race was rejected because global `.NOTPARALLEL` +already serializes those acts. The 18-case focused suite, typecheck, eight +manifest/configuration cases and all 198 architecture cases pass. Lint and the +guarded frontend run pass all 1,420 cases with zero skips/todos. The complete +isolated lifecycle rerun also passes real SIGHUP/repeated-signal cleanup and +listener release. The two authorized foreign containers return healthy afterward. + +**Step 1 review round 2 — `3160714..4966dca`.** Fresh GPT-6-astra and +GPT-6.1-sol reviewers independently inspected lifecycle/security and evidence/ +corpus. Both reran all 18 focused cases. Two additional findings were confirmed: +Docker can read `COMPOSE_PROFILES` from the selected dotenv file, and Development +API configuration can consult workstation user secrets through its profile. +The selected-file control now refuses reserved Compose assignments, including +export/whitespace/colon forms. A read-only real Compose control observes exactly +seven default services and refuses the planted file profile before Docker. +The demo API receives an empty invocation-owned HOME/APPDATA/TMPDIR; restoration +and builds retain their own profiles. A real API/PostgreSQL test accepts the +configured hop secret and rejects an additional secret planted in a foreign +profile. No user profile is modified. All 24 focused lifecycle cases, typecheck +and the real launcher cases pass with zero failures/skips. Lint, all 1,426 +guarded frontend cases and all 198 architecture cases pass. A fresh focused fix +review found that the foreign-secret request omitted its visitor header, allowing +an unrelated refusal to mask the regression. The corrected three-case real API +suite supplies that header and removes the profile boundary only in an owned +copy: the foreign secret now produces 200 in the mutant and 404 in the clean +runtime. All three cases pass. Both required review rounds and their verified +fixes are complete; Step 2 follows. + +### P02d-7 Step 2 — genericity and exit foundations (2026-10-11) + +**Implementation and both independent review rounds complete.** The exact-literal +G20 fence reads categorized `DemoIdentities.Create` values derived from typed +`SeedData`, including all declared IDs and translated identity/definition/field +labels. Ordinary body prose, locale/status/access values and schema keywords do +not become identities. Canonical platform values come from +`BuiltInCustomizations`, including collisions with demo values. + +Backend Roslyn and frontend TypeScript AST readers compare complete decoded +strings ordinally and GUIDs by value, including alternate spellings. Embedded +JSON strings and standalone JSON values are inspected. Source parse errors, +empty/malformed vocabulary and missing production subjects fail loudly. The +frontend fixture is derived, not a second declaration; architecture tests compare +it to the current typed projection. Regenerate it after editing `SeedData`: + +```sh +dotnet run --project backend/src/LearnStack.Tools.Seeder -c Release -- \ + --demo-identities > frontend/apps/web/src/test/fixtures/demo-identities.json +``` + +The backend scans Seeder orchestration as production and exempts only its exact +`SeedData.cs` declaration. Frontend scans app/package/configuration/native code +and root Node scripts, with exact named test-proof and development-launch data +exemptions. The existing unsupported-runtime-source/import census is preserved. +Every production project except the Seeder is checked for both compiled and +restore-resolved Seeder references, preventing symbolic `SeedData` access. + +The existing real API/PostgreSQL/HTML/Flight presentation successor proof calls +one shared ordered-field assertion. Planted label, value, ordering, active-markup, +language and internal-name controls exercise the same predicate without changing +production rendering. Original-pin and unaffected-tenant observations remain. + +**Initial verification.** Release suites pass 1,705 unit, 203 architecture and +four contract cases with zero failures/skips. The existing presentation-mode +real API/PostgreSQL/native production proof passes (one case), exercising both +HTML and Flight, successor/rebinding, original pins and the unaffected tenant. +Lint, typecheck, formatting and all 1,442 guarded frontend cases pass with zero +skips/todos. This is not Step 3 browser/Lighthouse evidence. + +**Review round 1.** Fresh GPT-6-astra and GPT-6.1-sol reviewers independently +checked backend identity/reference coverage and frontend subjects/behavioral +controls. Four verified omissions were fixed: conditional C# branches, UTF-8 +literals, emitted JSX values and runtime helpers hidden by test exemptions. +The initial fix exposed conditional branches conservatively; round 2 below +replaces that approach to preserve lexical semantics. JSX handling uses TypeScript's +emitter. Exact test directories replace name-wide directory exclusions, and +runtime relative imports include helpers even behind test filenames. Native HMR +and the shared process-stop helper are production subjects. Planted dirty/clean +controls cover all four findings. Release architecture (203), guarded frontend +(1,444), lint, typecheck, formatting and the documentation link audit pass. +Second-round review follows the fix commit. + +**Review round 2.** Fresh GPT-6-astra and GPT-6.1-sol reviewers verified three +census omissions and one ordering-control gap. Flattening C# branches could let +an inactive comment hide active code. The reader now parses feasible symbol +configurations independently, discovers conditional symbols to a fixed point, +and refuses more than eight symbols or unreadable configurations. UTF-8 and raw +literal semantics remain intact. Frontend generated-output exclusions now name +actual roots; nested source directories remain subjects. Runtime imports resolve +local module URL query/fragment suffixes and percent escapes, while computed +imports fail loudly. Literal `require.resolve` loads remain countable. The +ordering offender now swaps intact label/value pairs rather than corrupting them. +Release architecture (203) and all guarded frontend cases (1,446), lint, +typecheck, formatting and link/anchor checks pass. Fresh fix verification follows. + +**Fresh fix verification.** The prior omissions are closed. The verifier found +one new resolver-options gap: a relative `require.resolve` input could redirect +through `paths`. That unmodeled form now fails loudly, with a planted control; +the actual literal external-package ESLint load remains accepted. Both review +rounds and their verified findings are complete. Step 3 follows. + +#### P02d-7 preliminary exit evidence inventory + +This is an inventory, not a phase-exit verdict. Step 4 reconciles every individual +Deliverable and Completion Criterion against exact-head evidence, current CI and +protection receipts; historical delivery records retain their original limits. + +| Criterion group | Existing evidence to retain/recheck | P7 completion work | +|---|---|---| +| Two sites, linked catalog/course/lesson, distinct field sets and branding | P6 product/presentation HTML/Flight fixture; maintainer keyboard/focus/reflow/VoiceOver record; `DemoManifest` typed expectations. | Step 3 real Chromium linked navigation and computed themes, three host/locale lanes; same API/web/database with owned Keycloak stopped. | +| Clean preparation, non-destructive repeat and owned stop | Step 1 isolated seven-service/two-realm startup, audit-neutral Seeder repeat and signal/parent/process/TLS controls. | Step 3 shares the lifecycle; Step 4 records complete CI receipt and cleanup. | +| Education schema, RLS, scope, satellites, parents, privileges, migrations | `EducationIsolationTests`, migration/tenant-scoping/index/grant sweeps and P1 delivery record. | Re-run full backend suites at exit; Phase 05 preservation handoff. | +| Writer invariants, exact pins, locale/slug/state/body validation, MUST audit, branding and seed convergence | `EducationWriterTests`, `TenancyWriterTests`, `SeederTests`, P2 record. | Re-run with zero skips; retain typed inventories and bounded branding/access contracts. | +| Definition/settings reads, generations, rollback safety, fallback, no read validation | `CustomizationProjectionTests` and composition/cache/settings suites; P3 record. | Re-run; retain existing generation/family metrics and settings freshness boundary. | +| Public eligibility, cursor/scope/locale, DTO/HEAD/OpenAPI/SDK, no-store, read-only pipeline | P4 public HTTP, contract, transaction and dispatch guards. | Re-run SDK/contract/OpenAPI drift and all required checks; retain current seven-check read-back only after success. | +| Provenance/ingress/hop/visitor limits, private carriers, native production freshness, single bootstrap | P5 remediation and ADR-0055 real native/API proofs; P6 bootstrap/refusal coverage. | Step 3 uses identical ingress/limiter and records positive API calls with zero 429. | +| Localized renderer, inert authored text, metadata/status, manual accessibility | P6 i18n/route/HTML-RSC/a11y guards and recorded human checks. | Step 3 observes actual browser documents; Lighthouse supplements the bounded manual record. | +| G20 exact-literal/reference fence and schema replacement | This Step 2 backend/frontend dirty/clean controls and existing presentation successor fixture. | Both independent review rounds; Step 3 ordered fields. Computed comparisons remain a review/behavioral boundary. | +| Performance/accessibility audits, report/census/gzip guards | ADR-0056 accepted obligations, four Registered behavioral rules. | Step 3 implements 27 report pairs/hard checks; Step 4 activates mandatory CI/protection and updates statuses with enforcement. | +| Governance, all gates/catalogue rows, module/standard/skill/vision/MVP carriers and named exclusions | Existing decision register and P1–P6 records. | Step 4 complete criterion matrix, zero 02d Registered/Awaiting rows, owner-document checks and P02b-0/Phase 05 handoffs. | + +### P02d-7 Step 3 — browser and audit proof (2026-10-11) + +**Complete — both independent review rounds and verified fixes pass.** +The shared CI lifecycle invokes `verify-public-demo.mjs` over the same seeded +production API/native renderer. `DemoManifest` also derives hidden lesson titles +and field values, excluding values shared with eligible public content. The +browser follows the actual catalog/course/lesson links in all three host/locale +lanes and compares computed theme colors, identity and ordered authored fields. + +Exact development-tool pins are Lighthouse 13.5.0 and Playwright 1.64.0, using +its managed full Chromium 156.0.8078.4, revision 1248. The audit configuration +has an independently recorded 125-audit/14-artifact census and complete category +weights. A custom initial-navigation gatherer observes the actual document and +all delivered Script bodies; it never refetches or navigates. The shared policy +checks original inline source, complete decoded bodies, normalized gzip and the +actual audited product DOM before admitting a JSON/HTML report pair. + +The structured API completion observer retains only aggregate status/path-family +counts. A fresh HTTPS product read supplies its positive control. Four serial +batches preserve the real admission policy, with the approved quiet intervals +and subordinate deadlines. The public receipt records setup/proof/cleanup +milliseconds, versions, configuration, source digests, report inventory and API +counts. Browser profiles, certificates, private configuration and raw logs stay +outside the public evidence. CI cleanup confirms owned process/project absence +and removes owned private files, environment and lock within its separate bound. + +**Initial verification.** All 43 focused frontend controls and eight existing +manifest/configuration integration cases pass with zero failures/skips. Lint +and typecheck pass. The guarded frontend runner passes all 1,489 cases +(1,429 web + 60 SDK); all 203 architecture cases pass, with zero skips. +The controls include actual installed Lighthouse configuration +resolution, measured over-budget sources, wrong/error product documents, invalid +reports/census, missing/truncated/foreign-target sources and split API events. +The first complete serial feasibility run passes: lifecycle setup 61,468 ms, +proof 549,269 ms (9 min 9 s), and confirmed cleanup 10,656 ms. It produces all 27 +JSON/HTML pairs and nine browser screenshots. All hard checks pass: normalized +initial JavaScript is 106,381–106,573 bytes, CLS is zero, and scored automated +accessibility is 1 on every run. The four proof batches observe exactly +18/36/36/36 successful API calls; the separate observer control observes two. +Both untrusted/wrong-SPKI browser controls refuse before reaching the API. +The CI-owned Keycloak stays stopped. Owned processes/project/private files, +environment and lock are removed; the two authorized foreign containers restart. + +LCP samples are 2,008.544–2,166.836 ms, producing an advisory median warning for +each of the nine URLs. Server response is 15–23 ms and TBT 4–16 ms. These are +local simulated lab samples, not field/p95 performance or a new manual WCAG pass. +This is working-copy feasibility evidence over the `69ce613` baseline and build +`vCA7HXnit3froptth4IYD`; it is not an exact-head CI claim. Subsequent receipt-only +hardening records the complete expected/configuration census and startup/final +counters; the final committed CI run must validate those changes. Step 4 owns +CI enforcement, protection activation and phase exit. + +The first runtime attempts exposed harness defects before any audit verdict: +macOS's certificate verifier stalled when Chromium inherited the isolated Node +HOME, and optional `undefined` configuration properties differed from their JSON +receipt. A causal disposable TLS experiment confirms certificate refusal with +the real account HOME; only the managed browser receives it, with an explicit +owned disposable profile. Worker/API environments remain isolated. Configuration +comparison now uses the serializable receipt and an actual-resolution control. +The linked nine-page browser path subsequently passed, with exactly 18 successful +API calls and no 429, before the configuration comparison stopped that attempt. +These observations are preliminary validation, not the complete feasibility gate. + +**Transitive review.** Both direct tools are Apache-2.0 development dependencies. +The added tree includes MPL-2.0 `axe-core` 4.14.0, used by Lighthouse's development +accessibility audit; retain its package license/source obligations if distributing +that tool. Existing runtime package versions are unchanged. Resolving the added +OpenTelemetry API peer changes some lockfile peer identities without adding +application instrumentation. The full-workspace audit reports zero critical, +one high and one moderate advisory; neither path comes from the new tools. +The existing [security triage](#remediation-group-3--approved-security-maintenance) +owns Braces' unfixed build-glob recursion issue and the incompatible-major +selector-parser fix. Their maintainer/backport/parent-migration ownership and +Security Standards deadlines remain unchanged; this is not a clean-audit claim. + +#### Step 3 review round 1 + +Fresh GPT-6-astra (xhigh) runtime/measurement and GPT-6.1-sol (xhigh) +proof/corpus/dependency reviews cover `69ce613..d99d10b`. Both request changes; +three verified findings concern the proof, with no production-code defect. +Independent root reproductions confirm each failure before correction. + +- Completion observation silently ignored damaged JSON and unterminated records; + the receipt preceded final API output drain. Separate stdout/stderr decoders, + pending-tail refusal, joined EOF finalization and post-cleanup final census now + prevent malformed, late or 429 traffic from disappearing. Actual record controls + include split/interleaved clean output, damaged/newline/EOF records and late + success/refusal events. +- Lighthouse treats its navigation timeout as a warning. A real disposable + Chromium/Lighthouse run took 22,116 ms and admitted an incomplete image load. + Report admission now refuses warnings. The shared proof includes that actual + soft-timeout control with an otherwise passing report counterpart. +- Ancestor opacity and zero-area rendering hid product markers while the DOM gate + admitted them. Browser-native visibility plus positive-area rectangles now + apply to markers and individual field labels/values. Each real lesson navigation + plants ancestor-opacity, zero-area and hidden-label mutations, verifies refusal, + restores the document and re-admits the visible control, without new API calls. + +All 74 focused proof/lifecycle cases pass with zero failures/skips. Disposable +managed-Chromium controls verify visible admission and hidden/zero-area refusal. +A complete rerun and fresh independent round 2 remain pending; the earlier local +feasibility result is preserved and does not validate these subsequent changes. + +#### Step 3 review round 2 + +Fresh GPT-6-astra (xhigh) runtime/measurement and GPT-6.1-sol (xhigh) +proof/corpus reviews cover `69ce613..38c15d9`. Both request changes. Three +verified proof gaps remain; the existing 50 proof controls passed independently. + +- A completion record missing its opening brace or carrying a prefix was ignored, + as was an unexpected endpoint completion. The owned API emits structured + startup/output; only empty plaintext separators are now admitted. Damaged and + unexpected completion records refuse the entire census, including a 429. +- Process-group absence preceded child `close` and buffered stream EOF. An actual + child with deliberately deferred stdout demonstrated a 429 arriving after a + prematurely passing final census. Cleanup now joins `close` with a separate + shared five-second stop/output-drain bound before observer finalization. The + regression holds and releases the real pipe, proves cleanup waits, then refuses + the 429. +- Clip/filter/mask and offscreen/overflow styles could hide laid-out markers. + The bounded seeded-page predicate now refuses these ancestor styles and + horizontally unreachable/negative-area markers. Each lesson navigation checks + eight actual DOM mutations, restores and re-admits its positive control; + the receipt requires all 24 completed controls. This is not a general CSS + occlusion or visual-accessibility audit. + +All 78 focused proof/lifecycle cases, typecheck and lint pass. The full guarded +runner passes 1,500 cases (1,440 web + 60 SDK), with zero failures/skips/todos. +Fresh GPT-6-astra (xhigh) and GPT-6.1-sol (xhigh) fix reviews approve +`38c15d9..39cee01` and the final shared cleanup-deadline adjustment. Independent +controls reproduce the old observer/drain gaps, confirm joined stdout/stderr, +refuse daemon output errors and bound a TERM-resistant child plus held output +to 5,003 ms. Managed Chromium refuses all eight DOM mutants and re-admits each +restored counterpart; the five added cases passed the old predicate. + +The complete `38c15d9` rerun passes: setup 60,270 ms, proof 571,089 ms, +cleanup 10,688 ms, all 27 report pairs and nine screenshots, actual soft-timeout +refusal, nine visibility controls and 134 successful API completions, with zero +429/malformed/pending records. The authorized foreign containers restart healthy. +That receipt precedes round-2 fixes. The complete `39cee01` rerun also passes: +setup 60,374 ms, proof 585,644 ms and cleanup 10,719 ms. Build +`d0bzXjXoImUdH317X83rK`, invocation `825982bee2ed568f43208368f99f332d`, +produces all 27 report pairs, nine screenshots and 24 completed visibility +controls. The actual soft-timeout and both TLS refusal controls pass; final +observation contains 134 successful calls, zero 429/malformed/pending records. +All owned cleanup checks pass and the authorized foreign containers restart. +The subsequent shared-bound change has focused verification above and must also +run in final CI. Both independent review rounds and fresh fix verification are +complete. Step 4 owns CI enforcement and phase exit; neither is claimed here. + +### P02d-7 Step 4 — CI enforcement and phase exit (2026-10-11) + +**Implementation, both independent review rounds and real CI complete.** The +[CI and exit closeout](#p02d-7-ci-and-phase-exit-closeout-2026-10-11) records +successful artifacts and the activated seventh required context. +The always-running `public demo + lighthouse` job replaces the disabled +placeholder. It installs the exact locked browser tools and managed Chromium, +then invokes `make demo DEMO_MODE=ci` over the shared seven-service lifecycle. +The 20-minute job includes setup, the 12-minute serial proof, separate bounded +cleanup and artifact handling. An unconditional bounded workflow receipt records +failure even when setup prevents a product receipt. Uploads allow only public +JSON/HTML reports, screenshots and sanitized workflow/proof receipts. + +The [individual exit evidence matrix](phase-02d-exit-evidence.md) maps all +36 Deliverables, 79 Completion Criteria, 39 exclusions and nine exit obligations. +It preserves prior human/PR evidence and names the verified CI receipts; +implementation alone does not constitute phase exit. + +**Criterion reconciliation.** Published roots cannot receive new translations +under ADR-0050. The third-locale proof therefore creates and publishes new +French-only roots through supported commands, preserving the existing EN/TR +public responses and all five migration histories. Organization branding writers +remain unrouted. The branding criterion now separates API-root command refusal +from mapped-host reads and distinguishes a successful business audit from the +required standalone denied-attempt audit. This corrects the mutable criterion's +no-audit overclaim; ADR-0033/0044 and production auditing are unchanged. + +**Education preservation handoff.** Phase 05 inherits separate Course/Lesson +roots and their contained Pattern A translations. Its migration preserves stable +IDs and published localized URLs, `(sort,id)` lesson order, tenant/organization +scope, translated bodies, exact content-type/taxonomy revision pins and course +access policy. `SeedData.All` supplies both tenant curricula: eight courses, +ten lessons and 27 translations at their declared scopes, including draft and +restricted controls. Carry those persisted fixtures through the migration; +reseeding is not a migration strategy. + +**P02b-0 re-verification handoff.** Re-read the four `/api/v1/public` GET/HEAD +pairs and contract; retained `*.learnstack.local` seed hosts over native HTTPS, +manual local hosts/CA setup and private paired hop configuration; coordinated +60/min visitor and 600/min physical-peer accounting; tenant-wide English versus +organization-scoped Yoga seed context; one live bootstrap with two calls per +normal product document and three for a followed missing-content chain; and the +uncached settings reader plus `learnstack.tenancy.settings` eager-invalidation +event owned by Phase 02b. These delivered premises do not Accept future +session/BFF/authenticated-rate-limit decisions. + +**Initial validation at `bf5bc9c`.** Both third-locale/branding cases pass locally +with zero skips. Release build has zero warnings/errors; 1,705 unit, 203 architecture, +four contract and 180 Docker-free integration cases pass, as do all 1,500 guarded +frontend cases. Typecheck, lint, whitespace and 2,042 relative links (666 fragments) +pass. All 871 Docker integration cases pass with zero skips; execution guards pass. +Two fresh review rounds, a real +successful PR job and the authorized seventh-context activation/read-back remain. +No PR, live check activation or phase exit is claimed yet. + +#### Step 4 review round 1 (2026-10-11) + +Fresh GPT-6-astra (xhigh) and GPT-6.1-sol (xhigh) agents reviewed +`06d46ad..bf5bc9c` for runtime/CI ownership and corpus/exit evidence. Their +confirmed findings concern proof completeness and CI prerequisites, not a +production contract defect. C40–C42 now share one HTTP test: 17 requests pair +foreign-only/shared slugs, exact-locale omissions and organization/tenant host +controls with exact raw translation sets on each request's own enlisted +application connection. The third-locale case now enables French on the foreign +host and proves its positive catalog before checking the same French detail +URLs are hidden. Missing Cache-Control can no longer bypass its assertions. + +The CI job starts an absolute deadline before checkout, bounds setup steps and +interrupts the shared lifecycle by minute 16, reserving four minutes inside the +unchanged 20-minute ceiling for owned cleanup, policy cleanup and artifacts. +Four controls execute the actual workflow shell: first-step reservation, +remaining-budget arguments and two exhausted-budget refusals. An actual GNU +timeout → make → shared Node-owner experiment exits 124 only after child cleanup. +The internal 12-minute serial proof deadline remains unchanged. + +The GitHub-hosted Linux prerequisite loads only an invocation-owned AppArmor +userns profile for the exact managed Chromium when the kernel restriction needs +it, then performs a sandbox-enabled launch smoke. Fifteen isolated controls +exercise ownership, literal paths, failed load/removal and cleanup without +changing workstation policy. A real Linux job remains required; the initial +runner risk was source-supported inference, not a reproduced hosted failure. +The exit matrix, stale review guidance, Phase 02b risk premises and mutable +renderer/writer scope descriptions now match delivered contracts. + +All three focused locale/branding Docker cases pass with zero skips and the +execution guard. The guarded frontend rerun passes 1,504 cases (1,444 web + 60 +SDK), zero failures/skips/todos; typecheck, lint, scoped backend formatting and +whitespace pass. The initial full backend totals above precede the added combined +case. Final complete PR CI, round 2 and protection read-back remain required. + +#### Step 4 review round 2 (2026-10-11) + +Fresh GPT-6-astra (xhigh) and GPT-6.1-sol (xhigh) agents reviewed +`06d46ad..100b022` across runtime/CI ownership, security, correctness, +maintainability, test quality and the complete changed corpus/exit matrix. +Both approve with no remaining Blocker or Major. The corpus reviewer confirmed +two Minor navigation/census remnants: the matrix still called the expanded +three-case class two cases, and CONTRIBUTING still described completed +ADR-0055 reviews as remaining. Both are corrected; the reviewer independently +checked the bounded follow-up. One overlong P7 prose line is wrapped without +changing its content, and catalogue navigation names the new deadline/sandbox +controls. Earlier P1–P6 delivery records remain unchanged. + +The runtime reviewer independently passes all 15 Chromium-prerequisite controls, +four actual-workflow deadline controls and all three focused Docker HTTP cases, +with zero skips, plus whitespace. The corpus reviewer verifies all 36/79/39/9 +inventory counts, complete named evidence, 175 Implemented rules, 21 Active/one +Adopted standards and future-owner scope. Root checks pass seven ADR lifecycle +and 14 OpenAPI-CI controls. The complete PR's 33 unique Markdown files resolve +2,188 relative links and 686 fragments with no errors before these closing +navigation additions. Fenced and inline examples are excluded from link counts. +Both review rounds and verified fixes are complete. Actual successful Linux PR +CI, complete artifact receipts and authorized protection activation/read-back +remain required before a positive phase-exit verdict. + +#### P02d-7 CI and phase-exit closeout (2026-10-11) + +**Verdict: implementation and Phase 02d exit verification complete.** All four +P7 steps completed both fresh independent review rounds; verified findings are +fixed and committed. [PR #28](https://github.com/HodeTech/LearnStack/pull/28) +remains open for maintainer review and merge. No merge is claimed. + +All seven jobs passed in the first real +[PR run](https://github.com/HodeTech/LearnStack/actions/runs/38106505383) for source +head `8605fc94f7ba343d5cd7cc15d28001d99349735b`. GitHub tested synthetic merge +`15109c3a80a41ee7b6d6f9832880c7b2b13877f5`, whose parents are that source and +main `378346126949c90bb286b5217c24513fc2044f06`. The sanitized workflow receipt +records both identities. Downloaded TRX files confirm 1,705 unit, 203 architecture, +four contract, 180 Docker-free integration and 872 Docker integration passes: +2,964 backend tests, zero failures/skips. All three locale/branding/combined-scope +cases are present and Passed. Frontend job output confirms 1,444 web + 60 SDK +passes, zero failures/skips/todos; typecheck, lint, builds and SDK drift pass. +Meta, secret scan and OpenAPI compatibility pass too. + +The `public-demo-lighthouse` artifact contains exactly 27 JSON/HTML report pairs, +nine screenshots and two sanitized receipts; no private logs, profiles, env files +or keys are included. Report digests, nine URL/three-run census, configuration, +invocation, build and synthetic checkout identities match. The invocation is +`aab02c37c71c02569f71ef8829a96c09`; build is `yTmMjFs6JVE2ea_W-jFzX`. +Node 22.23.1, Lighthouse 13.5.0, Playwright 1.64.0 and Chromium revision 1248 / +156.0.8078.4 run on the four-CPU Ubuntu 24 hosted runner. Its sandbox-enabled +launch passes with the exact-executable temporary AppArmor prerequisite; +the owned policy removal passes afterward. + +All nine linked navigations and 24 planted visibility refusals pass. Every audit +has normalized initial JS gzip 106,359–106,553 bytes, CLS 0 and automated +accessibility 1. Per-URL timing medians remain advisory, not production p95. +All 134 API observations are 200, with zero 429, malformed or pending records: +six startup + two observer-control + 18 linked-browser + 3 × 36 audit calls. +Both TLS refusal controls and the actual audit-timeout control pass. Keycloak is +stopped before anonymous navigation. Setup/proof/cleanup take +228,127 / 650,154 / 11,041 ms; the proof stays inside 12 minutes and cleanup +inside 30 seconds. The complete job takes 15 minutes 51 seconds, within its +20-minute ceiling and absolute setup/proof deadline. + +After that successful run, the authorized API update adds only +`public demo + lighthouse` to main's required contexts. A fresh full read-back +confirms seven GitHub Actions contexts (`app_id: 15368`), strict up-to-date +checking, all six prior contexts retained and every unrelated protection setting +identical to the immediately preceding API response. The current list lives in +[CONTRIBUTING](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main). + +The [individual evidence matrix](phase-02d-exit-evidence.md) reconciles all +36 deliverables, 79 criteria, 39 explicit exclusions and E01–E09. All 45 register +rows have dated Accepted closure links; no Phase 02d-owned Registered/Awaiting +catalogue rule remains. All four ADR-0056 rules are Implemented; corpus checks +confirm 175 Implemented catalogue entries and 21 Active/one Adopted standards. +The existing P6 human/VoiceOver evidence retains its original attribution and +limits; this closeout claims no new final-head manual WCAG or zoom audit. +Phase 05 preservation and P02b-0 premises are handed off above and in their +owning documents; no future-phase decision is accepted by this exit. +Closing-document checks pass all five CorpusConsistencyTests with zero skips; +the 33 changed/new Markdown files resolve 2,197 relative links and 694 fragments +with no errors. Added prose respects 88 columns; ADR-0056 changes are insertion-only. + +##### Reviewed implementation head verification (2026-10-11) + +The first run above owns activation history. The separately downloaded +[reviewed-head run](https://github.com/HodeTech/LearnStack/actions/runs/38108622191) +passes all seven jobs for source `d47ef17c8473e17e0950361dc35e419ba6c0825c`, +tested merge `ba44563af66fbd7744b6625673e61286a8e77fa4` with the same main parent. +Its invocation is `170c02e7741fbc6c79895ce718cecd89`; build is +`rlL6ubYmcf7_VQaPBN-fA`. TRX/frontend receipts confirm the same 2,964 backend +and 1,504 frontend passes, zero failures/skips/todos. + +All 27 report pairs, nine screenshots, nine navigations and 24 visibility +refusals pass. Report digests and source/build/invocation/configuration identities +match. Normalized gzip is 106,358–106,551 bytes, CLS 0 and automated +accessibility 1 throughout. Both TLS refusals, the audit-timeout control and all +134 API observations (200 only, no malformed/pending records) pass. +Setup/proof/cleanup take 214,616 / 644,207 / 12,962 ms; all cleanup flags pass. +Sandbox-enabled Chromium and owned AppArmor cleanup pass. These numbers belong +to the exact implementation head reviewed externally, not to later commits. + +##### External review follow-up (2026-10-11) + +The maintainer's review of `3783461..d47ef17` reported no Blocker/Major and +four bounded documentation/maintenance findings. All four are verified and +addressed: the reviewed-head receipt above supplements activation history; +runtime imports move to the file header; installed tool/browser metadata feeds +the shared census and remains checked against independent committed pins; and +ADR-0056 gains dated acceptance-baseline navigation without changing history. +Tool versions, budgets, admission and cleanup behavior are unchanged. + +Local validation passes 1,454 web + 60 SDK tests, zero failures/skips/todos, +typecheck, lint and all 203 architecture tests. Ten added cases cover installed +metadata/pin mismatch, malformed or missing browser metadata, stale census and +wrong-version report refusal, each retaining a clean passing control. Both fresh +independent remediation rounds reviewed `d47ef17..dca3a7b` and approved without +findings. Each independently checked installed dependency resolution, fail-closed +metadata/census comparison and the downloaded historical receipts. All five +corpus tests pass; the complete PR's 33 changed/new Markdown files resolve +2,201 relative links and 698 fragments without errors. The new remediation head +must still pass full seven-job CI through the PR rollup before merge. + +##### Systematic review remediation (2026-10-11) + +The later review examined source `aebefee`. Its findings were re-verified against +current code before changes; this is a separate record from the historical +`d47ef17` review above. No new architecture decision or budget change is needed. +Work remains on `development`; PR #28 is not merged by this record. + +1. **Lifecycle and launcher — complete.** `cd6affd` removes the unsupported + Seeder launcher argument, attempts independent process/observer/Compose + cleanup stages even when a preceding stage fails, and retains failure causes + internally without echoing private diagnostics. Private files are removed + only after confirmed cleanup. Socket errors distinguish occupied ports from + other bind failures; declaration files describe the actual lifecycle API. + `/artifacts/` is ignored. All 34 lifecycle tests, lint and typecheck pass. + Two first-round reviewers and a fresh second-round reviewer approved without + findings. +2. **Genericity census — complete.** `db6bcdf`, `90114f0` and `c6cd69f` close + source-root/format admission and supported static import gaps. Local and + inherited-config candidate ambiguities are conservatively included or + refused; workspace manifests have explicit ownership. Backend evaluation + checks `Compile` and `ProjectReference` coverage, exact output roots and + bounded build shapes/properties. First-round frontend/backend reviewers + identified five verified resolution/build-admission gaps, fixed in + `90114f0`. The fresh second round found inherited-config shadowing, fixed in + `c6cd69f`; its same counterexample now refuses and its clean control passes. + All 18 frontend genericity cases, seven backend genericity cases, all 203 + architecture cases, lint, typecheck and targeted format pass. This remains a + bounded exact-literal/static-import fence, not arbitrary program analysis. +3. **Proof helpers and carriers — complete.** `9a0d285` extracts actual worker + network-inventory reconciliation and verifier advisory aggregation for fast + dirty/clean controls. Navigation counts come from completed + observations; upload globs admit only named reports/screenshots/receipts. + Current architecture, infrastructure, standards and route-authoring guidance + describe delivered G44/G45 enforcement. Two first-round reviewers and a fresh + second-round reviewer approved without findings. The second round also + reproduced all historical advisory medians from the downloaded receipt. + All 128 focused tests, 1,537 frontend cases (1,477 web + 60 SDK) and 2,092 + Docker-free backend cases (1,705 unit + 203 architecture + four contract + + 180 integration) pass with zero failures/skips/todos. Release build, format, + lint, typecheck and production frontend builds pass; 15 sandbox-policy and + seven ADR-status controls pass. The complete PR's 36 Markdown files resolve + 2,289 relative links and 719 fragments; added prose respects 88 columns. + The final PR rollup must still pass all seven required jobs, including the + real Docker and 27-audit lifecycle proof, before merge. + +**Late automatic-review follow-up.** Run 38125035732 passes all seven jobs for +source `4541d63`, including 2,964 backend and 1,537 frontend cases, the real +27-audit proof and cleanup. A later CodeRabbit comment exposed a local-DX gap: +ignored `.env.local` and OS/editor files reached raw filesystem classification. +The follow-up admits only explicitly known machine filenames that Git also +ignores. Ignored source files, unknown formats and compiler coverage retain +fail-closed behavior. The first independent round approved `d16d2d7`. The fresh +second round reproduced an automatically imported `.csproj.user` build hook; +`.user` is therefore excluded from the machine-file exemption in `9343e99`, +with explicit refusal controls. The reviewer independently re-ran the actual +collector: clean ignored files pass and the original build-hook counterexample +refuses. Both review rounds are complete, with no remaining findings. All 19 +frontend and seven backend genericity cases pass; the complete guarded frontend +rerun passes 1,538 cases (1,478 web + 60 SDK), zero failures/skips/todos. +Owned temporary ignored-file controls pass both actual raw collectors and are +removed afterward. Whitespace and the complete PR's link/fragment audit pass. +The final PR rollup below owns replacement final-head CI; all seven jobs remain +required before merge. + +**CI stability follow-up.** Run 38126790757 reports one unit-test failure in +`A_Factory_That_Ignores_Its_Token_Is_Still_Bounded`; all 203 architecture cases, +including the new admission controls, pass. This pre-existing test races an +80 ms cache deadline against a 600 ms factory delay. It now holds the factory +behind an explicit release signal until the cache's own timeout is observed, +checks that a replacement waits and retains the late-result/cache assertions. +The bounded test wait cannot masquerade as the cache timeout: its message must +identify the cache's own deadline. The release is guaranteed in `finally`. +Production cache behavior and its budget are unchanged. `32a7951` delivers +this test-only fix. Fresh GPT-6.1-sol (high) and GPT-6-astra (high) reviews approve +in two separate rounds with no findings; the second reviewer independently +passes the focused Release case. All 51 cache cases and all 1,705 unit cases +pass with zero skips. Replacement final-head CI remains required by the rollup +below; the initial failed job is not counted as passing evidence. + +**Other finding dispositions.** The gzip adapter equality checks wiring; the +independent Script observer checks source-window coverage, not a second byte +measurement. Existing fast tests already cover report admission, source-byte +normalization, completion accounting and gatherer cleanup; full orchestration, +TLS refusal, real visibility and report-file checks remain real lifecycle/CI +proofs. The added helpers supplement those proofs rather than replacing them. +The review's zero-fast-coverage claim was therefore broader than the actual gap. + +The reported PR-body count/gzip mismatch compared different receipts. Downloaded +[run 38119659152](https://github.com/HodeTech/LearnStack/actions/runs/38119659152) +for source `aebefee`, tested merge `0bb897c`, verifies 2,964 backend and 1,514 +frontend passes, gzip 106,358–106,552 bytes. Historical `d47ef17` numbers above +remain correct for that earlier head; neither substitutes for final remediation +CI. The newer proof took 619,309 ms inside the accepted 720,000 ms deadline; +cleanup took 10,595 ms inside 30,000 ms. No deadline increase is justified. + +Ordinal complete-value matching and strict readable JSON are accepted G20 +behavior, so host case-folding and JSON-comment tolerance are not introduced. +Below-fold laid-out content remains valid full-page evidence; adding a viewport +bottom cutoff would reject legitimate long lessons. Bounded child ownership +records are retained through process-group/EOF confirmation; no sustained leak +was demonstrated. The older `public-local` exit behavior, dormant platform +branches and test-project placement are unchanged maintenance suggestions. +Census regeneration instructions already name the authoritative Seeder command. + +Mutable Action tags are pre-existing throughout CI, with no accepted SHA-pinning +policy. This patch does not introduce a repository-wide dependency policy; +[Security Standards](../standards/11-security.md#dependency-hygiene) already assign +update automation to Phase 11. Accepted ADR-0053/0056 baseline and dated notes +remain historical; their then-pending wording is not rewritten as a correction. +Current carriers link delivered P7 evidence instead. No new human/WCAG evidence +is claimed by these orchestration and proof changes. + +Closing documentation commits must also pass the seven required jobs before +merge; the [PR check rollup](https://github.com/HodeTech/LearnStack/pull/28/checks) +owns that moving-head result. Next after maintainer review and merge is +**P02b-0 re-verification and decision pass**, before Phase 02b implementation. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved @@ -5697,10 +6650,11 @@ scope, with explicit data-preservation obligations. The distinguish this shape from Phase 05's target hierarchy. - `Course` — tenant-owned, optionally organization-scoped, with independent - `draft` / `published` state under [ADR-0048](../decisions/0048-walking-skeleton-publication.md). + `draft` / `published` state and explicit content-access policy under + [ADR-0050](../decisions/0050-publication-and-course-content-access.md). Its title, summary and per-locale slug live in `course_translations`; its optional - level reference pins an exact taxonomy revision and band. G5's write validation and - unresolved-band presentation remain with their later packets. No versioning, + level reference pins an exact taxonomy revision and band. P02d-2/4 delivered + G5's write validation and bounded unresolved-band presentation. No versioning, programs or cohorts. - `Lesson` — ordered within a course; its title and per-locale slug likewise live in `lesson_translations`. Its body is drawn the way @@ -5881,11 +6835,10 @@ module's audit catalogue and registered in both composition roots. [Audit Coverage Standards](../standards/18-audit-coverage.md). The command set and re-run behaviour are **G11**; the Customization contract the lesson writer calls, and which revisions it may bind, are **G12**. -- **Tenancy commands** raise `tenancy.locale.write` (SHOULD) and `tenancy.setting.write` - (MUST), both `(planned)` today. The setting command lands only once **G17** is - answered, because no command writing `tenant_settings` precedes the `[PiiSensitive]` - decision ([Phase 03](phase-03-identity-admin.md)); its keys and value checks follow - **G16**, and it performs the contrast check +- **Tenancy commands**, delivered in P02d-2, raise `tenancy.locale.write` (SHOULD) + and `tenancy.setting.write` (MUST). Accepted **G17** supplies whole-value + `[PiiSensitive]` redaction before setting writes; keys and value checks follow + Accepted **G16**, including the contrast check [Accessibility Standards § Color and Contrast](../standards/16-accessibility.md#color-and-contrast) requires before a theme token is saved, with the outcome G16 records. - **The Education module spec**, `docs/modules/education/` (`README.md`, `audit.md`, @@ -5904,8 +6857,8 @@ module's audit catalogue and registered in both composition roots. announced context, never from a command field ([Security Standards § Forbidden](../standards/11-security.md#forbidden)), and a tenant-wide row can be updated only from a context that announces no organization. The - seeder announces its context today by calling `IUnitOfWork.SetTenantContextAsync` - itself, which ADR-0040's closed setter set does not list: **G15**. + seeder uses the accepted **G15** contextual composition and registered caller + fence; it does not directly call a tenant-context setter. Each tenant's **own** customization data — the table in § Genericity proof — is authored through the Customization module's commands, as Phase 02a's seed is, and presented as @@ -5949,8 +6902,8 @@ Obligations already imposed: P02d-3 delivered **G12**'s cache contract, the loader/correctness contract (**G22**) and internal display fallback (**G24**) under the -[accepted gate answers](#accepted-gate-answers). P02d-4 delivers public response -parts; page-state behavior remains P02d-6. +[accepted gate answers](#accepted-gate-answers). P02d-4 delivered public response +parts; P02d-6 delivered page-state behavior. **The typed settings accessor** over `tenant_settings`, which Phase 02a left to its first reader, is delivered in P02d-3. Under Row Level Security a `tenant_settings` @@ -6063,27 +7016,31 @@ The full path from [Phase 02a Packet 7](phase-02a-kernel-tenancy.md#delivery-record-packet-7), exercised for real — on the path a Server Component takes: -1. The browser's `Host` reaches Next.js. Both pages are Server Components, so the API - call is made by the Next.js server, and the `Host` the API sees is not the visitor's. -2. The server SDK states the visitor's host to the API over the trusted hop - ([ADR-0036 § Effective host and the trusted hop](../decisions/0036-tenant-resolution-trusted-inputs.md#effective-host-and-the-trusted-hop)), - whose § Consequences names `frontend/packages/sdk/src/server.ts` as the only frontend - place that sets the hop headers. +1. Native HTTPS ingress validates the browser's host, physical peer and request + target, strips authority carriers and signs the request-local provenance envelope. + Middleware verifies it before making the one live site bootstrap call. +2. The configured server caller in + `frontend/apps/web/src/server/configured-public-client.ts` states the verified + host/visitor to the API over the closed trusted hop under + [ADR-0053](../decisions/0053-trusted-public-server-rendering.md) and + [ADR-0054](../decisions/0054-bounded-public-renderer-admission.md). + The injected SDK remains transport-policy agnostic. 3. The API resolves that effective host through `platform_host_to_tenant` to a `(tenant_id, organization_id?)` pair, the singleton `ITenantContextAccessor` is written and the transient `ITenantContext` resolves from it on every access, the transaction sets `app.tenant_id` / `app.organization_id` with `SET LOCAL`, and Row Level Security filters every read. -4. Whatever the edge resolves is for rendering only, and any `X-Tenant-Id` is an - assertion +4. RSC consumes the verified immutable bootstrap snapshot for that request under + [ADR-0055](../decisions/0055-public-renderer-bootstrap-failures.md). Rendering + contributes no tenant authority; the API owns resolution and admission ([Frontend Architecture Standards § Tenant Resolution](../standards/07-frontend-architecture.md#tenant-resolution)). -5. In this phase's default topology the loopback bind, not the secret, is the boundary - (ADR-0036 § Consequences). +5. The private loopback hop and authenticated internal envelope preserve the + accepted local trust boundary; a stock Next process cannot mint admission. -None of that path is wired from the renderer's side yet — § What this phase inherits -lists the scaffolds this phase replaces. The topology and its evidence are **G33**, the -limiter in front of it **G34**, the SDK transport **G31** and **G35**, the middleware -and entry behaviour **G36**, and the rendering mode **G37**. +P02d-5/6 delivered this path and its real production HTML/RSC isolation proofs. +**G33**, **G34**, **G31**, **G35**, **G36** and **G37** are Accepted and implemented; +their packet records retain the decisions and test receipts. P02d-7 adds the +shared foreground lifecycle and real browser/audit enforcement. Two hosts are registered in local development, one per seed tenant: [Phase 02a Packet 7](phase-02a-kernel-tenancy.md#delivery-record-packet-7) wrote one @@ -6484,11 +7441,13 @@ otherwise string containing `</style>` — never reaches a rendered document, and a refused write commits neither a `tenant_settings` row nor an audit row. A key outside the contract, and a failing contrast pair, behave as G16 records — never a silent save. -- A branding key written as an organization-scoped `tenant_settings` row behaves as - **G16** (e) records, checked request-level through a host mapped to the organization: - refused at write, committing neither a `tenant_settings` row nor an audit row; stored - while the page served on that host is unchanged; or, if G16 (e) applies it, shown on - that host's page and on no other host's. +- Under **G16** (e), the unrouted branding command dispatched through the API + composition root in organization scope refuses the write. It changes no + `tenant_settings` row and commits no successful setting-write audit; the denied + attempt remains auditable under ADR-0033/0044. A real GET on the mapped + organization host returns the same tenant palette before and after refusal. + This combines the application-writer boundary with the shipped HTTP reader; + it does not introduce an HTTP authoring endpoint. - If **G19** includes a write-time rule, a lesson write holding a disallowed URL is refused with details naming the JSON pointer, and nothing is written. - Whatever **G18** refuses at save — which may include a presentation entry naming a @@ -6536,8 +7495,10 @@ otherwise - In one test, a course slug that exists only in tenant B returns `200` with that course on tenant B's host and `404` on tenant A's host, and a `(locale, slug)` held by one - course in each tenant returns each host's own course, compared by id. A status alone - proves nothing here: a route miss and an unknown host also answer `404` with the same + course in each tenant returns each host's own course, compared against its exact + seed-derived public payload. Stable source-side IDs select the expected course; + accepted public DTOs expose no ID. A status alone proves nothing here: a route miss + and an unknown host also answer `404` with the same `not_found` code, and the same route's `200` is what rules them out. - In the same test, a course returns `200` at its slug in a locale it is translated into and `404` for that slug under an enabled locale it has no translation for; a lesson @@ -6820,8 +7781,8 @@ otherwise handler's response type and neither has a handler. The mechanical control is accepted in **G28**: mandatory marked dispatch and structural guards complement the physical read-only barrier. P02d-4 Step 1 delivers the physical read-only barrier and host admission. - Endpoint dispatch/dependency guards remain with Step 2; the barrier covers only - enlisted transactions. + Steps 2–4 deliver endpoint dispatch/dependency and eligibility proofs; + the physical barrier covers only enlisted transactions. - **A missing marker looks like a resolver bug.** A host-only request to a type without `[PublicSurface]` answers the unknown-host `404` by design, so a public read shipped without its marker reads as a resolution or Row Level Security defect. The tempting diff --git a/docs/roadmap/phase-04-cms-media-pages.md b/docs/roadmap/phase-04-cms-media-pages.md index ce8e8184..e83c46fb 100644 --- a/docs/roadmap/phase-04-cms-media-pages.md +++ b/docs/roadmap/phase-04-cms-media-pages.md @@ -8,7 +8,9 @@ catalog pages, campaign pages, and tenant-defined page blocks. [Phase 02d](phase-02d-walking-skeleton.md) delivers the anonymous read API and trusted server transport through P02d-5. Catalog, course-detail/outline and lesson product -pages are delivered by P02d-6; the two-host browser/demo proof remains P02d-7. +pages are delivered by P02d-6; P02d-7 implements the two-host demo and narrow +browser/Lighthouse proof. Its [delivery record](phase-02d-walking-skeleton.md) +owns current CI enforcement and phase-exit evidence. Its bounded renderer uses fixed section names and ADR-0051's ordered plain-text profile. This phase adds an authored, versioned, localized content system that a tenant admin drives from Admin Studio, including the richer primitive field set. @@ -87,6 +89,15 @@ Also in scope: - `TenantContentType` JSON Schema editor in Admin Studio; the schema is validated as a schema on save, not merely stored. +- Authored field ordering and per-locale field-label editing under + [ADR-0051](../decisions/0051-ordered-text-card-presentation.md), preserving the + ordered `x-fields` contract and localized visible labels when extending the + plain-text profile to richer primitives. +- Customization deprecate and revise commands with their Studio lifecycle controls; + per-band taxonomy editing is delivered with [Phase 05](phase-05-education-learning-content.md#admin-studio-education-screens) + and consolidated by [Phase 06](phase-06-renderer-admin-studio.md#admin-studio--screen-ownership). + Existing exact revision pins remain readable; the revision workflow decision + below precedes any new mutation contract. - Built-in primitive field types composed by the JSON Schema: text, rich text, number, boolean, date/time, media reference, entry reference, select / multi-select, JSON / object. The set is closed and changes only with a LearnStack release. @@ -283,6 +294,10 @@ Storage and asset management: emit no protected bearer URL; issuance and retrieval require effective access. It does not claim an authenticated evaluator exists before Phase 07. The first active URL/markup sink also needs the shared safety contract named in ADR-0051. + This phase owns the tenant media-origin allow-policy: approved origin provenance, + permitted schemes/hosts and enforcement at authored URL validation and the first + active media sink. Its decision pass settles the policy before introducing that + sink; Phase 02d's inert plain-text fields authorize no media URL or markup. **This phase owns the media processing pipeline.** No phase currently does: [Media Pipeline](../architecture/16-media-pipeline.md) describes `IVideoTranscoder`, @@ -399,7 +414,10 @@ describes. a `LimitKey`. - `ITenantSearch` / `IPlatformSearch` with a PostgreSQL full-text implementation over content entries and pages, tenant-filtered inside the port. -- Admin Studio CMS screens per the list above. +- Admin Studio CMS screens per the list above, including field-order/localized-label + authoring and customization deprecate/revise controls. +- The tenant media-origin allow-policy and its authoring/runtime enforcement at + active media sinks. - Public read APIs for the renderer, versioned per [ADR-0024](../decisions/0024-api-versioning-policy.md). - CMS/Studio UI messages extend the ADR-0027 foundation owned by P02d-6, with @@ -445,6 +463,10 @@ describes. page block; a video asset produces an HLS master playlist, a poster and a thumbnail strip through the ffmpeg worker. - An SVG containing a `<script>` element is stored sanitised and served without it. +- Disallowed tenant media origins are refused at authoring and cannot load through + an active sink; allowed origins render under the same tenant's policy. +- Studio preserves authored field order and displays translated labels in each + supported locale; deprecating/revising a definition preserves existing exact pins. - Draft and published versions are separated, published pages never show draft content, and the preview flow works for admin users. - The same Studio editor and rendering pipeline serve both diff --git a/docs/roadmap/phase-05-education-learning-content.md b/docs/roadmap/phase-05-education-learning-content.md index 34c23230..f4c99d1b 100644 --- a/docs/roadmap/phase-05-education-learning-content.md +++ b/docs/roadmap/phase-05-education-learning-content.md @@ -58,21 +58,22 @@ content-access policy through version/module migration, decides its versioned ow and any policy-edit/reparent/preview behavior before those writers, and never infers public access from a new version or listing. This note claims no shipped column. -**Current delivery — 2026-10-09.** P02d-2 delivered the policy column, restricted +**Current delivery — 2026-10-11.** P02d-2 delivered the policy column, restricted backfill and writers; P02d-4 delivered eligible public reads. Preserve those contracts through this phase's version/module migration. P02d-6 delivers the -bounded renderer and product/manual proofs; P02d-7 still owns the browser/demo -harness. The [packet closeout](phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) -records current implementation evidence. +bounded renderer and product/manual proofs; P02d-7 implements the demo and narrow +browser/Lighthouse proof. The [Phase 02d delivery record](phase-02d-walking-skeleton.md) +owns current implementation, CI enforcement and phase-exit evidence. Phase 05 does not re-create these. Phase 02d's [delivery status](phase-02d-walking-skeleton.md#delivery-record-p02d-1) distinguishes accepted design from shipped implementation. This phase's decision pass designs the move from independent lessons referencing courses to course versions and modules, and from translated inline bodies to lesson items. It must preserve course and -lesson ids, published localized URLs, lesson order, organization scope, translated -bodies and exact content-type and taxonomy revision bindings. The migration and its -tests must carry existing rows forward; re-seeding is not a migration strategy. +lesson ids, published localized URLs, lesson order, tenant/organization scope, +translated bodies, exact content-type and taxonomy revision bindings and effective +content-access policy. Migration tests must carry existing rows forward; +re-seeding is not a migration strategy. Each change follows [Database Standards § Migrations](../standards/05-database.md#migrations). | Phase 02d baseline | Phase 05 adds | @@ -134,12 +135,13 @@ scoring and does not re-decide it. for the yoga studio, tracks for a coding bootcamp). A taxonomy key and an item key are lowercase under [ADR-0018's key rule](../decisions/0018-tenant-driven-customization-model.md#2026-09-04--customization-keys-and-item-keys-are-lowercase), - and a band's name is its `display_name`. Which key the yoga taxonomy uses is G14 in - [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register), - and the decision pass that closes it writes the key here. The taxonomy is data, not - code. The `Level` projection must preserve the exact revision and band referenced - by an existing course; a new Active taxonomy cannot silently relabel or remove that - stored binding. This phase's decision pass defines how the revision-aware projection + and a band's name is its `display_name`. The shipped Yoga seed uses + `yoga-difficulty` revision 1, with `foundation`, `developing` and `advanced` bands; + [SeedData](../../backend/src/LearnStack.Tools.Seeder/SeedData.cs) owns that + inventory. The taxonomy is tenant data. The `Level` projection preserves the exact + revision and band referenced by an existing course; a new Active taxonomy cannot + silently relabel or remove that stored binding. This phase's decision pass defines + how the revision-aware projection composes with the target `Level` identity. - Tag. - Instructor profile reference, with tenant-defined custom fields via @@ -163,6 +165,18 @@ scoring and does not re-decide it. Required capabilities: draft version, published version, version clone, change summary, publish validation. +### Post-seed authoring and lifecycle + +This phase owns the authenticated Course/Lesson authoring API and Studio surface, +including commands to edit content after seeding, reorder lessons, unpublish and +delete. Its decision pass settles allowed states, versioned owners, concurrency, +access-policy edits, reparenting and published-URL behavior before those writers. +The current ADR-0050 baseline permits draft-only insertions and independent +`draft → published` transitions; it supplies no published edit, reorder, unpublish +or delete operation. New lifecycle behavior must preserve the migration obligations +[above](#what-phase-02d-already-shipped), rather than treating a seed rerun as an edit +or migration. This handoff records future ownership without selecting that contract. + ### Learning structure - Module. @@ -367,8 +381,10 @@ cross-phase screen ownership table lives in **one** place — - Lesson editor. - Lesson item editor (schema-driven for both built-in and tenant-defined item types). - `TenantLessonItemType` editor (schema + player key). -- `TenantLevelTaxonomy` editor (items + metadata; the tenant admin declares CEFR / - difficulty / track / kyu-dan). +- `TenantLevelTaxonomy` editor with per-band editing of keys, ordering, localized + labels and metadata; the tenant admin declares CEFR / difficulty / track / + kyu-dan. It consumes [Phase 04](phase-04-cms-media-pages.md)'s customization + deprecate/revise lifecycle and preserves existing exact revision/band pins. - `TenantCompletionRule` editor — sandboxed DSL editor with live validation against the ADR-0025 engine. - Course publish flow. @@ -387,6 +403,9 @@ cross-phase screen ownership table lives in **one** place — - Education catalog API — programs, courses, versions, categories, levels, tags, SEO. - Versioned course structure with draft / publish / clone. - Lesson content management with lesson items across both registry tiers. +- Authenticated post-seed Course/Lesson edit, reorder, unpublish and delete commands + and Studio controls under the lifecycle contract settled in this phase. +- Per-band taxonomy editing over the versioned customization lifecycle. - Public catalog rendering data, extending the read endpoints [Phase 02d](phase-02d-walking-skeleton.md) shipped. - The customization runtime cost model implemented and **measured**: validation on the @@ -407,6 +426,11 @@ cross-phase screen ownership table lives in **one** place — complete, no scoring band is assigned, and the failure is audited. - An admin creates a course, adds modules and lessons, edits it as a draft, and publishes it; the published course detail is readable by the public site. +- Post-seed edit/reorder/unpublish/delete cases prove the allowed state transitions, + concurrency and refusal behavior chosen by the lifecycle decision; seed reruns + cannot substitute for those commands. +- Migration tests preserve the existing seed rows' stable IDs, localized URLs, + scopes, lesson order, translated bodies, exact definition pins and access policy. - `CourseVersion` behaviour is covered by integration tests, including that an existing enrollment reference stays pinned to its version when a new version publishes. - Both seed tenants from [Phase 02a Packet 7](phase-02a-kernel-tenancy.md) — the English diff --git a/docs/roadmap/phase-06-renderer-admin-studio.md b/docs/roadmap/phase-06-renderer-admin-studio.md index 94f0f8c0..8def8216 100644 --- a/docs/roadmap/phase-06-renderer-admin-studio.md +++ b/docs/roadmap/phase-06-renderer-admin-studio.md @@ -9,8 +9,8 @@ learner-facing and instructor-facing screen after this phase is built into. [Phase 02d](phase-02d-walking-skeleton.md) delivers the public read API and trusted server transport through P02d-5. P02d-6 owns catalog, course-detail/outline and lesson pages, now implemented with product proofs and passing manual accessibility -smoke. P02d-7 owns the unbuilt browser/demo harness. This phase deepens -that bounded path with authored navigation, +smoke. P02d-7 implements the demo, narrow Playwright library browser proof and +Lighthouse audits. This phase deepens that bounded path with authored navigation, tenant error pages, complete block composition and editing surfaces. After this phase, LearnStack publishes a simple but real education website for a tenant, @@ -22,8 +22,12 @@ and a non-developer tenant admin can maintain it. ### What Phase 02d supplies -Rows identify implemented P02d-6 scope; its PR review and merge remain pending. Planned -P02d-7 work is not current behavior. +Rows identify P02d-6 scope merged through PR #27; its +[merge closeout](phase-02d-walking-skeleton.md#p02d-6-merge-and-closeout-2026-10-10) +records final verification. P02d-7's +[delivery record](phase-02d-walking-skeleton.md#p02d-7-step-3--browser-and-audit-proof-2026-10-11) +records the narrow browser/audit proof; the canonical Phase 02d closeout records +its CI enforcement and phase-exit state. | Phase 02d foundation | Phase 06 adds | |---|---| @@ -31,7 +35,7 @@ P02d-7 work is not current behavior. | Anonymous `(public)` catalog/course Server Component pages delivered P02d-6 Step 2; lesson page delivered Step 3 in [Phase 02d § Public renderer](phase-02d-walking-skeleton.md#public-renderer) | Navigation, SEO metadata beyond G40, tenant-authored error pages, redirect handling, full page composition | | ADR-0051 ordered plain-text lesson presentation, API projection delivered P02d-4 and bounded renderer delivered P02d-6 Step 3 | The complete two-tier block registry with safe-render placeholders | | Four-color public theme projection delivered P02d-4; safe document injection delivered P02d-6 Step 1 | The branding configuration surface, additional tokens and organization override | -| The `apps/web` Vitest suite [Phase 02d](phase-02d-walking-skeleton.md) extends | The browser-level end-to-end suite | +| The `apps/web` Vitest suite and P02d-7's pinned Playwright library/Lighthouse proof over nine seed-derived product URLs | The full Playwright Test project and direct axe suite across public, Studio and portal flows | **P02d-6 inheritance — Accepted 2026-10-09; Steps 1–4 implemented.** [ADR-0027](../decisions/0027-frontend-i18n.md) selects exact `next-intl` 4.14.9, @@ -45,9 +49,12 @@ uses plain same-host anchors, opaque signed-target pagination, safe four-color injection and local 307→404 navigation to `/{locale}/status/not-found`. The URL changes; known content-call failures are translated HTTP 200 noindex states. Tenant-authored errors, menus, media, expanded renderer composition and -organization overrides remain here. P02d-7 owns the initial browser/demo and -Lighthouse activation; this phase owns the full Playwright/axe suite and Phase 11 -owns web-vitals telemetry. Acceptance does not claim these consumers are shipped. +organization overrides remain here. P02d-7's Playwright 1.64.0 library proof and +Lighthouse 13.5.0 audits exercise the seeded production public pages; Lighthouse's +transitive axe audit is part of that bounded proof. This phase owns the full +`@playwright/test` project and direct axe suite, including authenticated Studio and +portal flows. [Phase 11](phase-11-production-hardening.md#observability) owns +web-vitals telemetry. These future consumers are not Phase 02d delivery. ### Public site renderer @@ -233,8 +240,9 @@ live class. **Phase 06 owns it.** ### End-to-end test stack — a carried dependency -P02d-7 owns the initial browser/demo harness; Phase 06 expands it into the full -browser/axe suite and depends on the ephemeral end-to-end stack working. +P02d-7 delivers a narrow Playwright library browser and Lighthouse harness through +the shared demo lifecycle. Phase 06 adds the full Playwright Test/axe suite and +retains the ephemeral end-to-end stack dependency. `infra/compose/e2e.yml` overlays the development stack with `volumes: !reset []`, which discards more than the named volumes: it also discards the **PostgreSQL init script** and the **SeaweedFS S3 identity file**. @@ -261,7 +269,8 @@ browser suite against a mis-provisioned stack is worse evidence than no suite. information-architecture pass. - The `(portal)` route-group shell and the instructor portal's first screens. - Shared UI package and the typed SDK over the full API surface. -- A browser-level end-to-end suite running against the fixed ephemeral stack. +- The full Playwright Test/axe end-to-end suite running against the fixed ephemeral + stack, extending the narrow P02d-7 public browser/audit proof. ## Completion Criteria diff --git a/docs/roadmap/phase-11-production-hardening.md b/docs/roadmap/phase-11-production-hardening.md index 1085bad5..96a290d7 100644 --- a/docs/roadmap/phase-11-production-hardening.md +++ b/docs/roadmap/phase-11-production-hardening.md @@ -401,7 +401,11 @@ bounds request *cost* once a request is inside. Neither substitutes for the othe - Database indexes. - Query review. -- Cache strategy. +- Cache strategy, including gateway/CDN response caching for public API responses + and rendered pages. This phase settles host/tenant/organization/locale and + eligibility partitioning, cache keys, validators and invalidation before enabling + it. The Phase 02d API and renderer retain `no-store`; the generation-keyed + definition cache does not authorize response or page caching. - CDN strategy for media and public assets. - Pagination enforcement. - Search indexing optimization. @@ -532,6 +536,8 @@ same `ILiveClassProvider`. - Backup and restore process with a completed drill. - Performance baseline against the budgets in [15-performance.md](../standards/15-performance.md). +- Gateway/CDN response-cache policy and isolation/freshness proofs before activation, + preserving the existing uncached public contract until that decision ships. - Live classroom operations checklist. - Release checklist and operational runbooks under `docs/runbooks/`. - ADR-0026 Accepted, and diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index 1e3eb8b7..66b4734f 100644 --- a/docs/standards/06-testing.md +++ b/docs/standards/06-testing.md @@ -6,6 +6,8 @@ Public-read additions derive from [ADR-0052](../decisions/0052-anonymous-public-read-boundary.md). Public-renderer controls derive from [ADR-0053](../decisions/0053-trusted-public-server-rendering.md) and [ADR-0054](../decisions/0054-bounded-public-renderer-admission.md). +The seeded browser/audit proof derives from +[ADR-0056](../decisions/0056-public-demo-browser-and-lighthouse-enforcement.md). Native admission, stock-browser fallback and HMR proof controls derive from [ADR-0055](../decisions/0055-public-renderer-bootstrap-failures.md). UI localization controls derive from [ADR-0027](../decisions/0027-frontend-i18n.md) @@ -47,7 +49,7 @@ We invest most at **unit + integration**. Architecture tests are zero-flake. E2E | Integration | `LearnStack.Tests.Integration` | xUnit + `WebApplicationFactory` (Docker-free host tests) and, from Packet 6, Testcontainers marked `[Trait("Requires","Docker")]` — CI runs the two halves in separate jobs by that trait | | Architecture | `LearnStack.Tests.Architecture` | xUnit with NetArchTest (Mono.Cecil), reflection and source scans | | API contract | `LearnStack.Tests.Contract` | The published OpenAPI document; the snapshot-diff gate activates in Phase 02d, per § API Contract Tests | -| End-to-end | none yet | Playwright, per § End-to-End Tests below. No project exists; the owning phase is named there | +| End-to-end | Shared demo lifecycle (P02d-7) | Playwright library + Lighthouse; Phase 06 owns the broader golden-flow suite, per § End-to-End Tests | ### Unit Tests @@ -82,10 +84,12 @@ belongs to is decided by what it needs, not by what it is about: a Testcontainers PostgreSQL. **Packet 7** introduces the population, because it is the only shape in which the real middleware chain, the real EF query filters and the real RLS policies run against one another in one request, connected as - `learnstack_app`. It drives no production endpoint — Packet 7 ships none — but a - test-only controller the fixture registers, which is what `IdempotencyFixture` - already does for `/api/v1/sideeffectprobe`. These live under `Database/` with the - rest of the Docker-bound suite and carry the same trait. + `learnstack_app`. Packet 7 originally drives test-only controllers because it + ships no production endpoint. P02d-4 adds actual public Education GET/HEAD cases + in `PublicEducationHttpTests`, including mapped-host scope and uniform hidden + resources. Test-only probes remain separate from the production endpoint set. + These live under `Database/` with the rest of the Docker-bound suite and carry + the same trait. **Where a Docker-bound test lives, and what it takes, is what routes it.** `Every_Database_Test_Carries_The_Docker_Trait` scans the whole of @@ -134,28 +138,48 @@ and were read as running. ### End-to-End Tests -**None exist, and the project this section used to name never did.** Driving the -API through `WebApplicationFactory` is what the *host tests* above do; that is -not end-to-end, and listing it as a separate suite made a phantom look shipped. - -End-to-end here means a browser: Playwright over a running stack, per the -frontend tooling table below. - -- Cover the MVP vertical slice (tenant → page → course → enrollment → live - session). -- ~10 high-signal scenarios. -- **Owner:** the first flow arrives in - [Phase 06](../roadmap/phase-06-renderer-admin-studio.md), which also wires the - axe accessibility checks that run through the same harness. - [Phase 02d](../roadmap/phase-02d-walking-skeleton.md) renders the first tenant - surfaces, puts two tenants in a browser and gates on a human opening them, not on a - Playwright run. - -P02d-5's required integration suite proves two-host production HTML/RSC transport -against the real API, using explicitly test-owned pages. G33's local topology is -delivered; its browser/TLS evidence remains P02d-7's G45 obligation. P02d-6 owns -G38(c)'s product-page cases. P02d-7 selects the demo/browser harness and Lighthouse -assertions after P6; Phase 06 retains the broader Playwright/axe golden-flow suite. +P02d-7 Step 3 implements the narrow seeded catalog → course → lesson browser +proof over the running production API/native renderer. Its +[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-3--browser-and-audit-proof-2026-10-11) +records the passing local nine-page walkthrough and 27 Lighthouse audits; +both independent Step 3 reviews are complete. The packet's Step 4 record owns +real CI receipts and required-check activation; local proof alone does not establish +activation. The proof uses Playwright's library and managed Chromium, without a +separate Playwright Test project. +It supplements G33's human walkthrough and preserves P6's recorded manual smoke; +it does not replace either with automated accessibility scores. + +Phase 06 owns the broader MVP vertical slice (tenant → page → course → enrollment +→ live session), approximately ten high-signal golden flows and the full +Playwright/axe harness. P02d-5/6's real-API HTML/RSC integration cases remain +separate from browser evidence. P02d-7's proof adds actual linked navigation, +computed themes, ordered fields, TLS refusal controls and bounded audits over +unchanged seeded product routes. + +Run the shared proof from a fresh checkout without root `.env`, web `.env.local` +or `artifacts/public-demo`, with Docker and the documented toolchain available: + +```bash +(cd frontend && pnpm install --frozen-lockfile) +(cd frontend && pnpm exec playwright install --with-deps chromium) +make demo DEMO_MODE=ci +``` + +The lifecycle provisions a unique tmpfs-backed Compose project, generates private +disposable TLS/configuration, seeds through normal commands and stops its Keycloak +before browser navigation. Fixed application/Compose ports must be free. Review +the sanitized `artifacts/public-demo/receipt.json`, all 27 JSON/HTML report pairs +and browser screenshots; proof and owned cleanup must both pass. Existing artifacts +must be moved aside before a repeat run. Private environments, certificates, keys, +browser profiles and raw process logs are excluded from artifacts. Human foreground +`make demo` retains manual hosts/CA/leaf setup and persistent local data. The +[local test workflow](../../.claude/skills/run-tests-locally/SKILL.md#public-demo-managed-browser-and-lighthouse) +owns the complete setup and artifact instructions. + +The frontend workspace pins Playwright and Lighthouse as development tools. +Lighthouse's transitive development `axe-core@4.14.0` does not supply a standalone +application axe dependency or the full Phase 06 axe suite. There is no +`pnpm test:e2e`, `pnpm test:a11y` or `@playwright/test` project. ### Public renderer fixture ownership @@ -183,9 +207,9 @@ or Lighthouse delivery. | Unit (logic) | Vitest | | Component | Testing Library + Vitest | | Server actions | Vitest + Next.js test helpers | -| E2E | Playwright | +| E2E | P02d-7: Playwright library; Phase 06: broader Playwright Test harness | | Visual regression | Chromatic / Playwright snapshot | -| Accessibility | axe-core via Playwright | +| Accessibility | P02d-7: Lighthouse automated score; Phase 06: full axe-core via Playwright suite | Rules: - Components with non-trivial state have unit tests. @@ -239,29 +263,33 @@ offenders. Canonical names/statuses live in the not mark them Implemented. Manual keyboard, focus, 320 CSS px reflow/zoom, long strings, contrast and real screen-reader smoke evidence must name commit, environment, both hosts/locales and assistive technology. If pending, P6 completion -is not claimed. Full Playwright/axe remains Phase 06; demo/browser/Lighthouse remains -P02d-7. G38(a)'s transport proofs and G38(d)'s guarded runner are already delivered. +is not claimed. Full Playwright Test/axe remains Phase 06; the narrow seeded +demo/browser/Lighthouse proof is delivered by P02d-7. G38(a)'s transport proofs and +G38(d)'s guarded runner are already delivered. ## Tenant Isolation Tests **Mandatory** for every PR touching tenant-owned data: -```csharp -public sealed class CourseTenantIsolationTests : IntegrationTestBase -{ - [Fact] - public async Task User_in_tenant_A_cannot_see_courses_in_tenant_B() - { - var (tenantA, tenantB) = await SeedTwoTenantsAsync(); - var courseInB = await CreateCourseAsync(tenantB); - - var response = await Client.As(tenantA.AdminUser) - .GetAsync($"/api/v1/courses/{courseInB.Id}"); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } -} -``` +Use the shipped examples: + +- [PublicEducationHttpTests](../../backend/tests/LearnStack.Tests.Integration/Database/PublicEducationHttpTests.cs) + resolves actual mapped hosts and asserts exact-locale public catalog/detail + behavior, foreign/draft/restricted hiding and sibling-organization refusal. + `Course_draft_branch_foreign_and_unknown_are_uniformly_hidden` and + `Organization_host_never_serves_a_sibling_organization` exercise the production + public GET/HEAD contract. +- [EducationIsolationTests](../../backend/tests/LearnStack.Tests.Integration/Database/EducationIsolationTests.cs) + independently tests raw app-role RLS and EF-filter behavior on all four Education + tables, including unresolved scope, insert scope, reporting reads versus writes, + immutable organizations and parent constraints. + +Fixtures apply every chain through +[MigrationChains](../../backend/tests/LearnStack.Tests.Integration/Database/MigrationChains.cs), +which owns current chain/history discovery. The current schema has five chains, +21 data tables and five history tables; tests must not copy a fixed partial chain +list. Follow [add-integration-test](../../.claude/skills/add-integration-test/SKILL.md) +for fixture selection and transaction-scoped context. Rules: - Every module owning tenant-scoped data has at least one such test. @@ -338,8 +366,12 @@ Real LiveKit is **not** required in CI; a fake provider satisfies the contract. `backend` job. 4. The Docker-bound integration tests — the `backend-integration` job. 5. Frontend unit and component tests — the `frontend` job. -6. E2E tests — from [Phase 06](../roadmap/phase-06-renderer-admin-studio.md), per - § End-to-End Tests; allowed to retry on infra flake, with logged justification. +6. Narrow seeded browser/Lighthouse proof — the `public demo + lighthouse` job + invokes the shared `make demo DEMO_MODE=ci` lifecycle, per § End-to-End Tests. + It retains real admission budgets and never retries a 429 or audits a refusal + page. The packet's Step 4 record owns the real PR receipt and seventh required + check activation. Phase 06 owns the broader Playwright Test/axe golden flows; + any infrastructure-flake retry there requires logged justification. 7. Coverage report — none yet, per § Coverage Targets. A failure in any step that runs fails the pull request. Which checks block a merge is a diff --git a/docs/standards/12-infrastructure.md b/docs/standards/12-infrastructure.md index e5e1061a..ffd25ae5 100644 --- a/docs/standards/12-infrastructure.md +++ b/docs/standards/12-infrastructure.md @@ -93,8 +93,11 @@ P02d-5 delivers G33's loopback API/native TLS web ingress and paired private loc configuration under [ADR-0053](../decisions/0053-trusted-public-server-rendering.md). Follow the [local setup](../roadmap/phase-02d-walking-skeleton.md#local-setup-and-ingress-details) for explicit hosts and certificate trust preparation; neither is installed by scripts. -P02d-7 owns G45's `make demo` lifecycle and browser/TLS evidence, plus the remaining -G44 Lighthouse harness/assertions after P6. APISIX remains Phase 11. +P02d-7 delivers G45's `make demo` lifecycle, browser/TLS evidence and G44's +Lighthouse harness/assertions under +[ADR-0056](../decisions/0056-public-demo-browser-and-lighthouse-enforcement.md). +The [Step 4 record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) +owns enforcement and phase-exit verification. APISIX remains Phase 11. ### Healthchecks and the readiness gate diff --git a/docs/standards/15-performance.md b/docs/standards/15-performance.md index d17fa927..ec90ffa9 100644 --- a/docs/standards/15-performance.md +++ b/docs/standards/15-performance.md @@ -1,6 +1,6 @@ # 15 — Performance Standards -**Status:** Adopted +**Status:** Active **Derives from:** [ADR 0002 — Initial Architecture](../decisions/0002-initial-architecture.md) (initial budgets in [04-technical-architecture.md § Performance Budgets](../architecture/04-technical-architecture.md)), [ADR 0005 — Live Classroom Media Stack](../decisions/0005-live-classroom-media-stack.md) (classroom join + bandwidth budgets). Public-read additions derive from [ADR-0052](../decisions/0052-anonymous-public-read-boundary.md); P02d-5's bounded @@ -9,6 +9,22 @@ caller and dynamic/no-store policy derive from Performance budgets, the rules that keep them, and the test discipline that protects them. +**P02d-7 measurement decision — Accepted 2026-10-11.** +[ADR-0056](../decisions/0056-public-demo-browser-and-lighthouse-enforcement.md) +defines the browser/Lighthouse measurement contract and hard/advisory split. +Its normalized gzip definition covers same-/cross-origin Script responses from +cold navigation through collection completion, including initial async/lazy +loads and original-document inline executable bodies. It excludes inert data, +evaluation-generated code and scripts requested after that window or by later +interaction. This can be stricter than entry-chunk/build-time accounting; it is +not merely a unit clarification. P7 delivers the always-running +`public demo + lighthouse` job and its fail-closed browser/report/source controls. +The hard gzip/CLS/automated-accessibility checks make this standard Active for +that delivered scope. Timing medians remain advisory; production baselines, +field telemetry and load tests remain Phase 11. The +[Step 4 record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) +owns real CI receipts and bounded required-check activation. + ## Initial Budgets | Surface | Budget | @@ -98,7 +114,8 @@ owns that boundary under ADR-0052. ### Bundle Size -- Initial JS payload on a public route: < 200 KB gzipped. +- Initial JS payload on a public route: < 200 decimal KB (`< 200000` bytes), + measured using [ADR-0056's normalized source scope](../decisions/0056-public-demo-browser-and-lighthouse-enforcement.md#normalized-initial-javascript-gzip-size). - Studio routes may exceed this; budget reviewed per route. - Lazy load below-the-fold blocks and modals. - Code-split heavy client libraries (rich-text editor, video player) via `dynamic(...)`. diff --git a/docs/standards/16-accessibility.md b/docs/standards/16-accessibility.md index f60ebfa0..38b2e0e6 100644 --- a/docs/standards/16-accessibility.md +++ b/docs/standards/16-accessibility.md @@ -98,8 +98,11 @@ LearnStack is an education platform; learners with disabilities are a first-clas - `eslint-plugin-jsx-a11y` in the frontend lint config. - Phase 06 integrates `axe-core` with Playwright for public-renderer and portal critical flows. -- P02d-7 owns Lighthouse activation and the accessibility assertions for public - routes; the CI placeholder remains disabled. +- P02d-7 delivers the narrow managed-browser walkthrough and hard Lighthouse + automated accessibility score for nine seeded pages under + [ADR-0056](../decisions/0056-public-demo-browser-and-lighthouse-enforcement.md). + The [Step 4 record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) + owns CI and required-check receipts; this supplements the scoped P6 human record. - Manual keyboard walkthroughs for new screens in the PR review. **P02d-6 G43 enforcement — Step 3, 2026-10-10.** All 31 active recommended diff --git a/docs/standards/17-code-review.md b/docs/standards/17-code-review.md index 9ff8a4f0..6b23ba6c 100644 --- a/docs/standards/17-code-review.md +++ b/docs/standards/17-code-review.md @@ -2,6 +2,9 @@ **Status:** Active **Derives from:** [ADR 0003 — Tenant Isolation Defense in Depth](../decisions/0003-tenant-isolation-defense-in-depth.md), [ADR 0010 — Cross-Module Communication](../decisions/0010-cross-module-communication.md), [ADR 0041 — Correcting False Statements in Accepted ADRs](../decisions/0041-correcting-false-statements-in-accepted-adrs.md) (every zero-tolerance blocker maps back to one of these three). +Public accessibility enforcement additionally derives from +[ADR-0027](../decisions/0027-frontend-i18n.md) and +[ADR-0056](../decisions/0056-public-demo-browser-and-lighthouse-enforcement.md). How LearnStack reviews pull requests. The goal is faster, safer ship — not gatekeeping. @@ -65,16 +68,20 @@ Before requesting reviews, run the relevant checklist on your own diff: ([Security Standards § Multi-Tenant + Organization Isolation Review Checklist](11-security.md#multi-tenant--organization-isolation-review-checklist)). - [ ] New forms use React Hook Form + Zod and render errors at the field level. - [ ] New routes have `error.tsx` and `loading.tsx` where appropriate. -- [ ] Accessibility: every interactive element keyboard-reachable; labels associated; color contrast checked; axe-core tests pass where a suite runs them — none does yet (see the note below). -- [ ] Bundle size delta acceptable on public routes (< 200 KB gzipped per route). - -> **Open in Phase 02d.** No axe-core suite exists in `frontend/` today, and -> [Testing Standards § End-to-End Tests](06-testing.md#end-to-end-tests) assigns the -> Playwright axe run to [Phase 06](../roadmap/phase-06-renderer-admin-studio.md). Which -> accessibility checks fail a build on Phase 02d's public pages — route tests, -> `jsx-a11y` at error severity or component axe — is G43 in -> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), -> and the pass that closes it edits the accessibility box with its answer. +- [ ] Accessibility: keyboard reachability, labels and contrast reviewed; blocking + JSX accessibility checks and the public browser/Lighthouse proof pass where + applicable. Preserve the scoped manual evidence described below. +- [ ] Public-route JavaScript meets the normalized gzip budget and measurement + scope in [Performance Standards](15-performance.md#frontend-rules). + +> **Accepted G43 enforcement is delivered.** P02d-6 supplies blocking `jsx-a11y` +> rules with planted controls. P02d-7's shared browser/Lighthouse proof enforces +> automated accessibility score 100 through Lighthouse's transitive axe audits; +> it does not provide the full Playwright Test/axe suite owned by +> [Phase 06](../roadmap/phase-06-renderer-admin-studio.md). +> [Testing Standards § End-to-End Tests](06-testing.md#end-to-end-tests) separates +> these automated checks from the preserved keyboard, focus, reflow, calculated +> contrast and maintainer VoiceOver smoke evidence. Neither is a full WCAG audit. If a checkbox doesn't apply to your diff, omit it. If you cannot tick a checkbox, fix the gap before requesting review. diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 243d8ee0..2c03a25b 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -8,7 +8,9 @@ [ADR-0027 Frontend UI Localization](../decisions/0027-frontend-i18n.md) (P02d-6 proofs implemented; manual smoke recorded), [ADR-0055 Public Renderer Bootstrap Failures](../decisions/0055-public-renderer-bootstrap-failures.md) -(replacement admission proofs implemented; both review rounds complete). The +(replacement admission proofs implemented; both review rounds complete), +[ADR-0056 Public Demo Browser and Lighthouse Enforcement](../decisions/0056-public-demo-browser-and-lighthouse-enforcement.md) +(accepted 2026-10-11; browser/audit replacement proofs implemented). The catalogue grows as subsequent ADRs and phases land their tests; per-test ownership stays with the originating ADR / standard. @@ -101,7 +103,7 @@ not implemented is the failure mode this column exists to prevent. ### Implemented today -**154 test methods run in +**159 test methods run in [`backend/tests/LearnStack.Tests.Architecture`](../../backend/tests/LearnStack.Tests.Architecture),** shipped by [Phase 01](../roadmap/phase-01-repository-tooling.md), [Phase 02a Packets 2–3](../roadmap/phase-02a-kernel-tenancy.md), Packet 4, Packet 6, Packet 7, @@ -132,8 +134,8 @@ two fifths of its subject is the defect this section is about. It also refuses a test class that exists nowhere, because otherwise a renamed or deleted file drops its entries out of the subject instead of failing. -**170 rules in this catalogue are Implemented, and 107 of them are in that assembly.** -The other 63 are no less binding, and most could not live there. The table says where +**175 rules in this catalogue are Implemented, and 108 of them are in that assembly.** +The other 67 are no less binding, and most could not live there. The table says where and why, and deliberately carries no per-row count: those are the numbers nothing recomputes, and the first version of this table claimed "three rules" for a suite that holds ten. @@ -644,15 +646,63 @@ otherwise). not a second hard-coded list. Its source reader must observe the declared values and fail on an incomplete or unreadable declaration rather than report clean. - **Source:** [Phase 02d G20](../roadmap/phase-02d-walking-skeleton.md#p02d-2-accepted-answers). -- **Scope:** G20(a)'s source is decided in P02d-2. Production subjects, matching, - built-in exclusions and permitted exemptions remain G20(b/c)'s later decisions; - no exemption or exhaustive consumer scope is accepted by this registration. -- **Type:** xUnit + source scan, with planted offenders and allowed-data controls. +- **Scope:** P02d-2 accepted G20(a)'s source; + [P02d-5](../roadmap/phase-02d-walking-skeleton.md#p02d-5-accepted-answers) + accepted bounded seed/development/test exemptions, and + [P02d-6](../roadmap/phase-02d-walking-skeleton.md#p02d-6-accepted-answers) + accepted production frontend TS/JS and JSON subjects, including renderer and + message values. Matching/built-in detail, the Seeder-reference fence and + behavioral ownership are in the + [P7 package](../roadmap/phase-02d-walking-skeleton.md#p02d-7-genericity-enforcement), + approved on 2026-10-11; Step 2 implements the exact-literal/reference fences. + ADR-0056's separate + browser/Lighthouse measurement decision and its implemented controls complement + this source fence; neither proves all dynamically assembled tenant comparisons. +- **Type:** Roslyn C#/embedded-JSON scan, TypeScript AST/JSON scan, compiled and + restore-resolved project references, with planted offenders and clean controls. **Kind:** structural. -- **Status:** **Registered** — P02d-2 establishes and verifies the literal source - (`SeederConventionTests.Seed_Literal_Source_Is_Complete_And_Readable`); - P02d-5/6 settle remaining scope before their subjects ship. Implement the complete - guard and its planted companion by P02d-7 exit, with no vacuous pass. +- **Status:** **Implemented** — `DomainGenericityTests.cs` (partial source in + `DomainGenericityTests.DemoIdentities.cs`) and frontend + `demo-genericity.test.ts`/`demo-genericity-analysis.ts`. `DemoIdentities.Create` + projects categorized values from typed `SeedData`; the architecture suite + verifies category coverage and exact frontend fixture drift. Canonical built-ins + come from their owning declarations. Production census includes Seeder + orchestration, frontend packages/messages/configuration/native code; only the + exact seed declaration and named test/development data are exempt. Complete + decoded values match ordinally; GUIDs match by value. C# conditional symbol + configurations are parsed independently to a fixed point (at most eight symbols; + excessive or unreadable configurations fail). UTF-8 literals and emitted JSX + text/attribute values are included. Frontend + runtime imports cannot hide helpers behind test filenames or directories; + exact standalone proof/development entries retain their bounded exemptions. + Generated/dependency roots are exact paths. Known environment/OS/editor/log + files are omitted only when Git also ignores them; source and unknown formats + retain fail-closed admission. Local module URL suffixes, + supported tsconfig aliases, workspace exports, re-exports, import-equals and + literal `require.resolve` edges enter the closure. Ambiguous local JS/TS and alias + targets are conservatively included; only owned workspace manifests select + workspace exports, and duplicate identities refuse. Missing/unsupported local + edges and unclassified repository roots/source formats fail. Installed external + packages are resolved under the known dependency roots; local/link targets + enter the census or refuse. Native `.server` edges require the actual pinned + TypeScript output mapping; the known stylesheet is an explicit inert asset. + Backend `DomainGenericityTests.SourceCensus.cs` cross-checks the source set with + Release MSBuild-evaluated `Compile` inputs, `ProjectReference` paths and actual + output/intermediate roots. + Nested `bin`/`obj` remain authored source. Linked inputs outside the census and + unmodeled build properties, custom targets/imports/SDKs and references outside + the admitted production project set refuse rather than silently omit code. + Dirty/clean controls cover these admission boundaries. Computed runtime imports + fail rather than omit a helper. + The rule does not claim + to prove dynamically assembled identities. + `Production_Projects_Do_Not_Reference_The_Seeder` + reads every non-Seeder production assembly and restore graph. Its compiled + test edge and restored fixture are deliberate forbidden-reference controls. + The existing real API/PostgreSQL presentation proof retains original pins and + the unaffected tenant; `orderedLessonFields` is its shared behavioral predicate + with planted label/value/order/active-markup/language controls. Browser + observation of these fields remains P7 Step 3. - **Phase:** 02d (P02d-2 source; P02d-5/6 scope; P02d-7 implementation exit). #### `Frontend_Has_Only_The_Web_App` @@ -4455,6 +4505,68 @@ test-only instrumentation stays in the disposable copied app. `frontend/apps/web/scripts/verify-ingress.mjs`, with facade/runtime unit controls. - **Phase:** 02d (P02d-6 remediation Steps 1–3). +## P02d-7 public demo and Lighthouse controls + +These behavioral rules are Implemented under Accepted ADR-0056 by +`frontend/apps/web/scripts/verify-public-demo.mjs`, its contract/gatherer/worker +and the always-running `public demo + lighthouse` job through `make demo`. +`src/test/public-demo.test.ts` and `src/test/demo-lifecycle.test.ts` execute +planted failing and clean controls in the required frontend job. Actual browser +mutants, TLS refusals and a real Lighthouse timeout also run in the full proof. +`src/test/public-demo-ci.test.ts` executes the actual workflow deadline shell; +`scripts/ci-public-chromium.test.mjs` tests owned Linux sandbox preparation and +cleanup before the job runs the real managed-browser launch smoke. +The [Step 4 record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) +owns exact CI and protection receipts. These are Node behavioral rules, not +C# architecture-test methods or a full WCAG/production-performance claim. + +### `Public_Demo_Uses_Seeded_Production_Pages` + +- **Asserts:** the three seed-derived host/locale paths follow actual catalog, + course and lesson links in the same production build/database; expected + identity, ordered fields, computed themes and hidden-content exclusions hold. +- **Source:** [ADR-0056](../decisions/0056-public-demo-browser-and-lighthouse-enforcement.md). +- **Scope:** nine delivered pages; full Playwright/axe remains Phase 06. +- **Type:** Node browser proof with wrong-page/error-page controls. + **Kind:** behavioural. +- **Status:** **Implemented** — `verify-public-demo.mjs` browser mode and + `public-demo.test.ts` actual-page controls, executed by the jobs above. +- **Phase:** 02d (P02d-7 Step 3). + +### `Public_Lighthouse_Reports_Enforce_Budgets` + +- **Asserts:** all 27 current report pairs pass actual-page admission and exact + invocation/configuration census; hard budgets and advisory timing medians are + distinguished without permitting missing or invalid measurements. +- **Source:** [ADR-0056](../decisions/0056-public-demo-browser-and-lighthouse-enforcement.md#lab-profile-and-budgets). +- **Scope:** pinned Lighthouse/Chromium; every seed lane/URL/run. +- **Type:** Node report validation and real audits, with stale/missing/error and + budget-offender controls. **Kind:** behavioural. +- **Status:** **Implemented** — named Node proof and planted controls above. +- **Phase:** 02d (P02d-7 Step 3). + +### `Public_Initial_JavaScript_Gzip_Budget_Is_Measured` + +- **Asserts:** the cold-navigation Script response and executable-inline census + is complete; normalized level-9 gzip totals are strictly below 200000 bytes. +- **Source:** [ADR-0056](../decisions/0056-public-demo-browser-and-lighthouse-enforcement.md#normalized-initial-javascript-gzip-size). +- **Scope:** the ADR's collection window, origin coverage and exclusions. +- **Type:** Node source accounting, with omitted/empty/malformed/over-budget and + clean-source controls. **Kind:** behavioural. +- **Status:** **Implemented** — named Node proof and planted controls above. +- **Phase:** 02d (P02d-7 Step 3). + +### `Public_Demo_Preserves_Admission_And_Cleanup` + +- **Asserts:** real quotas, positive API observation, stopped CI-owned Keycloak, + scoped TLS trust, serial deadlines and confirmed owned cleanup remain intact. +- **Source:** [ADR-0056](../decisions/0056-public-demo-browser-and-lighthouse-enforcement.md#pacing-runtime-and-artifacts). +- **Scope:** the shared demo lifecycle and full-stack CI job. +- **Type:** Node lifecycle/proof controls and real production navigation. + **Kind:** behavioural. +- **Status:** **Implemented** — named Node proof and planted controls above. +- **Phase:** 02d (P02d-7 Steps 1, 3 and 4). + ## References - [ADR-0003 Tenant Isolation Defense in Depth](../decisions/0003-tenant-isolation-defense-in-depth.md) (Amendment 3) diff --git a/docs/standards/README.md b/docs/standards/README.md index 0d6dbf7e..211483d4 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -67,8 +67,8 @@ bookkeeping pass. ### Honest status today -The table below describes the implementation as of 2026-10-10: -[Phase 02a](../roadmap/phase-02a-kernel-tenancy.md) and P02d-1–5 are complete and +The table below describes the implementation as of 2026-10-11: +[Phase 02a](../roadmap/phase-02a-kernel-tenancy.md) and P02d-1–6 are complete and merged. P02d-5's ingress, configured server caller, dynamic entry and frontend guards are delivered through PR #26; the [merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09) @@ -81,13 +81,18 @@ loaders, chrome/states and atomic theme injection. Its owns foundation validation and review evidence. Steps 2–3 implement actual catalog/course/lesson views, pagination/metadata and blocking JSX accessibility controls. Step 4 implements automated product HTML/Flight proofs and completes -manual accessibility, including maintainer-reported VoiceOver. P7 owns +manual accessibility, including maintainer-reported VoiceOver. P7 delivers demo/browser/Lighthouse. ADR-0055 remediation delivers single-bootstrap admission and passing production replacement proofs. All three steps completed both independent review rounds. The [Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) owns completed Step 3 reviews, verified fixes and delivery evidence; its five rules -are Implemented. Acceptance alone promotes no standard or test status. +are Implemented. P6's +[merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-merge-and-closeout-2026-10-10) +records PR #27's merge and final verification. P7 delivers the shared demo, +G20 fences and seeded browser/Lighthouse enforcement. Its +[Step 4 record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) +owns review, real CI and protection receipts. Acceptance alone promotes no status. **The documents and this table say the same thing, and a test holds them to it.** [Phase 02a Packet 10](../roadmap/phase-02a-kernel-tenancy.md) reconciled the twenty-two @@ -112,9 +117,9 @@ included. | 11 | [Security](11-security.md) | **Active** | No authentication yet, and the isolation half of this document is live and mechanical. Row Level Security with the four roles and the isolation suite that runs as `learnstack_app`; the tenancy edge, the trusted-hop predicate and the anonymous rate limiter from Packet 4; and, since Packet 10, § The out-of-band setters is mechanical — every announcer of a session variable is one the table names and each reader opens its transaction read-only, with `App_Role_Cannot_Enumerate_Tenants`, `App_Role_Cannot_Enumerate_Host_Map` and `Tenant_A_Cannot_Repoint_Tenant_B_Host` proving the role cannot read or repoint what the policies bar. Authentication and authorisation land in [Phase 02b](../roadmap/phase-02b-events-auth.md) and [Phase 03](../roadmap/phase-03-identity-admin.md). Three sections are **not** enforced by anything today and are the document's own carve-out: § Transport, § HTTP Headers and § CORS — nothing sets HSTS, `nosniff`, a CSP or an origin policy, at the edge or in either app. [Phase 11 § Secure headers](../roadmap/phase-11-production-hardening.md) owns them, at APISIX and in the ASP.NET layer beside it. | | 12 | [Infrastructure](12-infrastructure.md) | **Active** | Compose stack, `Makefile`, CI workflow, pre-commit hooks and secret scanning all live since Phase 01. | | 13 | [Documentation](13-documentation.md) | **Active** | Governs this corpus; the CI link audit walks changed Markdown. | -| 14 | [Git Workflow](14-git-workflow.md) | **Active** | Conventional Commits, hooks and required checks are live. Two branch-protection settings — `Require approvals` and `Do not allow bypassing` — are **deferred by maintainer decision (2026-08-10)** while the repository has one active contributor; the trigger and what activating them involves are recorded in [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md). P02d-1 repaired and re-read the live required-check list on 2026-09-14: all five active jobs, including the current meta name and Docker integration, are required with GitHub Actions attribution and strict up-to-date checking. P02d-4 activates the sixth `openapi diff` context after a successful real job and verifies every other setting unchanged. [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) owns the current list; the [P02d-4 delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-4-step-4-contract-sdk-and-ci) records activation verification. | -| 15 | [Performance](15-performance.md) | **Adopted** | No budget is measured and no load test exists. Enforcement lands in [Phase 11](../roadmap/phase-11-production-hardening.md). Accepted P02d-5 G44 assigns Lighthouse activation to P02d-7 after P6 pages; P7 selects the harness and assertions. The job remains disabled. [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register) owns the decision; any status promotion lands with its enforcement. | -| 16 | [Accessibility](16-accessibility.md) | **Active** | P6 delivers catalog/course/lesson and controlled-state semantics, all 31 active recommended jsx-a11y rules at error severity with per-rule actual-config dirty/clean controls, and real product HTML/Flight proofs. The [packet closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) records keyboard/focus/reflow/contrast observations and the maintainer's passing VoiceOver smoke. This is enforcement for delivered public-page scope, not a full WCAG audit. Full Playwright/axe remains [Phase 06](../roadmap/phase-06-renderer-admin-studio.md); Accepted G44 assigns Lighthouse to P02d-7 after P6. | +| 14 | [Git Workflow](14-git-workflow.md) | **Active** | Conventional Commits, hooks and required checks are live. Two branch-protection settings — `Require approvals` and `Do not allow bypassing` — are **deferred by maintainer decision (2026-08-10)** while the repository has one active contributor; the trigger and what activating them involves are recorded in [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md). P02d-1 repaired and re-read the live required-check list on 2026-09-14: all five active jobs, including the current meta name and Docker integration, are required with GitHub Actions attribution and strict up-to-date checking. P02d-4 activates the sixth `openapi diff` context after a successful real job and verifies every other setting unchanged. P02d-7 activates the seventh `public demo + lighthouse` context after complete real PR success and verifies all six prior contexts, strict mode and every unrelated setting preserved; its [CI and exit closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-7-ci-and-phase-exit-closeout-2026-10-11) records the receipts. [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) owns the current list; the [P02d-4 delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-4-step-4-contract-sdk-and-ci) records activation verification. | +| 15 | [Performance](15-performance.md) | **Active** | P02d-7 delivers the always-running `public demo + lighthouse` job under ADR-0056: nine seeded pages, 27 audits, actual-page/source/census controls and hard normalized gzip/CLS/automated-accessibility checks. Timing medians are advisory. The [Step 4 record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) owns real CI and required-check receipts. Production p95/field telemetry/load tests remain [Phase 11](../roadmap/phase-11-production-hardening.md). | +| 16 | [Accessibility](16-accessibility.md) | **Active** | P6 delivers catalog/course/lesson and controlled-state semantics, all 31 active recommended jsx-a11y rules at error severity with per-rule actual-config dirty/clean controls, and real product HTML/Flight proofs. The [packet closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) records keyboard/focus/reflow/contrast observations and the maintainer's passing VoiceOver smoke. This is enforcement for delivered public-page scope, not a full WCAG audit. P02d-7 adds hard Lighthouse automated accessibility and a narrow library browser walkthrough; full Playwright Test/axe remains [Phase 06](../roadmap/phase-06-renderer-admin-studio.md). | | 17 | [Code Review](17-code-review.md) | **Active** | Applied to every pull request merged so far; the zero-tolerance blocker list is in live use. | | 18 | [Audit Coverage](18-audit-coverage.md) | **Active** | Packet 9 lit the write path under [ADR-0033](../decisions/0033-audit-durability-model.md) and [ADR-0044](../decisions/0044-audit-write-path.md): classification at step 3, MUST rows on the business transaction before `COMMIT`, `audit_log` / `audit_config` with their append-only layers, and the catalogue ↔ matrix join enforced in both directions and per request type by `AuditCoverageTests`. Retention and partitioning are [Phase 11](../roadmap/phase-11-production-hardening.md)'s. | | 19 | [Permissions](19-permissions.md) | **Adopted** | No permission key, policy or role exists. Lands in [Phase 03](../roadmap/phase-03-identity-admin.md). | @@ -131,12 +136,13 @@ Step 3 adds Education eligibility, scoped seek pages and bounded content/label projections with HTTP/database proof. Step 4 adds served/snapshot equality, recursive DTO guards, generated SDK/drift and breaking-policy CI controls. -Twenty `Active`, two `Adopted`. Packet 10 moved five: the frontend rules, the one-app -rule, the locale invariants, the out-of-band setters and the port bans are all mechanical -now, and a standard whose rules a test enforces is `Active` by this document's own -definition. P02d-6 promotes Accessibility with blocking lint/semantic controls and -recorded manual evidence. Performance and Permissions remain Adopted: no budget is -enforced and no permission key exists. Each names the phase that will promote it. +Twenty-one `Active`, one `Adopted`. Packet 10 moved five: the frontend rules, the one- +app rule, the locale invariants, the out-of-band setters and the port bans are all +mechanical now, and a standard whose rules a test enforces is `Active` by this +document's own definition. P02d-6 promotes Accessibility with blocking lint/semantic +controls and recorded manual evidence. P02d-7 promotes Performance with the hard checks +above; this does not make advisory or future production measurements enforced. +Permissions remains Adopted until Phase 03 ships permission keys and enforcement. ## Relationship to ADRs diff --git a/frontend/README.md b/frontend/README.md index c6045710..bb9263ae 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -27,7 +27,7 @@ There is **no `extensions/` folder** — ADR-0018 model is data, not code. ## Common Commands ```bash -pnpm install # install workspace deps +pnpm install --frozen-lockfile # install workspace deps pnpm --filter @learnstack/web dev # native HTTPS ingress; prepare TLS first pnpm lint # eslint across the workspace pnpm typecheck # tsc --noEmit across the workspace @@ -40,5 +40,32 @@ remain explicit developer steps. Start `make public-api` in another terminal. The native ingress serves the configured tenant hosts over HTTPS on port 3000. Direct stock `next dev/start` is unsupported. P6 implements localized public catalog/course/lesson pages; its [closeout](../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) -records completed reviews and passing manual accessibility. P7 owns the browser/demo -harness; P6 awaits maintainer PR review and merge. +records completed reviews and passing manual accessibility. P6 is complete and +merged through PR #27 on 2026-10-10. P7 delivers the shared demo and narrow +Playwright library/Lighthouse proof; Phase 06 owns the broader Playwright Test/axe +suite. Automated scores retain P6's separately attributed manual evidence. + +## Seeded browser and Lighthouse proof + +From a fresh checkout without root `.env`, web `.env.local` or +`artifacts/public-demo`, run: + +```bash +# from frontend/, after the locked dependency install above +pnpm exec playwright install --with-deps chromium +cd .. +make demo DEMO_MODE=ci +``` + +The shared lifecycle builds the production API/native renderer, seeds an isolated +tmpfs-backed Compose project, stops its Keycloak and runs nine linked product +pages plus 27 cold audits. Docker, .NET, Node, pnpm, OpenSSL and make are required; +fixed application/Compose ports must be free. It uses disposable private TLS and +certificate-specific browser trust without system hosts/trust changes. Review +`artifacts/public-demo/receipt.json`, all 27 JSON/HTML report pairs and browser +screenshots; proof and owned cleanup must both pass. Follow +[Running tests locally](../.claude/skills/run-tests-locally/SKILL.md#public-demo-managed-browser-and-lighthouse) +for the complete workflow. The packet's Step 4 record and +[branch protection](../.github/CONTRIBUTING.md#branch-protection-settings-on-main) +own real CI receipts and required-check activation; local success alone does not +establish activation. Human foreground `make demo` retains the manual HTTPS setup. diff --git a/frontend/apps/web/README.md b/frontend/apps/web/README.md index c1351016..1df84221 100644 --- a/frontend/apps/web/README.md +++ b/frontend/apps/web/README.md @@ -28,7 +28,15 @@ precedence. P02d-6 implements localized catalog/course/lesson pages, ordered plain-text presentation, safe four-color themes and G40 page states. Its [product closeout](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) records completed verification, reviews and scoped browser/VoiceOver observations. -P6 awaits maintainer PR review and merge. +P6 is complete and merged through PR #27 on 2026-10-10; its +[merge closeout](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-merge-and-closeout-2026-10-10) +owns final verification. P7's shared demo/browser package was approved on +2026-10-11, together with ADR-0056. The shared local/CI lifecycle and narrow +Playwright library proof are delivered: nine seeded pages and 27 Lighthouse audits. +The [Step 3 record](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-step-3--browser-and-audit-proof-2026-10-11) +owns local execution and reviews; the packet's Step 4 record and CONTRIBUTING own +real CI receipts and required-check activation. Phase 06 retains the broader +Playwright Test/axe suite. There is **no `extensions/` folder for vertical-provided components** — per [ADR-0018](../../../docs/decisions/0018-tenant-driven-customization-model.md), @@ -48,9 +56,8 @@ primitives or ask LearnStack to add a new composite. ```bash # from the repository root, after the documented hosts/certificate preparation -make public-env -make public-api # keep running in its own terminal -make public-web # run in a second terminal +make demo # production API + native web; Ctrl+C joins owned processes +# Optional separate development/watch terminals: make public-api / make public-web ``` Follow the root [HTTPS Quickstart](../../../README.md#quickstart) for dependencies, @@ -59,3 +66,22 @@ IPv4 loopback port 3000 and serves configured hosts over HTTPS. `/api/healthz` i the exact web health path. The paired API origin comes from private local configuration, never from the visitor's URL. Direct stock `next dev/start` does not establish trusted provenance and is unsupported. + +For the isolated browser/Lighthouse proof, use a fresh checkout without root +`.env`, web `.env.local` or `artifacts/public-demo`: + +```bash +(cd frontend && pnpm install --frozen-lockfile) +(cd frontend && pnpm exec playwright install --with-deps chromium) +make demo DEMO_MODE=ci +``` + +Run these commands from the repository root with Docker and the documented +toolchain available and fixed application/Compose ports free. CI mode generates +private disposable TLS/configuration, uses certificate-specific browser trust and +stops its Keycloak before the product proof. Review the sanitized +`artifacts/public-demo/receipt.json`, 27 JSON/HTML report pairs and browser +screenshots; proof and owned cleanup must both pass. See +[Running tests locally](../../../.claude/skills/run-tests-locally/SKILL.md#public-demo-managed-browser-and-lighthouse) +for artifact freshness and cleanup rules. Local success alone does not establish +required-check activation or a new manual accessibility pass. diff --git a/frontend/apps/web/scripts/public-demo-census.json b/frontend/apps/web/scripts/public-demo-census.json new file mode 100644 index 00000000..9e3944d8 --- /dev/null +++ b/frontend/apps/web/scripts/public-demo-census.json @@ -0,0 +1,719 @@ +{ + "versions": { + "lighthouse": "13.5.0", + "playwright": "1.64.0", + "chromiumRevision": "1248", + "browserVersion": "156.0.8078.4" + }, + "artifacts": [ + { + "id": "Accessibility" + }, + { + "id": "CSSUsage" + }, + { + "id": "DevtoolsLog" + }, + { + "id": "FullPageScreenshot" + }, + { + "id": "ImageElements" + }, + { + "id": "JsUsage" + }, + { + "id": "NetworkUserAgent", + "dependencies": { + "DevtoolsLog": { + "id": "DevtoolsLog" + } + } + }, + { + "id": "PublicDemoDocument" + }, + { + "id": "Scripts" + }, + { + "id": "SourceMaps", + "dependencies": { + "Scripts": { + "id": "Scripts" + } + } + }, + { + "id": "Stacks" + }, + { + "id": "Stylesheets" + }, + { + "id": "Trace" + }, + { + "id": "TraceElements", + "dependencies": { + "Trace": { + "id": "Trace" + }, + "SourceMaps": { + "id": "SourceMaps" + } + } + } + ], + "audits": [ + "accesskeys", + "aria-allowed-attr", + "aria-allowed-role", + "aria-command-name", + "aria-conditional-attr", + "aria-deprecated-role", + "aria-dialog-name", + "aria-hidden-body", + "aria-hidden-focus", + "aria-input-field-name", + "aria-meter-name", + "aria-progressbar-name", + "aria-prohibited-attr", + "aria-required-attr", + "aria-required-children", + "aria-required-parent", + "aria-roles", + "aria-text", + "aria-toggle-field-name", + "aria-tooltip-name", + "aria-treeitem-name", + "aria-valid-attr", + "aria-valid-attr-value", + "autocomplete-valid", + "bootup-time", + "button-name", + "bypass", + "cache-insight", + "cls-culprits-insight", + "color-contrast", + "cumulative-layout-shift", + "custom-controls-labels", + "custom-controls-roles", + "definition-list", + "diagnostics", + "dlitem", + "document-latency-insight", + "document-title", + "dom-size-insight", + "duplicate-id-aria", + "duplicated-javascript-insight", + "empty-heading", + "final-screenshot", + "first-contentful-paint", + "focus-traps", + "focusable-controls", + "font-display-insight", + "forced-reflow-insight", + "form-field-multiple-labels", + "frame-title", + "heading-order", + "html-has-lang", + "html-lang-valid", + "html-xml-lang-mismatch", + "identical-links-same-purpose", + "image-alt", + "image-delivery-insight", + "image-redundant-alt", + "initial-js-gzip-bytes", + "inp-breakdown-insight", + "input-button-name", + "input-image-alt", + "interactive", + "interactive-element-affordance", + "label", + "label-content-name-mismatch", + "landmark-one-main", + "largest-contentful-paint", + "layout-shifts", + "lcp-breakdown-insight", + "lcp-discovery-insight", + "legacy-javascript-insight", + "link-in-text-block", + "link-name", + "list", + "listitem", + "logical-tab-order", + "long-tasks", + "main-thread-tasks", + "mainthread-work-breakdown", + "managed-focus", + "max-potential-fid", + "meta-refresh", + "meta-viewport", + "metrics", + "modern-http-insight", + "network-dependency-tree-insight", + "network-requests", + "network-rtt", + "network-server-latency", + "non-composited-animations", + "object-alt", + "offscreen-content-hidden", + "presentation-role-conflict", + "redirects", + "render-blocking-insight", + "resource-summary", + "screenshot-thumbnails", + "script-treemap-data", + "select-name", + "server-response-time", + "skip-link", + "speed-index", + "svg-img-alt", + "tabindex", + "table-duplicate-name", + "table-fake-caption", + "target-size", + "td-has-header", + "td-headers-attr", + "th-has-data-cells", + "third-parties-insight", + "total-blocking-time", + "total-byte-weight", + "unminified-css", + "unminified-javascript", + "unsized-images", + "unused-css-rules", + "unused-javascript", + "use-landmarks", + "user-timings", + "valid-lang", + "video-caption", + "viewport-insight", + "visual-order-follows-dom" + ], + "categories": { + "performance": [ + { + "id": "first-contentful-paint", + "weight": 10 + }, + { + "id": "largest-contentful-paint", + "weight": 25 + }, + { + "id": "total-blocking-time", + "weight": 30 + }, + { + "id": "cumulative-layout-shift", + "weight": 25 + }, + { + "id": "speed-index", + "weight": 10 + }, + { + "id": "cache-insight", + "weight": 0 + }, + { + "id": "cls-culprits-insight", + "weight": 0 + }, + { + "id": "document-latency-insight", + "weight": 0 + }, + { + "id": "dom-size-insight", + "weight": 0 + }, + { + "id": "duplicated-javascript-insight", + "weight": 0 + }, + { + "id": "font-display-insight", + "weight": 0 + }, + { + "id": "forced-reflow-insight", + "weight": 0 + }, + { + "id": "image-delivery-insight", + "weight": 0 + }, + { + "id": "inp-breakdown-insight", + "weight": 0 + }, + { + "id": "lcp-breakdown-insight", + "weight": 0 + }, + { + "id": "lcp-discovery-insight", + "weight": 0 + }, + { + "id": "legacy-javascript-insight", + "weight": 0 + }, + { + "id": "modern-http-insight", + "weight": 0 + }, + { + "id": "network-dependency-tree-insight", + "weight": 0 + }, + { + "id": "render-blocking-insight", + "weight": 0 + }, + { + "id": "third-parties-insight", + "weight": 0 + }, + { + "id": "viewport-insight", + "weight": 0 + }, + { + "id": "interactive", + "weight": 0 + }, + { + "id": "max-potential-fid", + "weight": 0 + }, + { + "id": "unminified-css", + "weight": 0 + }, + { + "id": "unminified-javascript", + "weight": 0 + }, + { + "id": "unused-css-rules", + "weight": 0 + }, + { + "id": "unused-javascript", + "weight": 0 + }, + { + "id": "total-byte-weight", + "weight": 0 + }, + { + "id": "user-timings", + "weight": 0 + }, + { + "id": "bootup-time", + "weight": 0 + }, + { + "id": "mainthread-work-breakdown", + "weight": 0 + }, + { + "id": "long-tasks", + "weight": 0 + }, + { + "id": "non-composited-animations", + "weight": 0 + }, + { + "id": "unsized-images", + "weight": 0 + }, + { + "id": "network-requests", + "weight": 0 + }, + { + "id": "network-rtt", + "weight": 0 + }, + { + "id": "network-server-latency", + "weight": 0 + }, + { + "id": "main-thread-tasks", + "weight": 0 + }, + { + "id": "diagnostics", + "weight": 0 + }, + { + "id": "metrics", + "weight": 0 + }, + { + "id": "screenshot-thumbnails", + "weight": 0 + }, + { + "id": "final-screenshot", + "weight": 0 + }, + { + "id": "script-treemap-data", + "weight": 0 + }, + { + "id": "resource-summary", + "weight": 0 + }, + { + "id": "redirects", + "weight": 0 + }, + { + "id": "server-response-time", + "weight": 0 + }, + { + "id": "layout-shifts", + "weight": 0 + } + ], + "accessibility": [ + { + "id": "accesskeys", + "weight": 7 + }, + { + "id": "aria-allowed-attr", + "weight": 10 + }, + { + "id": "aria-command-name", + "weight": 7 + }, + { + "id": "aria-conditional-attr", + "weight": 7 + }, + { + "id": "aria-deprecated-role", + "weight": 1 + }, + { + "id": "aria-dialog-name", + "weight": 7 + }, + { + "id": "aria-hidden-body", + "weight": 10 + }, + { + "id": "aria-hidden-focus", + "weight": 7 + }, + { + "id": "aria-input-field-name", + "weight": 7 + }, + { + "id": "aria-meter-name", + "weight": 7 + }, + { + "id": "aria-progressbar-name", + "weight": 7 + }, + { + "id": "aria-prohibited-attr", + "weight": 7 + }, + { + "id": "aria-required-attr", + "weight": 10 + }, + { + "id": "aria-required-children", + "weight": 10 + }, + { + "id": "aria-required-parent", + "weight": 10 + }, + { + "id": "aria-roles", + "weight": 10 + }, + { + "id": "aria-text", + "weight": 7 + }, + { + "id": "aria-toggle-field-name", + "weight": 7 + }, + { + "id": "aria-tooltip-name", + "weight": 7 + }, + { + "id": "aria-treeitem-name", + "weight": 7 + }, + { + "id": "aria-valid-attr-value", + "weight": 10 + }, + { + "id": "aria-valid-attr", + "weight": 10 + }, + { + "id": "button-name", + "weight": 10 + }, + { + "id": "bypass", + "weight": 7 + }, + { + "id": "color-contrast", + "weight": 7 + }, + { + "id": "definition-list", + "weight": 7 + }, + { + "id": "dlitem", + "weight": 7 + }, + { + "id": "document-title", + "weight": 7 + }, + { + "id": "duplicate-id-aria", + "weight": 10 + }, + { + "id": "form-field-multiple-labels", + "weight": 3 + }, + { + "id": "frame-title", + "weight": 7 + }, + { + "id": "heading-order", + "weight": 3 + }, + { + "id": "html-has-lang", + "weight": 7 + }, + { + "id": "html-lang-valid", + "weight": 7 + }, + { + "id": "html-xml-lang-mismatch", + "weight": 3 + }, + { + "id": "image-alt", + "weight": 10 + }, + { + "id": "input-button-name", + "weight": 10 + }, + { + "id": "input-image-alt", + "weight": 10 + }, + { + "id": "label", + "weight": 10 + }, + { + "id": "link-in-text-block", + "weight": 7 + }, + { + "id": "link-name", + "weight": 7 + }, + { + "id": "list", + "weight": 7 + }, + { + "id": "listitem", + "weight": 7 + }, + { + "id": "meta-refresh", + "weight": 10 + }, + { + "id": "meta-viewport", + "weight": 10 + }, + { + "id": "object-alt", + "weight": 7 + }, + { + "id": "select-name", + "weight": 10 + }, + { + "id": "skip-link", + "weight": 3 + }, + { + "id": "tabindex", + "weight": 7 + }, + { + "id": "target-size", + "weight": 7 + }, + { + "id": "td-headers-attr", + "weight": 7 + }, + { + "id": "th-has-data-cells", + "weight": 7 + }, + { + "id": "valid-lang", + "weight": 7 + }, + { + "id": "video-caption", + "weight": 10 + }, + { + "id": "landmark-one-main", + "weight": 3 + }, + { + "id": "autocomplete-valid", + "weight": 1 + }, + { + "id": "presentation-role-conflict", + "weight": 1 + }, + { + "id": "svg-img-alt", + "weight": 1 + }, + { + "id": "focusable-controls", + "weight": 0 + }, + { + "id": "interactive-element-affordance", + "weight": 0 + }, + { + "id": "logical-tab-order", + "weight": 0 + }, + { + "id": "visual-order-follows-dom", + "weight": 0 + }, + { + "id": "focus-traps", + "weight": 0 + }, + { + "id": "managed-focus", + "weight": 0 + }, + { + "id": "use-landmarks", + "weight": 0 + }, + { + "id": "offscreen-content-hidden", + "weight": 0 + }, + { + "id": "custom-controls-labels", + "weight": 0 + }, + { + "id": "custom-controls-roles", + "weight": 0 + }, + { + "id": "table-duplicate-name", + "weight": 0 + }, + { + "id": "empty-heading", + "weight": 0 + }, + { + "id": "aria-allowed-role", + "weight": 0 + }, + { + "id": "image-redundant-alt", + "weight": 0 + }, + { + "id": "identical-links-same-purpose", + "weight": 0 + }, + { + "id": "label-content-name-mismatch", + "weight": 0 + }, + { + "id": "table-fake-caption", + "weight": 0 + }, + { + "id": "td-has-header", + "weight": 0 + } + ] + }, + "settings": { + "formFactor": "mobile", + "screenEmulation": { + "mobile": true, + "width": 412, + "height": 823, + "deviceScaleFactor": 1.75, + "disabled": false + }, + "throttlingMethod": "simulate", + "throttling": { + "rttMs": 150, + "throughputKbps": 1638.4, + "requestLatencyMs": 562.5, + "downloadThroughputKbps": 1474.56, + "uploadThroughputKbps": 675, + "cpuSlowdownMultiplier": 4 + }, + "disableStorageReset": false + } +} diff --git a/frontend/apps/web/scripts/public-demo-contract.d.mts b/frontend/apps/web/scripts/public-demo-contract.d.mts new file mode 100644 index 00000000..eaa16922 --- /dev/null +++ b/frontend/apps/web/scripts/public-demo-contract.d.mts @@ -0,0 +1,38 @@ +export function digest(value: string | Buffer): string; +export function productDomSnapshot(): unknown; +export function admitProduct( + document: unknown, + lane: unknown, + kind: string, + catalogTitle: string, +): void; +export function decodedSource(body: unknown): string; +export function normalizedJavaScript( + document: string, + inventory: unknown, +): { + scripts: Array<{ id: string; url: string; digest: string; bytes: number; gzip: number }>; + inline: { count: number; digest: string; bytes: number; gzip: number }; + total: number; +}; +export function assertConfiguration(actual: unknown, expected: unknown): void; +export function validateReport( + lhr: unknown, + expected: unknown, + census: unknown, +): Record<string, number>; +export function reportInventory(rows: unknown, expected: unknown): void; + +export function reconcileScriptInventory( + log: Array<{ + method: string; + params: { type: string; requestId: string; response: { url: string } }; + }>, + inventory: Array<{ id: string; url: string }>, +): void; +export function reportAdvisories( + reports: Array<{ url: string; metrics: Record<string, number> }>, +): Array<{ + url: string; + metrics: Record<string, { median: number; threshold: number; warning: boolean }>; +}>; diff --git a/frontend/apps/web/scripts/public-demo-contract.mjs b/frontend/apps/web/scripts/public-demo-contract.mjs new file mode 100644 index 00000000..bce0ca63 --- /dev/null +++ b/frontend/apps/web/scripts/public-demo-contract.mjs @@ -0,0 +1,324 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { gzipSync } from 'node:zlib'; + +import { absentFromWholeResponse, documentViewport } from './public-rendering-assertions.mjs'; + +const { JSDOM } = createRequire(import.meta.url)('jsdom'); +export const digest = (value) => createHash('sha256').update(value).digest('hex'); + +/** Serializable DOM observation, executed in the same target that was navigated/audited. */ +export function productDomSnapshot() { + const visible = (node) => { + if (!node || node.closest('[hidden],template')) return false; + const style = getComputedStyle(node); + const box = node.getBoundingClientRect(); + // These seeded pages have no paint-clipping/filter/mask containers. Refuse + // such styles rather than mistaking a laid-out, fully hidden marker for + // visible product identity. This is a bounded product gate, not a general + // CSS occlusion or visual-accessibility audit. + for (let ancestor = node; ancestor; ancestor = ancestor.parentElement) { + const parentStyle = getComputedStyle(ancestor); + if ( + parentStyle.clipPath !== 'none' || + parentStyle.clip !== 'auto' || + parentStyle.filter !== 'none' || + parentStyle.maskImage !== 'none' || + ['hidden', 'clip'].includes(parentStyle.overflowX) || + ['hidden', 'clip'].includes(parentStyle.overflowY) + ) + return false; + } + return ( + style.display !== 'none' && + style.visibility === 'visible' && + Number(style.opacity) !== 0 && + node.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) && + box.width > 0 && + box.height > 0 && + box.right > 0 && + box.left < document.documentElement.clientWidth && + box.bottom > 0 + ); + }; + const pick = (selector) => [...document.querySelectorAll(selector)].filter(visible); + const root = getComputedStyle(document.documentElement); + const muted = pick('.public-muted')[0]; + return { + url: location.href, + lang: document.documentElement.lang, + dir: document.documentElement.dir, + headings: pick('h1').map((node) => node.textContent), + institutions: pick('.public-institution').map((node) => node.textContent), + mains: pick('main').length, + states: document.querySelectorAll('.public-state').length, + noindex: [...document.querySelectorAll('meta[name="robots"]')].some((node) => + /noindex/i.test(node.content), + ), + kinds: ['catalog', 'course', 'lesson'].filter((kind) => pick('.public-' + kind).length === 1), + theme: Object.fromEntries( + ['primary', 'bg', 'fg', 'muted'].map((key) => [ + key, + root.getPropertyValue('--ls-' + key).trim(), + ]), + ), + foreground: getComputedStyle(document.body).color, + background: getComputedStyle(document.body).backgroundColor, + muted: muted ? getComputedStyle(muted).color : null, + fields: pick('.public-lesson-field').map((node) => { + const term = node.querySelector('dt'); + const value = node.querySelector('dd'); + return { + label: visible(term) ? term.textContent : undefined, + value: visible(value) ? value.textContent : undefined, + labelLang: term?.lang, + labelDir: term?.dir, + valueLang: value?.closest('[lang]')?.lang, + adjacent: term?.nextElementSibling === value, + children: (term?.childElementCount ?? -1) + (value?.childElementCount ?? -1), + }; + }), + links: pick('.public-course-card h2 a, .public-lesson-list a').map((node) => node.href), + }; +} + +const rgb = (hex) => + `rgb(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(', ')})`; + +export function admitProduct(document, lane, kind, catalogTitle) { + const url = lane[kind + 'Url']; + assert.equal(document.status, 200, 'Actual product document status'); + assert.match(document.headers['content-type'] ?? '', /^text\/html/i); + assert.match(document.headers['cache-control'] ?? '', /no-store/); + assert.equal(document.redirects, 0, 'Product navigation has no redirects'); + const { dom, html } = document; + assert.equal(dom.url, url, 'Actual displayed URL'); + assert.equal(dom.lang, lane.locale, 'Actual admitted content locale'); + assert.equal(dom.dir, 'ltr'); + assert.deepEqual( + dom.headings, + [kind === 'catalog' ? catalogTitle : lane[kind + 'Title']], + 'Visible seeded page identity', + ); + assert.deepEqual(dom.institutions, [lane.displayName], 'Visible institution identity'); + assert.equal(dom.mains, 1); + assert.equal(dom.states, 0, 'No controlled state can satisfy a product audit'); + assert.equal(dom.noindex, false, 'No refusal/fallback document'); + assert.deepEqual(dom.kinds, [kind]); + assert.deepEqual( + dom.theme, + { + primary: lane.theme.primary, + bg: lane.theme.background, + fg: lane.theme.foreground, + muted: lane.theme.muted, + }, + 'Computed complete palette', + ); + assert.equal(dom.foreground, rgb(lane.theme.foreground)); + assert.equal(dom.background, rgb(lane.theme.background)); + if (dom.muted !== null) assert.equal(dom.muted, rgb(lane.theme.muted)); + documentViewport(new JSDOM(html).window.document); + assert.ok(lane.excludedTitles.length > 0, 'Containment markers are nonempty'); + absentFromWholeResponse({ body: html }, lane.excludedTitles); + assert.ok(lane.excludedContent.length > 0, 'Protected content markers are nonempty'); + absentFromWholeResponse({ body: html }, lane.excludedContent); + if (kind === 'catalog') assert.ok(dom.links.includes(lane.courseUrl), 'Rendered course link'); + if (kind === 'course') assert.ok(dom.links.includes(lane.lessonUrl), 'Rendered lesson link'); + if (kind === 'lesson') + assert.deepEqual( + dom.fields, + lane.fields.map((field) => ({ + label: field.label, + value: field.value, + labelLang: lane.locale, + labelDir: 'ltr', + valueLang: lane.locale, + adjacent: true, + children: 0, + })), + 'Exact ordered authored lesson fields', + ); +} + +export function decodedSource(body) { + assert.equal(typeof body?.body, 'string', 'Complete CDP body is required'); + let text; + if (body.base64Encoded) { + assert.match( + body.body, + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/, + 'Canonical base64 source', + ); + const bytes = Buffer.from(body.body, 'base64'); + assert.equal(bytes.toString('base64'), body.body); + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } else text = body.body; + assert.ok(text.isWellFormed() && !text.includes('\ufffd'), 'Undecodable source is refused'); + assert.ok(Buffer.byteLength(text) <= 8 * 1024 * 1024, 'Source byte bound'); + return text; +} + +export function normalizedJavaScript(document, inventory) { + assert.ok( + Array.isArray(inventory) && inventory.length > 0 && inventory.length <= 1000, + 'Nonempty bounded Script census', + ); + const ids = new Set(); + const scripts = inventory.map((row) => { + assert.equal(ids.has(row.id), false, 'Each delivered response is counted once'); + ids.add(row.id); + assert.equal(row.status, 200, 'Every delivered Script response succeeds'); + assert.equal(row.complete, true, 'Truncated/incomplete source is refused'); + assert.ok(/^https?:\/\//.test(row.url), 'External source URL'); + const text = decodedSource(row.body); + assert.ok(text.length > 0, 'Unavailable source never counts as zero'); + return { + id: row.id, + url: row.url, + digest: digest(text), + bytes: Buffer.byteLength(text), + gzip: gzipSync(text, { level: 9 }).length, + }; + }); + const dom = new JSDOM(document, { includeNodeLocations: true }); + const bodies = [...dom.window.document.querySelectorAll('script')] + .filter((node) => { + const type = (node.getAttribute('type') ?? '').split(';')[0].trim().toLowerCase(); + return ( + !node.hasAttribute('src') && + (!type || + type === 'module' || + /^(?:application\/(?:x-)?(?:java|ecma)script|text\/(?:(?:x-)?(?:java|ecma)script|javascript1\.[0-5]|jscript|livescript))$/.test( + type, + )) + ); + }) + .map((node) => { + const location = dom.nodeLocation(node); + assert.ok(location?.startTag && location?.endTag, 'Original inline source bounds'); + return document.slice(location.startTag.endOffset, location.endTag.startOffset); + }); + assert.ok(bodies.length > 0, 'Original Flight/executable inline census'); + const inlineText = bodies.join('\n'); + decodedSource({ body: inlineText, base64Encoded: false }); + const inline = { + count: bodies.length, + digest: digest(inlineText), + bytes: Buffer.byteLength(inlineText), + gzip: gzipSync(inlineText, { level: 9 }).length, + }; + const bytes = scripts.reduce((sum, row) => sum + row.bytes, inline.bytes); + assert.ok(bytes <= 16 * 1024 * 1024, 'Total source byte bound'); + return { scripts, inline, total: scripts.reduce((sum, row) => sum + row.gzip, inline.gzip) }; +} + +/** Settings and configured weights are immutable; LHR legally zeros manual/N/A weights. */ +export function assertConfiguration(actual, expected) { + assert.deepEqual(actual, expected, 'Pinned resolved audit/artifact/category census'); +} + +export function validateReport(lhr, expected, census) { + assert.equal(lhr.lighthouseVersion, census.versions.lighthouse); + assert.equal(lhr.runtimeError, undefined, 'Runtime errors refuse the report'); + assert.deepEqual(lhr.runWarnings, [], 'Incomplete/warning audit reports are refused'); + for (const key of ['requestedUrl', 'mainDocumentUrl', 'finalDisplayedUrl']) + assert.equal(lhr[key], expected.url, key); + assert.deepEqual(Object.keys(lhr.categories).sort(), ['accessibility', 'performance']); + assert.deepEqual(Object.keys(lhr.audits).sort(), census.audits); + for (const result of Object.values(lhr.audits)) { + assert.notEqual(result.scoreDisplayMode, 'error', 'Audit errors refuse the report'); + assert.equal(result.errorMessage, undefined, 'Audit error message'); + } + for (const [name, category] of Object.entries(census.categories)) { + const references = lhr.categories[name].auditRefs; + assert.deepEqual( + references.map((row) => ({ id: row.id, weight: row.weight })), + category.map((row) => ({ + id: row.id, + weight: ['notApplicable', 'informative', 'manual'].includes( + lhr.audits[row.id].scoreDisplayMode, + ) + ? 0 + : row.weight, + })), + 'Complete report weight set', + ); + } + const metrics = {}; + for (const key of [ + 'initial-js-gzip-bytes', + 'cumulative-layout-shift', + 'largest-contentful-paint', + 'server-response-time', + 'total-blocking-time', + ]) { + const value = lhr.audits[key]?.numericValue; + assert.ok( + typeof value === 'number' && Number.isFinite(value) && value >= 0, + 'Finite measured ' + key, + ); + metrics[key] = value; + } + assert.ok(metrics['initial-js-gzip-bytes'] < 200000, 'Initial JavaScript gzip budget'); + assert.ok(metrics['cumulative-layout-shift'] < 0.05, 'CLS budget'); + assert.equal(lhr.categories.accessibility.score, 1, 'Automated accessibility budget'); + return metrics; +} + +export function reportInventory(rows, expected) { + assert.equal(rows.length, 27, 'Exactly 27 successful reports'); + assert.deepEqual( + rows.map((row) => `${row.url}#${row.run}`).sort(), + expected.map((row) => `${row.url}#${row.run}`).sort(), + 'Complete unique URL/run census', + ); + for (const row of rows) { + assert.equal(row.commit, expected[0].commit, 'Exact invocation commit'); + assert.equal(row.build, expected[0].build, 'Exact invocation build'); + assert.equal(row.invocation, expected[0].invocation, 'Fresh invocation receipt'); + assert.equal(row.configDigest, expected[0].configDigest, 'Exact audited configuration'); + assert.equal(row.browserVersion, expected[0].browserVersion, 'Exact audited browser'); + } +} + +/** Reconcile independent Lighthouse events with the complete source gatherer's window. */ +export function reconcileScriptInventory(log, inventory) { + const logged = log.filter( + (row) => row.method === 'Network.responseReceived' && row.params.type === 'Script', + ); + assert.ok(logged.length > 0, 'Positive independent Script network observer'); + for (const row of logged) + assert.ok( + inventory.some( + (item) => item.id === row.params.requestId && item.url === row.params.response.url, + ), + 'Script inventory overlap', + ); +} + +/** Timing metrics remain warnings; each URL owns exactly three finite samples. */ +export function reportAdvisories(reports) { + return [...new Set(reports.map((row) => row.url))].map((url) => ({ + url, + metrics: Object.fromEntries( + Object.entries({ + 'largest-contentful-paint': 1500, + 'server-response-time': 300, + 'total-blocking-time': 200, + }).map(([key, threshold]) => { + const samples = reports.filter((row) => row.url === url).map((row) => row.metrics[key]); + assert.equal(samples.length, 3, 'Exactly three advisory samples per URL'); + assert.ok( + samples.every( + (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0, + ), + 'Finite advisory samples', + ); + const median = samples.sort((a, b) => a - b)[1]; + return [key, { median, threshold, warning: median >= threshold }]; + }), + ), + })); +} diff --git a/frontend/apps/web/scripts/public-demo-gatherer.d.mts b/frontend/apps/web/scripts/public-demo-gatherer.d.mts new file mode 100644 index 00000000..dc1beb2b --- /dev/null +++ b/frontend/apps/web/scripts/public-demo-gatherer.d.mts @@ -0,0 +1,22 @@ +export class PublicDemoDocument { + startInstrumentation(context: unknown): Promise<void>; + getArtifact(context: unknown): Promise<unknown>; +} +export const demoLighthouseConfiguration: unknown; +export function configurationCensus(config: unknown): unknown; +export function resolvedDemoCensus(flags: unknown): Promise<unknown>; + +export class InitialJavaScriptGzip { + static readonly meta: { + id: string; + title: string; + description: string; + scoreDisplayMode: string; + requiredArtifacts: string[]; + }; + static audit(artifacts: { PublicDemoDocument: { gzip: { total: number } } }): { + score: number; + numericValue: number; + numericUnit: string; + }; +} diff --git a/frontend/apps/web/scripts/public-demo-gatherer.mjs b/frontend/apps/web/scripts/public-demo-gatherer.mjs new file mode 100644 index 00000000..55d8d893 --- /dev/null +++ b/frontend/apps/web/scripts/public-demo-gatherer.mjs @@ -0,0 +1,217 @@ +import { Audit } from 'lighthouse/core/audits/audit.js'; +import { initializeConfig } from 'lighthouse/core/config/config.js'; +import BaseGatherer from 'lighthouse/core/gather/base-gatherer.js'; + +import { + decodedSource, + normalizedJavaScript, + productDomSnapshot, +} from './public-demo-contract.mjs'; +import { readDemoToolchain } from './public-demo-toolchain.mjs'; + +/** Observe the initial audited target; never refetch a body or navigate a second time. */ +export class PublicDemoDocument extends BaseGatherer { + meta = { supportedModes: ['navigation'] }; + async startInstrumentation({ driver }) { + this.driver = driver; + this.session = driver.defaultSession; + this.requests = new Map(); + this.operations = []; + this.errors = []; + this.closed = false; + this.listeners = []; + const listen = (event, listener) => { + this.session.on(event, listener); + this.listeners.push(() => this.session.off(event, listener)); + }; + this.foreign = (event) => { + if ( + event.method === 'Network.responseReceived' && + event.params.type === 'Script' && + event.sessionId !== this.session.id() + ) + this.errors.push('Unhandled Script target'); + }; + driver.targetManager.on('protocolevent', this.foreign); + await this.session.sendCommand('Network.enable', { + maxTotalBufferSize: 32 * 1024 * 1024, + maxResourceBufferSize: 8 * 1024 * 1024, + }); + const { frameTree } = await this.session.sendCommand('Page.getFrameTree'); + this.frame = frameTree.frame.id; + listen('Network.requestWillBeSent', (event) => { + if (this.closed || !['Document', 'Script'].includes(event.type)) return; + if (event.type === 'Document' && event.frameId !== this.frame) return; + if (this.requests.size >= 1000) { + this.errors.push('Request bound'); + return; + } + const previous = this.requests.get(event.requestId); + if (previous || event.redirectResponse) this.errors.push('Unexpected initial redirect'); + this.requests.set(event.requestId, { + id: event.requestId, + url: event.request.url, + type: event.type, + complete: false, + bytesObserved: 0, + }); + }); + listen('Network.responseReceived', (event) => { + if (this.closed) return; + const row = this.requests.get(event.requestId); + if (!row) return; + row.status = event.response.status; + row.headers = Object.fromEntries( + Object.entries(event.response.headers).map(([key, value]) => [ + key.toLowerCase(), + String(value), + ]), + ); + if (event.response.fromServiceWorker || event.response.fromDiskCache) + this.errors.push('Cold source came from a cache/worker'); + }); + listen('Network.dataReceived', (event) => { + const row = this.requests.get(event.requestId); + if (row) row.bytesObserved += event.dataLength; + }); + listen('Network.loadingFailed', (event) => { + if (this.requests.has(event.requestId)) this.errors.push('Initial source failed'); + }); + listen('Network.loadingFinished', (event) => { + const row = this.requests.get(event.requestId); + if (this.closed || !row) return; + this.operations.push( + this.session + .sendCommand('Network.getResponseBody', { requestId: event.requestId }) + .then((body) => { + row.body = body; + row.complete = true; + }) + .catch(() => { + this.errors.push('Complete initial source body unavailable'); + }), + ); + }); + } + + async getArtifact({ driver }) { + try { + const dom = await driver.executionContext.evaluate(productDomSnapshot, { + args: [], + useIsolation: true, + }); + // Freeze the collection window before draining already owned body reads. + this.closed = true; + await Promise.all(this.operations); + if (this.errors.length) throw new Error(this.errors[0]); + const rows = [...this.requests.values()]; + if (rows.some((row) => !row.complete || row.status !== 200 || !row.body)) + throw new Error('Initial response/source inventory incomplete'); + const documents = rows.filter((row) => row.type === 'Document'); + if (documents.length !== 1) throw new Error('One actual main document is required'); + const document = documents[0]; + const html = decodedSource(document.body); + const inventory = rows.filter((row) => row.type === 'Script'); + for (const row of rows) { + // dataLength is decoded bytes, unlike encoded Content-Length/transferSize. + if (row.bytesObserved && Buffer.byteLength(decodedSource(row.body)) !== row.bytesObserved) + throw new Error('Decoded source completeness disagreement'); + } + const gzip = normalizedJavaScript(html, inventory); + return { + url: document.url, + status: document.status, + headers: document.headers, + html, + dom, + redirects: 0, + gzip, + inventory: inventory.map((row) => ({ id: row.id, url: row.url })), + cutoff: Date.now(), + }; + } finally { + this.closed = true; + for (const remove of this.listeners) remove(); + driver.targetManager.off('protocolevent', this.foreign); + } + } +} + +export class InitialJavaScriptGzip extends Audit { + static get meta() { + return { + id: 'initial-js-gzip-bytes', + title: 'Normalized initial JavaScript gzip bytes', + description: 'Complete initial external sources plus original inline executable bodies.', + scoreDisplayMode: Audit.SCORING_MODES.INFORMATIVE, + requiredArtifacts: ['PublicDemoDocument', 'URL'], + }; + } + static audit(artifacts) { + return { score: 1, numericValue: artifacts.PublicDemoDocument.gzip.total, numericUnit: 'byte' }; + } +} + +export const demoLighthouseConfiguration = { + extends: 'lighthouse:default', + artifacts: [{ id: 'PublicDemoDocument', gatherer: { instance: new PublicDemoDocument() } }], + audits: [{ implementation: InitialJavaScriptGzip }], + settings: { + onlyCategories: ['performance', 'accessibility'], + onlyAudits: ['initial-js-gzip-bytes'], + skipAudits: ['bf-cache'], + formFactor: 'mobile', + throttlingMethod: 'simulate', + screenEmulation: { + mobile: true, + width: 412, + height: 823, + deviceScaleFactor: 1.75, + disabled: false, + }, + throttling: { + rttMs: 150, + throughputKbps: 1638.4, + cpuSlowdownMultiplier: 4, + requestLatencyMs: 562.5, + downloadThroughputKbps: 1474.56, + uploadThroughputKbps: 675, + }, + }, +}; + +export function configurationCensus(config) { + // Compare the serializable configuration receipt: absent optional properties + // must not differ merely because Lighthouse represents them as undefined. + return JSON.parse( + JSON.stringify({ + versions: readDemoToolchain(), + artifacts: config.artifacts + .map((row) => ({ id: row.id, dependencies: row.dependencies })) + .sort((a, b) => a.id.localeCompare(b.id)), + audits: config.audits.map((row) => row.implementation.meta.id).sort(), + categories: Object.fromEntries( + Object.entries(config.categories).map(([key, value]) => [ + key, + value.auditRefs.map((row) => ({ id: row.id, weight: row.weight })), + ]), + ), + settings: { + formFactor: config.settings.formFactor, + screenEmulation: config.settings.screenEmulation, + throttlingMethod: config.settings.throttlingMethod, + throttling: config.settings.throttling, + disableStorageReset: config.settings.disableStorageReset, + }, + }), + ); +} + +export async function resolvedDemoCensus(flags) { + const { resolvedConfig } = await initializeConfig( + 'navigation', + demoLighthouseConfiguration, + flags, + ); + return configurationCensus(resolvedConfig); +} diff --git a/frontend/apps/web/scripts/public-demo-toolchain.d.mts b/frontend/apps/web/scripts/public-demo-toolchain.d.mts new file mode 100644 index 00000000..8ab57a21 --- /dev/null +++ b/frontend/apps/web/scripts/public-demo-toolchain.d.mts @@ -0,0 +1,16 @@ +export interface DemoToolchain { + lighthouse: string; + playwright: string; + chromiumRevision: string; + browserVersion: string; +} +export function readDemoToolchain(): Readonly<DemoToolchain>; +export function toolchainVersions( + pins: { lighthouse: string; playwright: string }, + installed: { + lighthouse: string; + playwright: string; + core: string; + browsers: Array<{ name: string; revision: string; browserVersion?: string }>; + }, +): Readonly<DemoToolchain>; diff --git a/frontend/apps/web/scripts/public-demo-toolchain.mjs b/frontend/apps/web/scripts/public-demo-toolchain.mjs new file mode 100644 index 00000000..9816d25a --- /dev/null +++ b/frontend/apps/web/scripts/public-demo-toolchain.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; + +/** Match installed metadata to exact workspace pins before admitting audit tools. */ +export function toolchainVersions(pins, installed) { + for (const name of ['lighthouse', 'playwright']) { + assert.match(pins[name], /^\d+\.\d+\.\d+$/, 'Exact workspace tool pin'); + assert.equal(installed[name], pins[name], 'Installed ' + name + ' matches workspace pin'); + } + assert.equal(installed.core, installed.playwright, 'Playwright core matches its library'); + const chromium = installed.browsers.filter((browser) => browser.name === 'chromium'); + assert.equal(chromium.length, 1, 'One managed full Chromium declaration'); + assert.match(chromium[0].revision, /^\d+$/, 'Managed Chromium revision'); + assert.match(chromium[0].browserVersion, /^\d+\.\d+\.\d+\.\d+$/, 'Managed Chromium version'); + return Object.freeze({ + lighthouse: installed.lighthouse, + playwright: installed.playwright, + chromiumRevision: chromium[0].revision, + browserVersion: chromium[0].browserVersion, + }); +} + +export function readDemoToolchain() { + const require = createRequire(import.meta.url); + const playwrightManifest = require.resolve('playwright/package.json'); + const coreManifest = createRequire(playwrightManifest).resolve('playwright-core/package.json'); + const json = (path) => JSON.parse(readFileSync(path, 'utf8')); + return toolchainVersions( + json(new URL('../../../package.json', import.meta.url)).devDependencies, + { + lighthouse: require('lighthouse/package.json').version, + playwright: json(playwrightManifest).version, + core: json(coreManifest).version, + browsers: json(join(dirname(coreManifest), 'browsers.json')).browsers, + }, + ); +} diff --git a/frontend/apps/web/scripts/public-demo-worker.mjs b/frontend/apps/web/scripts/public-demo-worker.mjs new file mode 100644 index 00000000..85bd8d23 --- /dev/null +++ b/frontend/apps/web/scripts/public-demo-worker.mjs @@ -0,0 +1,279 @@ +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; + +import lighthouse, { generateReport } from 'lighthouse'; +import { chromium } from 'playwright'; + +import { + admitProduct, + assertConfiguration, + digest, + productDomSnapshot, + reconcileScriptInventory, + validateReport, +} from './public-demo-contract.mjs'; +import { resolvedDemoCensus, demoLighthouseConfiguration } from './public-demo-gatherer.mjs'; +import { readDemoToolchain } from './public-demo-toolchain.mjs'; + +const expectedCensus = JSON.parse( + readFileSync(new URL('./public-demo-census.json', import.meta.url), 'utf8'), +); +const catalogue = (locale) => + JSON.parse( + readFileSync( + new URL( + `../src/i18n/messages/${locale.startsWith('tr') ? 'tr' : 'en'}/public.json`, + import.meta.url, + ), + 'utf8', + ), + ); + +async function observeDocument(page, response) { + assert.ok(response, 'Live product response exists'); + await response.finished(); + return { + status: response.status(), + headers: await response.allHeaders(), + html: (await response.body()).toString('utf8'), + dom: await page.evaluate(productDomSnapshot), + redirects: response.request().redirectedFrom() ? 1 : 0, + }; +} + +async function observe(page, response, lane, kind) { + const document = await observeDocument(page, response); + admitProduct(document, lane, kind, catalogue(lane.locale).catalog.title); + return document; +} + +async function browserPaths(browser, input) { + let visibilityControls = 0; + let navigations = 0; + for (const [laneIndex, lane] of input.lanes.entries()) { + const context = await browser.newContext({ + viewport: { width: 412, height: 823 }, + deviceScaleFactor: 1.75, + }); + const page = await context.newPage(); + page.setDefaultTimeout(15000); + try { + let response = await page.goto(lane.catalogUrl, { waitUntil: 'networkidle', timeout: 15000 }); + for (const [index, kind] of ['catalog', 'course', 'lesson'].entries()) { + if (index) { + [response] = await Promise.all([ + page.waitForNavigation({ waitUntil: 'networkidle', timeout: 15000 }), + page + .locator(kind === 'course' ? '.public-course-card h2 a' : '.public-lesson-list a') + .filter({ hasText: lane[kind + 'Title'] }) + .click(), + ]); + } + await observe(page, response, lane, kind); + navigations++; + await page.screenshot({ + path: join(input.output, `browser-${laneIndex}-${kind}.png`), + fullPage: true, + }); + if (kind === 'lesson') { + // Exercise the real DOM predicate, not a fabricated snapshot. No + // additional document/API request is introduced by these mutations. + for (const css of [ + 'body { opacity: 0 !important }', + 'body { transform: scale(0) !important }', + 'dt { opacity: 0 !important }', + 'body { clip-path: inset(100%) !important }', + 'h1 { filter: opacity(0) !important }', + 'dt { mask-image: linear-gradient(transparent, transparent) !important }', + 'body { position: absolute !important; left: -100000px !important }', + 'body { height: 0 !important; overflow: hidden !important }', + ]) { + const mask = await page.addStyleTag({ content: css }); + try { + const hidden = await observeDocument(page, response); + assert.throws( + () => admitProduct(hidden, lane, kind, catalogue(lane.locale).catalog.title), + 'Actual hidden product markers cannot satisfy admission', + ); + } finally { + await mask.evaluate((node) => node.remove()); + } + await observe(page, response, lane, kind); + visibilityControls++; + } + } + } + } finally { + await context.close(); + } + } + return { navigations, visibilityControls }; +} + +/** The pinned Lighthouse load deadline is a warning, which admission must refuse. */ +async function timeoutControl(port) { + const server = createServer((request, response) => { + if (request.url === '/pending.png') return; + if (request.url === '/script.js') { + response.writeHead(200, { 'content-type': 'text/javascript' }); + response.end('window.source = true;'); + return; + } + if (request.url === '/favicon.ico') { + response.writeHead(204); + response.end(); + return; + } + response.writeHead(200, { 'content-type': 'text/html', 'cache-control': 'no-store' }); + response.end( + '<!doctype html><html lang="en"><head><meta name="viewport" content="width=device-width, initial-scale=1"><title>Deadline control

Deadline control

', + ); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + try { + const url = `http://127.0.0.1:${server.address().port}/`; + const result = await lighthouse( + url, + { port, hostname: '127.0.0.1', logLevel: 'error', maxWaitForLoad: 15000 }, + demoLighthouseConfiguration, + ); + assert.equal(result.lhr.runtimeError, undefined, 'Control reaches report-warning admission'); + assert.ok(result.lhr.runWarnings.length > 0, 'Actual incomplete-navigation warning'); + assert.throws( + () => validateReport(result.lhr, { url }, expectedCensus), + /Incomplete\/warning/, + 'Soft navigation timeout must refuse', + ); + const clean = structuredClone(result.lhr); + clean.runWarnings = []; + validateReport(clean, { url }, expectedCensus); + } finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } +} + +async function main(input) { + assert.deepEqual(readDemoToolchain(), expectedCensus.versions, 'Pinned tool/browser census'); + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${input.port}`, { + timeout: 10000, + }); + let browserVersion; + try { + browserVersion = browser.version(); + assert.equal( + browserVersion, + expectedCensus.versions.browserVersion, + 'Pinned managed full Chromium version', + ); + // The native browser starts with no product target. Retire its blank target. + for (const context of browser.contexts()) + for (const page of context.pages()) await page.close(); + if (input.mode === 'tls-refusal') { + const page = await browser.newPage(); + let refused = false; + let failureCode = 'no-navigation-error'; + try { + await page.goto(input.lanes[0].catalogUrl, { timeout: 10000 }); + } catch (error) { + failureCode = error.message.match(/ERR_[A-Z_]+/)?.[0] ?? error.name; + refused = failureCode === 'ERR_CERT_AUTHORITY_INVALID'; + } + assert.equal( + refused, + true, + 'Untrusted/wrong-SPKI browser certificate refusal: ' + failureCode, + ); + writeFileSync(input.result, JSON.stringify({ refused: true, browserVersion })); + return; + } + if (input.mode === 'browser') { + const counts = await browserPaths(browser, input); + writeFileSync(input.result, JSON.stringify({ browserVersion, ...counts })); + return; + } + if (input.mode !== 'timeout-control') { + const context = await browser.newContext({ + viewport: { width: 412, height: 823 }, + deviceScaleFactor: 1.75, + }); + const page = await context.newPage(); + try { + const response = await page.goto(input.url, { waitUntil: 'networkidle', timeout: 15000 }); + await observe(page, response, input.lane, input.kind); + } finally { + await context.close(); + } + } + } finally { + await browser.close(); + } + + if (input.mode === 'timeout-control') { + await timeoutControl(input.port); + writeFileSync(input.result, JSON.stringify({ browserVersion, refused: true })); + return; + } + + const flags = { + port: input.port, + hostname: '127.0.0.1', + logLevel: 'error', + output: ['json', 'html'], + maxWaitForLoad: 15000, + }; + assertConfiguration(await resolvedDemoCensus(flags), expectedCensus); + const result = await lighthouse(input.url, flags, demoLighthouseConfiguration); + assert.ok(result?.artifacts?.PublicDemoDocument, 'Actual audit product artifact'); + for (const { id } of expectedCensus.artifacts) + assert.ok(result.artifacts[id], 'Required gathered artifact ' + id); + admitProduct( + result.artifacts.PublicDemoDocument, + input.lane, + input.kind, + catalogue(input.lane.locale).catalog.title, + ); + const metrics = validateReport(result.lhr, input, expectedCensus); + assert.equal(metrics['initial-js-gzip-bytes'], result.artifacts.PublicDemoDocument.gzip.total); + const source = result.artifacts.PublicDemoDocument; + // This is independent network-window coverage, not a second gzip measurement. + reconcileScriptInventory(result.artifacts.DevtoolsLog, source.inventory); + const base = join(input.output, `audit-${input.laneIndex}-${input.kind}-${input.run}`); + const report = JSON.stringify(result.lhr); + writeFileSync(base + '.json', report, { flag: 'wx' }); + writeFileSync(base + '.html', generateReport(result.lhr, 'html'), { flag: 'wx' }); + writeFileSync( + input.result, + JSON.stringify({ + ...input.identity, + url: input.url, + run: input.run, + browserVersion, + metrics, + configDigest: digest(JSON.stringify(expectedCensus)), + reportDigest: digest(report), + gzip: source.gzip, + cutoff: source.cutoff, + }), + ); +} + +try { + await main(JSON.parse(readFileSync(process.argv[2], 'utf8'))); +} catch (error) { + // Never echo raw browser/provider messages, document values or configuration. + console.error( + 'Public demo worker refused: ' + + (error?.code === 'ERR_ASSERTION' + ? String(error.message).split('\n')[0].slice(0, 160) + : (error?.name ?? 'failure')), + ); + process.exitCode = 1; +} diff --git a/frontend/apps/web/scripts/public-rendering-assertions.d.mts b/frontend/apps/web/scripts/public-rendering-assertions.d.mts index 637502c2..46ba8041 100644 --- a/frontend/apps/web/scripts/public-rendering-assertions.d.mts +++ b/frontend/apps/web/scripts/public-rendering-assertions.d.mts @@ -59,3 +59,15 @@ export function productTheme( malformed?: boolean, readStylesheet?: (path: string) => string, ): void; + +export function orderedLessonFields( + article: Element, + content: { + readonly fields: readonly { + readonly name: string; + readonly label: { readonly value: string; readonly locale: string }; + readonly value: string; + }[]; + }, + locale: string, +): void; diff --git a/frontend/apps/web/scripts/public-rendering-assertions.mjs b/frontend/apps/web/scripts/public-rendering-assertions.mjs index dee1496c..1f89cc7e 100644 --- a/frontend/apps/web/scripts/public-rendering-assertions.mjs +++ b/frontend/apps/web/scripts/public-rendering-assertions.mjs @@ -212,3 +212,43 @@ export function productTheme(doc, details, malformed = false, readStylesheet) { ); } } + +/** The real successor-revision proof and its planted controls share this predicate. */ +export function orderedLessonFields(article, content, locale) { + const terms = [...article.querySelectorAll('dl dt')]; + const values = [...article.querySelectorAll('dl dd')]; + assert.deepEqual( + terms.map((term) => term.textContent), + content.fields.map((field) => field.label.value), + 'Descriptor order controls authored labels', + ); + assert.deepEqual( + values.map((value) => value.textContent), + content.fields.map((field) => field.value), + 'Every exact API string renders as text in descriptor order', + ); + assert.equal( + article.querySelectorAll('dl').length, + content.fields.length ? 1 : 0, + 'Only nonempty cards have a definition list', + ); + for (const [index, field] of content.fields.entries()) { + const term = terms[index]; + const value = values[index]; + assert.equal( + term.lang, + field.label.locale, + 'Each label attributes the actual fallback-resolved language', + ); + assert.equal(term.dir, field.label.locale === 'ar' ? 'rtl' : 'ltr'); + assert.equal(term.childElementCount, 0, 'Labels remain escaped text'); + assert.equal(value.childElementCount, 0, 'Values remain escaped text'); + assert.equal( + value.closest('[lang]').lang, + locale, + 'Content value does not inherit its label fallback locale', + ); + assert.notEqual(term.textContent, field.name, 'Internal field names never become labels'); + assert.equal(term.nextElementSibling, value, 'Every term is followed by its definition'); + } +} diff --git a/frontend/apps/web/scripts/verify-public-demo.mjs b/frontend/apps/web/scripts/verify-public-demo.mjs new file mode 100644 index 00000000..813682cc --- /dev/null +++ b/frontend/apps/web/scripts/verify-public-demo.mjs @@ -0,0 +1,281 @@ +import assert from 'node:assert/strict'; +import { randomBytes, createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { cpus, homedir, platform, release } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as pause } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +import { digest, reportAdvisories, reportInventory } from './public-demo-contract.mjs'; +import { stopTestChild } from './stop-test-child.mjs'; +import { demoRequest } from '../../../../scripts/demo-http.mjs'; +import { completionDelta, finalCompletionCensus } from '../../../../scripts/demo-observation.mjs'; + +const root = fileURLToPath(new URL('../../../../', import.meta.url)); +const worker = fileURLToPath(new URL('./public-demo-worker.mjs', import.meta.url)); +const snapshot = (state) => JSON.parse(JSON.stringify(state)); +const census = JSON.parse( + readFileSync(new URL('./public-demo-census.json', import.meta.url), 'utf8'), +); + +/** Four paced batches over one unchanged production API/build/database. */ +export async function verifyPublicDemo(runtime) { + const started = Date.now(); + const deadline = started + 12 * 60 * 1000; + const remaining = (bound) => { + runtime.processes.assertActive(); + if (Date.now() >= deadline) throw new Error('Public proof wall-clock deadline'); + return Math.min(bound, deadline - Date.now()); + }; + const output = join(root, 'artifacts/public-demo'); + if (existsSync(output)) throw new Error('Public proof artifact directory must be fresh'); + mkdirSync(output, { recursive: true }); + const identity = { + invocation: randomBytes(16).toString('hex'), + commit: ( + await runtime.processes.run('git', ['rev-parse', 'HEAD'], { + cwd: root, + env: { PATH: process.env.PATH }, + label: 'Read-only source provenance', + capture: true, + }) + ).trim(), + build: readFileSync(join(root, 'frontend/apps/web/.next/BUILD_ID'), 'utf8').trim(), + }; + assert.match(identity.commit, /^[a-f0-9]{40}$/); + const hosts = [...new Set(runtime.manifest.map((lane) => lane.host))]; + const spki = createHash('sha256') + .update(runtime.leaf.publicKey.export({ type: 'spki', format: 'der' })) + .digest('base64'); + const childrenEnv = { + PATH: process.env.PATH, + HOME: runtime.privateRoot, + TMPDIR: runtime.privateRoot, + NODE_TLS_REJECT_UNAUTHORIZED: '1', + NEXT_TELEMETRY_DISABLED: '1', + }; + const receipt = { + identity, + node: process.version, + os: platform() + ' ' + release(), + cpus: cpus().length, + runnerImage: process.env.ImageOS ?? null, + versions: { + lighthouse: census.versions.lighthouse, + playwright: census.versions.playwright, + chromiumRevision: census.versions.chromiumRevision, + }, + configDigest: digest(JSON.stringify(census)), + setupMilliseconds: runtime.setupMilliseconds, + batches: [], + reports: [], + tls: [], + proofMilliseconds: null, + passed: false, + }; + receipt.configuration = census; + const expected = runtime.manifest.flatMap((lane) => + ['catalog', 'course', 'lesson'].flatMap((kind) => + [1, 2, 3].map((run) => ({ + ...identity, + url: lane[kind + 'Url'], + run, + configDigest: receipt.configDigest, + browserVersion: census.versions.browserVersion, + })), + ), + ); + receipt.expectedReports = expected; + const save = () => writeFileSync(join(output, 'receipt.json'), JSON.stringify(receipt, null, 2)); + let operationIndex = 0; + async function operation(input, trust = spki) { + const index = operationIndex++; + const profile = join(runtime.privateRoot, `chromium-${index}`); + mkdirSync(profile, { mode: 0o700 }); + const resultFile = join(runtime.privateRoot, `browser-result-${index}.json`); + const inputFile = join(runtime.privateRoot, `browser-input-${index}.json`); + const browser = runtime.processes.start( + chromium.executablePath(), + [ + '--headless=new', + '--remote-debugging-address=127.0.0.1', + '--remote-debugging-port=0', + `--user-data-dir=${profile}`, + '--no-first-run', + '--no-default-browser-check', + '--disable-background-networking', + '--disable-component-update', + '--disable-sync', + '--no-proxy-server', + `--host-resolver-rules=${hosts.map((host) => `MAP ${host} 127.0.0.1`).join(',')}`, + ...(trust ? [`--ignore-certificate-errors-spki-list=${trust}`] : []), + 'about:blank', + ], + { + cwd: root, + // macOS's native certificate verifier needs the real account home. + // Chromium data stays in its explicitly owned disposable profile. + env: { ...childrenEnv, ...(platform() === 'darwin' ? { HOME: homedir() } : {}) }, + label: 'Owned managed Chromium', + }, + ); + try { + const portFile = join(profile, 'DevToolsActivePort'); + const startupDeadline = Date.now() + remaining(10000); + while (!existsSync(portFile)) { + remaining(10000); + if (browser.child.exitCode !== null || Date.now() >= startupDeadline) + throw new Error('Managed Chromium startup'); + await pause(50, undefined, { signal: runtime.signal }); + } + const port = Number(readFileSync(portFile, 'utf8').split('\n')[0]); + assert.ok(port > 0 && port <= 65535); + writeFileSync( + inputFile, + JSON.stringify({ + ...input, + port, + lanes: runtime.manifest, + result: resultFile, + output, + identity, + }), + { mode: 0o600, flag: 'wx' }, + ); + await runtime.processes.run('node', [worker, inputFile], { + cwd: root, + env: childrenEnv, + label: `Public ${input.mode} proof`, + timeout: remaining(input.mode === 'browser' ? 135000 : 60000), + // Fixed assertions/phase names only; provider/document logs stay suppressed. + observe: (chunk) => { + if (chunk.toString().startsWith('Public demo worker refused:')) + console.error(chunk.toString().trim()); + }, + }); + return JSON.parse(readFileSync(resultFile, 'utf8')); + } finally { + await stopTestChild(browser.child); + await browser.exit; + rmSync(profile, { recursive: true, force: true }); + } + } + async function quiet() { + remaining(61000); + if (deadline - Date.now() < 61000) throw new Error('Insufficient proof quiet interval'); + const before = runtime.completions.total; + await pause(61000, undefined, { signal: runtime.signal }); + assert.equal(runtime.completions.total, before, 'Quiet window has no hidden product traffic'); + } + async function finishBatch(before, expected, name) { + // The API finishes before returning its document; allow only log delivery, + // never a retry or extra request, to catch up on its pipe. + const until = Date.now() + remaining(2000); + while ( + (runtime.completions.total - before.total < expected || runtime.completions.pendingBytes) && + Date.now() < until + ) + await pause(20, undefined, { signal: runtime.signal }); + receipt.batches.push({ name, ...completionDelta(before, runtime.completions, expected) }); + save(); + } + let failure; + try { + assert.equal(runtime.mode, 'ci'); + // Positive observer control uses already completed readiness, then a fresh + // product document through independent CA/hostname verification. + assert.equal(runtime.completions.total, 6, 'Three two-call readiness documents completed'); + receipt.startupCompletions = snapshot(runtime.completions); + const beforeControl = snapshot(runtime.completions); + const control = await demoRequest(runtime.manifest[0].lessonUrl, { + ca: runtime.ca, + signal: runtime.signal, + loopback: true, + }); + assert.equal(control.status, 200); + await finishBatch(beforeControl, 2, 'observer-positive'); + const beforeTls = snapshot(runtime.completions); + for (const trust of [null, randomBytes(32).toString('base64')]) + receipt.tls.push(await operation({ mode: 'tls-refusal' }, trust)); + assert.equal( + runtime.completions.total, + beforeTls.total, + 'Untrusted browser TLS never reaches API', + ); + receipt.timeoutControl = await operation({ mode: 'timeout-control' }); + assert.equal(receipt.timeoutControl.refused, true); + assert.equal( + runtime.completions.total, + beforeTls.total, + 'Synthetic timeout control has no API traffic', + ); + await quiet(); + let before = snapshot(runtime.completions); + receipt.browser = await operation({ mode: 'browser' }); + assert.equal(receipt.browser.navigations, 9); + assert.equal(receipt.browser.visibilityControls, 24); + await finishBatch(before, 18, 'linked-browser'); + for (const [laneIndex, lane] of runtime.manifest.entries()) { + await quiet(); + before = snapshot(runtime.completions); + for (const kind of ['catalog', 'course', 'lesson']) + for (const run of [1, 2, 3]) { + console.warn(`demo audit: lane ${laneIndex + 1}/3, ${kind}, run ${run}/3`); + const url = lane[kind + 'Url']; + const report = await operation({ mode: 'audit', laneIndex, lane, kind, url, run }); + receipt.reports.push(report); + remaining(1); + if (runtime.completions.statuses[429] || runtime.completions.total - before.total > 48) + throw new Error('Public audit quota/status bound'); + save(); + } + await finishBatch(before, 36, `audit-lane-${laneIndex}`); + } + reportInventory(receipt.reports, expected); + for (const row of receipt.reports) { + const base = `audit-${runtime.manifest.findIndex((lane) => row.url.startsWith(lane.catalogUrl))}-${row.url.includes('/lessons/') ? 'lesson' : runtime.manifest.some((lane) => row.url === lane.catalogUrl) ? 'catalog' : 'course'}-${row.run}`; + assert.equal( + digest(readFileSync(join(output, base + '.json'))), + row.reportDigest, + 'Fresh complete report bytes', + ); + assert.ok( + readFileSync(join(output, base + '.html'), 'utf8').length > 1000, + 'Complete HTML report', + ); + } + receipt.advisories = reportAdvisories(receipt.reports); + remaining(1); + receipt.proofMilliseconds = Date.now() - started; + receipt.passed = true; + } catch (error) { + failure = error; + } finally { + receipt.proofMilliseconds = Date.now() - started; + const beforeCleanup = snapshot(runtime.completions); + try { + receipt.cleanup = await runtime.dispose(); + } catch { + receipt.cleanup = { passed: false }; + receipt.passed = false; + failure ??= new Error('Public proof owned cleanup failed'); + } finally { + receipt.totalCompletions = snapshot(runtime.completions); + try { + finalCompletionCensus( + beforeCleanup, + runtime.completions, + (receipt.startupCompletions?.total ?? 0) + + receipt.batches.reduce((sum, batch) => sum + batch.count, 0), + ); + } catch (error) { + receipt.passed = false; + failure ??= error; + } + save(); + } + } + if (failure) throw failure; +} diff --git a/frontend/apps/web/scripts/verify-public-rendering.mjs b/frontend/apps/web/scripts/verify-public-rendering.mjs index 409c390a..f434be76 100644 --- a/frontend/apps/web/scripts/verify-public-rendering.mjs +++ b/frontend/apps/web/scripts/verify-public-rendering.mjs @@ -30,6 +30,7 @@ import { absentFromSerializedText, absentFromWholeResponse as assertAbsentFromWholeResponse, documentViewport, + orderedLessonFields, documentOpenGraphLocales, productContainment as assertProductContainment, productTheme as assertProductTheme, @@ -882,42 +883,7 @@ function lessonDocument(response, tenant, locale, title, path, content) { false, 'Ready lesson metadata is indexable', ); - const terms = [...article.querySelectorAll('dl dt')]; - const values = [...article.querySelectorAll('dl dd')]; - assert.deepEqual( - terms.map((term) => term.textContent), - content.fields.map((field) => field.label.value), - 'Descriptor order controls authored labels', - ); - assert.deepEqual( - values.map((value) => value.textContent), - content.fields.map((field) => field.value), - 'Every exact API string renders as text in descriptor order', - ); - assert.equal( - article.querySelectorAll('dl').length, - content.fields.length ? 1 : 0, - 'Only nonempty cards have a definition list', - ); - for (const [index, field] of content.fields.entries()) { - const term = terms[index]; - const value = values[index]; - assert.equal( - term.lang, - field.label.locale, - 'Each label attributes the actual fallback-resolved language', - ); - assert.equal(term.dir, field.label.locale === 'ar' ? 'rtl' : 'ltr'); - assert.equal(term.childElementCount, 0, 'Labels remain escaped text'); - assert.equal(value.childElementCount, 0, 'Values remain escaped text'); - assert.equal( - value.closest('[lang]').lang, - locale, - 'Content value does not inherit its label fallback locale', - ); - assert.notEqual(term.textContent, field.name, 'Internal field names never become labels'); - assert.equal(term.nextElementSibling, value, 'Every term is followed by its definition'); - } + orderedLessonFields(article, content, locale); assert.equal( article.textContent.includes('Omitted optional field'), false, diff --git a/frontend/apps/web/src/test/demo-genericity-analysis.ts b/frontend/apps/web/src/test/demo-genericity-analysis.ts new file mode 100644 index 00000000..b3ea403d --- /dev/null +++ b/frontend/apps/web/src/test/demo-genericity-analysis.ts @@ -0,0 +1,96 @@ +import ts from 'typescript'; + +type Vocabulary = { + readonly values: readonly { readonly kind: string; readonly value: string }[]; + readonly platformValues: readonly string[]; +}; + +function guid(value: string): string | null { + const compact = value.replace(/\s/g, ''); + const hex = compact.match( + /^\{0x([0-9a-f]{1,8}),0x([0-9a-f]{1,4}),0x([0-9a-f]{1,4}),\{0x([0-9a-f]{1,2}),0x([0-9a-f]{1,2}),0x([0-9a-f]{1,2}),0x([0-9a-f]{1,2}),0x([0-9a-f]{1,2}),0x([0-9a-f]{1,2}),0x([0-9a-f]{1,2}),0x([0-9a-f]{1,2})\}\}$/i, + ); + if (hex) + return hex + .slice(1) + .map((part, index) => part!.padStart(index === 0 ? 8 : index < 3 ? 4 : 2, '0')) + .join('') + .toLowerCase(); + const bare = value.trim().replace(/^\{(.+)\}$|^\((.+)\)$/, '$1$2'); + if (!/^(?:[0-9a-f]{32}|[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})$/i.test(bare)) return null; + return bare.replaceAll('-', '').toLowerCase(); +} + +function jsonValues(value: unknown): string[] { + if (typeof value === 'string') return [value]; + if (Array.isArray(value)) return value.flatMap(jsonValues); + if (value !== null && typeof value === 'object') return Object.values(value).flatMap(jsonValues); + return []; +} + +/** G20 compares whole decoded values; it does not prove arbitrary computed branches. */ +export function demoIdentityFindings( + path: string, + source: string, + vocabulary: Vocabulary, +): string[] { + if (!vocabulary.values.length || !vocabulary.platformValues.length) + throw new Error('The typed demo identity source is empty'); + const ids = new Set( + vocabulary.values.filter((row) => row.kind === 'id').map((row) => guid(row.value)), + ); + if (!ids.size || ids.has(null)) throw new Error('The typed demo ID source is malformed'); + const platform = new Set(vocabulary.platformValues); + const identities = new Set( + vocabulary.values + .filter((row) => row.kind !== 'id' && !platform.has(row.value)) + .map((row) => row.value), + ); + const values: string[] = []; + if (path.endsWith('.json')) values.push(...jsonValues(JSON.parse(source))); + else { + if (!/\.(?:[cm]?[jt]sx?)$/.test(path)) + throw new Error(`Unsupported genericity subject: ${path}`); + const file = ts.createSourceFile( + path, + source, + ts.ScriptTarget.Latest, + true, + /\.[jt]sx$/.test(path) + ? ts.ScriptKind.TSX + : /\.[cm]?js$/.test(path) + ? ts.ScriptKind.JS + : ts.ScriptKind.TS, + ); + const diagnostics = (file as ts.SourceFile & { parseDiagnostics: readonly ts.Diagnostic[] }) + .parseDiagnostics; + if (diagnostics.length) throw new Error(`Unreadable genericity subject: ${path}`); + const visit = (node: ts.Node) => { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + values.push(node.text); + // Authored JSON carried by a source literal is not a hidden second vocabulary. + if (node.text.trimStart().startsWith('{') || node.text.trimStart().startsWith('[')) { + try { + values.push(...jsonValues(JSON.parse(node.text))); + } catch { + /* Ordinary text may start with a bracket. Standalone JSON is strict. */ + } + } + } + ts.forEachChild(node, visit); + }; + visit(file); + if (/\.[jt]sx$/.test(path)) { + // TypeScript emits JSX entity decoding and whitespace exactly as runtime + // strings. Reading the emitted tree avoids inventing a second JSX parser. + const emitted = ts.transpileModule(source, { + compilerOptions: { jsx: ts.JsxEmit.React, target: ts.ScriptTarget.ESNext }, + fileName: path, + }).outputText; + visit(ts.createSourceFile('jsx.js', emitted, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)); + } + } + return [ + ...new Set(values.filter((value) => identities.has(value) || ids.has(guid(value)))), + ].sort(); +} diff --git a/frontend/apps/web/src/test/demo-genericity-sources.ts b/frontend/apps/web/src/test/demo-genericity-sources.ts new file mode 100644 index 00000000..07cb5e82 --- /dev/null +++ b/frontend/apps/web/src/test/demo-genericity-sources.ts @@ -0,0 +1,399 @@ +import { isBuiltin } from 'node:module'; +import { posix } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import ts from 'typescript'; + +// Exact proof entries and development declarations are bounded exemptions. +// Shared runtime helpers are not exemptions, even if tests also import them. +const exemptEntries = new Set([ + 'frontend/apps/web/scripts/verify-ingress.mjs', + 'frontend/apps/web/scripts/verify-ingress-cleanup.mjs', + 'frontend/apps/web/scripts/verify-ingress-controls.mjs', + 'frontend/apps/web/scripts/verify-public-admission.mjs', + 'frontend/apps/web/scripts/verify-public-rendering.mjs', + 'frontend/apps/web/scripts/admission-browser.mjs', + 'frontend/apps/web/scripts/admission-completion.mjs', + 'frontend/apps/web/scripts/admission-hmr.mjs', + 'frontend/apps/web/scripts/admission-hmr-watch.mjs', + 'scripts/public-local.mjs', +]); + +const generatedOutputs = new Set([ + 'frontend/apps/web/.next', + 'frontend/apps/web/.server', + 'frontend/apps/web/coverage', + 'frontend/packages/sdk/dist', + 'frontend/packages/ui/dist', + 'frontend/coverage', +]); + +/** Only actual generated roots are omitted; nested source directories stay visible. */ +export function omitDemoSourceDirectory(path: string): boolean { + return dependencyRoots.has(path) || generatedOutputs.has(path); +} + +const dependencyRoots = new Set([ + 'frontend/node_modules', + 'frontend/apps/web/node_modules', + 'frontend/packages/config/node_modules', + 'frontend/packages/sdk/node_modules', + 'frontend/packages/ui/node_modules', +]); +const sourceExtension = /\.(?:[cm]?[jt]sx?|json)$/; +const declaration = /\.d\.[cm]?[jt]sx?$/; +const nonRuntimeFiles = new Set([ + 'frontend/.gitignore', + 'frontend/.npmrc', + 'frontend/.nvmrc', + 'frontend/.prettierignore', + 'frontend/README.md', + 'frontend/apps/web/README.md', + 'frontend/apps/web/.env.local.example', + 'frontend/apps/web/src/app/globals.css', + 'frontend/apps/web/src/components/.gitkeep', + 'frontend/apps/web/tsconfig.tsbuildinfo', + 'frontend/packages/sdk/tsconfig.tsbuildinfo', + 'frontend/packages/ui/tsconfig.tsbuildinfo', + 'frontend/pnpm-lock.yaml', + 'frontend/pnpm-workspace.yaml', + 'scripts/adr-status.py', + 'scripts/assert-tests-ran.py', + 'scripts/connection-string.awk', + 'scripts/install-oasdiff.py', + 'scripts/openapi-ci.py', + 'scripts/openapi-policy.py', + 'scripts/seed.sh', + 'scripts/test-adr-status.py', + 'scripts/test-openapi-ci.py', +]); +const nonRuntimeRoots = new Set([ + '.claude', + '.config', + '.githooks', + '.github', + '.vscode', + 'docs', + 'infra', +]); +const rootFiles = new Set([ + '.env.example', + '.gitattributes', + '.gitignore', + '.leakwatch.yaml', + '.leakwatchignore', + '.prettierignore', + 'AGENTS.md', + 'CLAUDE.md', + 'Makefile', + 'README.md', +]); + +/** Refuse new roots/formats until their production or non-runtime ownership is explicit. */ +export function demoSourceClassification(path: string): 'runtime' | 'other' { + if (!path || path.startsWith('/') || path.split('/').some((part) => part === '..')) + throw new Error(`Unclassified repository source: ${path}`); + const root = path.split('/')[0]!; + if (nonRuntimeRoots.has(root) || rootFiles.has(path)) return 'other'; + // Backend source admission and evaluated Compile coverage are owned by its architecture test. + if (root === 'backend') { + if ( + /^backend\/(?:src|analyzers|tests|openapi)\//.test(path) || + /^backend\/(?:\.editorconfig|Directory\.(?:Build|Packages)\.props|LearnStack\.slnx|README\.md|global\.json)$/.test( + path, + ) + ) + return 'other'; + throw new Error(`Unclassified repository source: ${path}`); + } + if (root !== 'frontend' && root !== 'scripts') + throw new Error(`Unclassified repository source: ${path}`); + if (nonRuntimeFiles.has(path) || declaration.test(path)) return 'other'; + if ( + !sourceExtension.test(path) || + [...dependencyRoots, ...generatedOutputs].some((dir) => path.startsWith(dir + '/')) + ) + throw new Error(`Unclassified repository source: ${path}`); + return 'runtime'; +} + +// A narrow machine-file exception, never a blanket exemption for ignored formats. +export function omitIgnoredDemoLocalFile( + path: string, + ignored: (path: string) => boolean, +): boolean { + const name = posix.basename(path); + const environment = new Set([ + '.env', + '.env.local', + '.env.development', + '.env.test', + '.env.production', + '.env.development.local', + '.env.test.local', + '.env.production.local', + ]); + return ( + (environment.has(name) || + name === '.DS_Store' || + name === 'Thumbs.db' || + /\.(?:swp|log|suo|rsuser)$/.test(name)) && + ignored(path) + ); +} + +export type ExternalModuleResolver = (from: string, specifier: string, anchors: string[]) => string; + +function testSource(path: string): boolean { + return ( + path.startsWith('frontend/apps/web/src/test/') || /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(path) + ); +} + +/** A standalone exemption cannot hide a statically imported runtime helper. */ +export function productionDemoSources( + all: ReadonlyMap, + external?: ExternalModuleResolver, +): Map { + const refused = (path: string): never => { + throw new Error(`Unmodeled runtime import: ${path}`); + }; + const packages = [...all] + .filter(([path]) => /^frontend\/(?:apps|packages)\/[^/]+\/package\.json$/.test(path)) + .map(([path, source]) => ({ + root: posix.dirname(path), + manifest: JSON.parse(source), + })); + const generated = new Map(); + const serverConfig = all.get('frontend/apps/web/tsconfig.server.json'); + if (serverConfig) { + const config = JSON.parse(serverConfig); + const expected = ['src/server/ingress.ts', 'src/server/public-admission-runtime.ts']; + if ( + config.compilerOptions?.rootDir !== 'src/server' || + config.compilerOptions?.outDir !== '.server' || + JSON.stringify([...config.include].sort()) !== JSON.stringify(expected) + ) + refused('tsconfig.server.json'); + for (const input of expected) + generated.set( + `frontend/apps/web/.server/${posix.basename(input, '.ts')}.js`, + `frontend/apps/web/${input}`, + ); + } + const candidates = (base: string) => [ + base, + ...['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs', '.json'].flatMap((ext) => [ + base + ext, + posix.join(base, 'index' + ext), + ]), + ...(/\.[cm]?jsx?$/.test(base) + ? [base.replace(/\.[cm]?jsx?$/, '.ts'), base.replace(/\.[cm]?jsx?$/, '.tsx')] + : []), + ]; + const local = (base: string, from: string): string[] => { + if (base === 'frontend/apps/web/src/app/globals.css') return all.has(base) ? [] : refused(from); + base = generated.get(base) ?? base; + // Conservative union: native Node and TypeScript need not choose the same candidate. + const targets = candidates(base).filter((path) => all.has(path)); + if ( + !targets.length || + targets.some((target) => !sourceExtension.test(target) || declaration.test(target)) + ) + return refused(from); + return targets; + }; + const mapped = (value: unknown, root: string, from: string): string[] => { + if (typeof value === 'string' && value.startsWith('./')) + return local(posix.normalize(posix.join(root, value)), from); + if (value && typeof value === 'object' && !Array.isArray(value)) { + const values = Object.values(value); + if (values.length) return values.flatMap((value) => mapped(value, root, from)); + } + return refused(from); + }; + const resolveEdge = (edge: string, path: string, anchors: string[] = []): string[] => { + if (isBuiltin(edge)) return []; + if (/^(?:file:|\/)/.test(edge)) return refused(path); + if (edge.startsWith('.')) { + if (anchors.length) return refused(path); + const filename = fileURLToPath(new URL(edge, `file:///learnstack/${path}`)); + if (!filename.startsWith('/learnstack/')) return refused(path); + return local(filename.slice('/learnstack/'.length), path); + } + // Match actual supported tsconfig paths rather than treating alias spelling as external. + let directory = posix.dirname(path); + while (directory !== '.') { + const source = all.get(posix.join(directory, 'tsconfig.json')); + if (source) { + const config = JSON.parse(source); + if (config.compilerOptions?.baseUrl) return refused(path); + const aliases: string[] = []; + for (const [pattern, replacements] of Object.entries(config.compilerOptions?.paths ?? {})) { + const parts = pattern.split('*'); + if (parts.length > 2 || !Array.isArray(replacements) || replacements.length === 0) + return refused(path); + const match = + parts.length === 1 + ? edge === pattern + : edge.startsWith(parts[0]!) && edge.endsWith(parts[1]!); + if (!match) continue; + const capture = + parts.length === 1 ? '' : edge.slice(parts[0]!.length, edge.length - parts[1]!.length); + aliases.push( + ...replacements.flatMap((value) => { + if ( + typeof value !== 'string' || + !value.startsWith('./') || + value.split('*').length > 2 + ) + return refused(path); + return local( + posix.normalize(posix.join(directory, value.replace('*', capture))), + path, + ); + }), + ); + } + // Inherited alias maps are not silently ignored. The shipped inherited presets have none. + const checkExtends = (name: unknown, owner: string, seen = new Set()) => { + if (name === undefined) return; + if (typeof name !== 'string') return refused(path); + const targets = name.startsWith('.') + ? local(posix.normalize(posix.join(owner, name)), path) + : packages.flatMap((pkg) => + name.startsWith(pkg.manifest.name + '/') + ? mapped( + pkg.manifest.exports?.['./' + name.slice(pkg.manifest.name.length + 1)], + pkg.root, + path, + ) + : [], + ); + if (!targets.length) return refused(path); + for (const target of targets) { + if (seen.has(target)) return refused(path); + const inherited = JSON.parse(all.get(target)!); + if (inherited.compilerOptions?.paths || inherited.compilerOptions?.baseUrl) + return refused(path); + checkExtends(inherited.extends, posix.dirname(target), new Set([...seen, target])); + } + }; + checkExtends(config.extends, directory); + // Include every supported match instead of guessing TypeScript alias precedence. + if (aliases.length) return aliases; + break; + } + directory = posix.dirname(directory); + } + const matches = packages.filter( + (pkg) => edge === pkg.manifest.name || edge.startsWith(pkg.manifest.name + '/'), + ); + if (matches.length > 1) return refused(path); + const pkg = matches[0]; + if (pkg) { + const key = + edge === pkg.manifest.name ? '.' : './' + edge.slice(pkg.manifest.name.length + 1); + return mapped( + pkg.manifest.exports?.[key] ?? (key === '.' ? pkg.manifest.main : undefined), + pkg.root, + path, + ); + } + // #imports and unmodeled aliases require explicit modeling; bare names are not an exemption. + if (edge.startsWith('#') || !external) return refused(path); + const target = external(path, edge, anchors); + if (all.has(target)) return local(target, path); + if (![...dependencyRoots].some((root) => target.startsWith(root + '/'))) return refused(path); + return []; + }; + const result = new Map( + [...all].filter( + ([path]) => + sourceExtension.test(path) && + !declaration.test(path) && + !testSource(path) && + !exemptEntries.has(path), + ), + ); + for (const [path, source] of result) { + if (path.endsWith('.json')) continue; + const file = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true); + const imports: { name: string; anchors?: string[] }[] = []; + const resolveCall = (node: ts.Node): node is ts.CallExpression => + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'require' && + node.expression.name.text === 'resolve'; + const visit = (node: ts.Node) => { + if ( + ((ts.isImportDeclaration(node) && !node.importClause?.isTypeOnly) || + (ts.isExportDeclaration(node) && !node.isTypeOnly)) && + node.moduleSpecifier && + ts.isStringLiteralLike(node.moduleSpecifier) + ) + imports.push({ name: node.moduleSpecifier.text }); + if ( + ts.isImportEqualsDeclaration(node) && + !node.isTypeOnly && + ts.isExternalModuleReference(node.moduleReference) + ) { + const value = node.moduleReference.expression; + if (!value || !ts.isStringLiteralLike(value)) return refused(path); + imports.push({ name: value.text }); + } + if ( + ts.isCallExpression(node) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && node.expression.text === 'require') || + resolveCall(node)) + ) { + const value = node.arguments[0]; + if (value && resolveCall(value)) { + ts.forEachChild(node, visit); + return; + } + if (!value || !ts.isStringLiteralLike(value)) return refused(path); + const anchors: string[] = []; + if (node.arguments.length > 1) { + const options = node.arguments[1]; + if ( + !resolveCall(node) || + value.text.startsWith('.') || + !options || + !ts.isObjectLiteralExpression(options) || + options.properties.length !== 1 + ) + return refused(path); + const property = options.properties[0]; + if ( + !property || + !ts.isPropertyAssignment(property) || + property.name.getText(file) !== 'paths' || + !ts.isArrayLiteralExpression(property.initializer) || + !property.initializer.elements.length + ) + return refused(path); + for (const anchor of property.initializer.elements) { + if ( + !resolveCall(anchor) || + anchor.arguments.length !== 1 || + !anchor.arguments[0] || + !ts.isStringLiteralLike(anchor.arguments[0]) + ) + return refused(path); + anchors.push(anchor.arguments[0].text); + } + } + imports.push({ name: value.text, anchors }); + } + ts.forEachChild(node, visit); + }; + visit(file); + for (const edge of imports) + for (const target of resolveEdge(edge.name, path, edge.anchors)) + result.set(target, all.get(target)!); + } + return result; +} diff --git a/frontend/apps/web/src/test/demo-genericity.test.ts b/frontend/apps/web/src/test/demo-genericity.test.ts new file mode 100644 index 00000000..b1ad1c18 --- /dev/null +++ b/frontend/apps/web/src/test/demo-genericity.test.ts @@ -0,0 +1,512 @@ +// @vitest-environment node +import { execFileSync, spawnSync } from 'node:child_process'; +import { readdirSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { demoIdentityFindings } from './demo-genericity-analysis'; +import { + demoSourceClassification, + omitDemoSourceDirectory, + omitIgnoredDemoLocalFile, + productionDemoSources, +} from './demo-genericity-sources'; +import vocabulary from './fixtures/demo-identities.json'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..'); +function productionSources(): Map { + const inventory = execFileSync( + 'git', + [ + 'ls-files', + '--cached', + '--others', + '--exclude-standard', + '--exclude=.claude/worktrees/', + '-z', + ], + { cwd: root, encoding: 'utf8' }, + ) + .split('\0') + .filter(Boolean); + for (const path of inventory) demoSourceClassification(path); + const files = new Map(); + const visit = (directory: string) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + const name = relative(root, path).replaceAll('\\', '/'); + if ( + entry.isFile() && + omitIgnoredDemoLocalFile(name, (candidate) => { + const result = spawnSync('git', ['check-ignore', '--quiet', '--', candidate], { + cwd: root, + stdio: 'ignore', + }); + if (result.status !== 0 && result.status !== 1) + throw new Error('Git ignored-local-file admission failed'); + return result.status === 0; + }) + ) + continue; + if (entry.isSymbolicLink() && !omitDemoSourceDirectory(name)) + throw new Error(`Unmodeled production symlink: ${name}`); + if (entry.isDirectory()) { + if (!omitDemoSourceDirectory(name)) visit(path); + } else if (entry.isSymbolicLink()) { + if (!omitDemoSourceDirectory(name)) + throw new Error(`Unmodeled production symlink: ${name}`); + } else if ( + demoSourceClassification(name) === 'runtime' || + name === 'frontend/apps/web/src/app/globals.css' + ) { + files.set(name, readFileSync(path, 'utf8')); + } + } + }; + visit(join(root, 'frontend')); + visit(join(root, 'scripts')); + return productionDemoSources(files, (from, specifier, anchors) => { + let resolver = createRequire(join(root, from)); + for (const anchor of anchors) resolver = createRequire(resolver.resolve(anchor)); + return relative(root, resolver.resolve(specifier)).replaceAll('\\', '/'); + }); +} + +describe('P02d-7 exact demo identity boundary', () => { + it('Production_Code_Does_Not_Branch_On_Demo_Tenant_Literals', () => { + const files = productionSources(); + expect(files.size).toBeGreaterThan(50); + for (const path of [ + 'frontend/apps/web/src/middleware.ts', + 'frontend/apps/web/src/server/ingress.ts', + 'frontend/apps/web/src/server/configured-public-client.ts', + 'frontend/apps/web/src/components/public/lesson.tsx', + 'frontend/apps/web/src/i18n/messages/en/public.json', + 'frontend/packages/sdk/src/server.ts', + 'frontend/apps/web/scripts/public-server.mjs', + 'frontend/apps/web/scripts/development-hmr.mjs', + 'frontend/apps/web/scripts/stop-test-child.mjs', + 'scripts/demo.mjs', + ]) + expect(files.has(path), `Production census includes ${path}`).toBe(true); + expect( + [...files].flatMap(([path, source]) => + demoIdentityFindings(path, source, vocabulary).map((value) => `${path}: ${value}`), + ), + ).toEqual([]); + }); + + const identity = vocabulary.values.find((row) => row.kind === 'tenant-name')!.value; + const id = vocabulary.values.find((row) => row.kind === 'id')!.value; + it.each([ + 'src/components/public/lesson.tsx', + 'src/server/configured-public-client.ts', + 'packages/ui/src/helper.ts', + 'src/server/native.mjs', + 'src/server/legacy.js', + ])('refuses a planted branch in %s', (path) => { + expect( + demoIdentityFindings( + path, + `if (name === ${JSON.stringify(identity)}) specialize();`, + vocabulary, + ), + ).toEqual([identity]); + expect( + demoIdentityFindings( + path, + `// ${identity}\nif (name === 'independent') specialize();`, + vocabulary, + ), + ).toEqual([]); + }); + it('refuses decoded literals, static templates, JSON values and alternate UUID spellings', () => { + const escaped = `\\u${identity.charCodeAt(0).toString(16).padStart(4, '0')}${identity.slice(1)}`; + expect(demoIdentityFindings('probe.ts', `const x = '${escaped}';`, vocabulary)).toEqual([ + identity, + ]); + expect(demoIdentityFindings('probe.ts', `const x = \`${identity}\`;`, vocabulary)).toEqual([ + identity, + ]); + const parts = id.split('-'); + const bytes = (parts[3]! + parts[4]!).match(/../g)!; + const hex = `{0x${parts[0]},0x${parts[1]},0x${parts[2]},{${bytes.map((byte) => `0x${byte}`).join(',')}}}`; + for (const value of [ + id, + id.toUpperCase(), + id.replaceAll('-', ''), + `{${id}}`, + `(${id})`, + hex, + ` ${id} `, + ]) + expect(demoIdentityFindings('probe.ts', `const x = '${value}';`, vocabulary)).toEqual([ + value, + ]); + expect( + demoIdentityFindings('messages.json', JSON.stringify({ title: identity }), vocabulary), + ).toEqual([identity]); + expect( + demoIdentityFindings( + 'probe.ts', + `const x = ${JSON.stringify(JSON.stringify({ title: identity }))};`, + vocabulary, + ), + ).toEqual([identity]); + }); + it('preserves canonical platform values and ordinal complete-value matching', () => { + for (const value of vocabulary.platformValues) + expect( + demoIdentityFindings('probe.ts', `const x = ${JSON.stringify(value)};`, vocabulary), + ).toEqual([]); + for (const value of [identity.toUpperCase(), `${identity} suffix`, `prefix ${identity}`]) + expect( + demoIdentityFindings('probe.ts', `const x = ${JSON.stringify(value)};`, vocabulary), + ).toEqual([]); + }); + it('reads emitted JSX text, attributes and entities without decoding ordinary strings', () => { + for (const body of [ + `

${identity}

`, + `

generic

`, + `

generic

`, + `

\n ${identity}\n

`, + ]) + expect(demoIdentityFindings('probe.tsx', `const view = ${body};`, vocabulary)).toEqual([ + identity, + ]); + expect( + demoIdentityFindings('probe.tsx', 'const view =

independent

;', vocabulary), + ).toEqual([]); + expect( + demoIdentityFindings( + 'probe.tsx', + `const value = "${identity.replace(' ', ' ')}";`, + vocabulary, + ), + ).toEqual([]); + }); + it('keeps nested runtime fixture helpers and follows runtime imports into test exclusions', () => { + const entry = 'frontend/apps/web/scripts/public-server.mjs'; + for (const helper of [ + 'fixtures/select-theme.mjs', + 'test/select-theme.mjs', + 'select-theme.test.mjs', + '../src/test/select-theme.ts', + '../src/dist/select-theme.ts', + 'coverage/select-theme.mjs', + 'verify-public-rendering.mjs', + ]) { + const target = relative(root, resolve(root, dirname(entry), helper)).replaceAll('\\', '/'); + const files = productionDemoSources( + new Map([ + [entry, `import { special } from './${helper}';`], + [target, `export const special = name => name === ${JSON.stringify(identity)};`], + [ + 'frontend/apps/web/src/test/unrelated.test.ts', + `const fixture = ${JSON.stringify(identity)};`, + ], + ]), + ); + expect(files.has(entry)).toBe(true); + expect(files.has(target)).toBe(true); + expect(files.has('frontend/apps/web/src/test/unrelated.test.ts')).toBe(false); + expect( + [...files].flatMap(([path, source]) => demoIdentityFindings(path, source, vocabulary)), + ).toEqual([identity]); + } + }); + it('resolves Node module URL suffixes and refuses computed runtime imports', () => { + const entry = 'frontend/apps/web/scripts/public-server.mjs'; + const target = 'frontend/apps/web/src/test/special.mjs'; + for (const suffix of ['?runtime=1', '#runtime', '?a=1#runtime']) { + const files = productionDemoSources( + new Map([ + [entry, `import '../src/test/special.mjs${suffix}';`], + [target, `export const special = ${JSON.stringify(identity)};`], + ]), + ); + expect(files.has(target)).toBe(true); + } + const resolved = productionDemoSources( + new Map([ + [entry, "require(require.resolve('../src/test/special.mjs'));"], + [target, `export const special = ${JSON.stringify(identity)};`], + ]), + ); + expect(resolved.has(target)).toBe(true); + expect(() => + productionDemoSources( + new Map([ + [ + entry, + "require(require.resolve('eslint-plugin-jsx-a11y', { paths: [require.resolve('eslint-config-next')] }));", + ], + ]), + (_from, name) => `frontend/node_modules/${name}/index.js`, + ), + ).not.toThrow(); + for (const source of [ + "const path = './special.test.mjs'; await import(path);", + "const path = './special.test.mjs'; require(path);", + "const path = './special.test.mjs'; require(require.resolve(path));", + "require(require.resolve('./fixtures/demo-identities.json', { paths: [new URL('../src/test', import.meta.url).pathname] }));", + "await import('file:///outside/helper.mjs');", + ]) + expect(() => productionDemoSources(new Map([[entry, source]]))).toThrow( + 'Unmodeled runtime import', + ); + }); + it('omits only known generated roots in the actual filesystem collector', () => { + for (const path of [ + 'frontend/apps/web/.next', + 'frontend/packages/sdk/dist', + 'frontend/node_modules', + ]) + expect(omitDemoSourceDirectory(path)).toBe(true); + for (const path of [ + 'frontend/apps/web/src/dist', + 'frontend/apps/web/scripts/coverage', + 'frontend/apps/web/src/.server', + 'scripts/node_modules', + 'frontend/apps/web/src/node_modules', + ]) + expect(omitDemoSourceDirectory(path)).toBe(false); + }); + it('omits only ignored known machine files and retains ignored source and unknown formats', () => { + for (const name of ['.env.local', '.DS_Store', 'Thumbs.db', 'editor.swp', 'runtime.log']) { + const path = `frontend/apps/web/${name}`; + expect(omitIgnoredDemoLocalFile(path, () => true)).toBe(true); + expect(omitIgnoredDemoLocalFile(path, () => false)).toBe(false); + const result = spawnSync('git', ['check-ignore', '--quiet', '--', path], { + cwd: root, + stdio: 'ignore', + }); + expect(result.status).toBe(0); + } + for (const name of [ + '.env.hidden.ts', + '.env.hidden.json', + 'hidden.vue', + 'hidden.resx', + 'Probe.csproj.user', + ]) + expect(omitIgnoredDemoLocalFile(`frontend/apps/web/${name}`, () => true)).toBe(false); + // MSBuild automatically imports this ignored format; it is not inert machine data. + expect( + spawnSync('git', ['check-ignore', '--quiet', '--', 'frontend/apps/web/Probe.csproj.user'], { + cwd: root, + stdio: 'ignore', + }).status, + ).toBe(0); + expect(() => demoSourceClassification('frontend/apps/web/hidden.vue')).toThrow('Unclassified'); + }); + it('refuses unclassified production roots and formats while retaining declared non-runtime ownership', () => { + for (const path of [ + 'shared/probe.mjs', + 'frontend/apps/web/src/probe.vue', + 'scripts/probe.runtime', + 'backend/feature/Probe.cs', + ]) + expect(() => demoSourceClassification(path)).toThrow('Unclassified'); + for (const path of [ + 'scripts/demo.mjs', + 'scripts/node_modules/probe/index.js', + 'frontend/apps/web/src/test/probe.test.ts', + ]) + expect(demoSourceClassification(path)).toBe('runtime'); + for (const path of [ + 'README.md', + 'docs/example.md', + 'scripts/seed.sh', + 'frontend/apps/web/src/app/globals.css', + ]) + expect(demoSourceClassification(path)).toBe('other'); + }); + it('follows aliases, workspace exports, import-equals and transitive re-exports into exemptions', () => { + const entry = 'frontend/apps/web/src/server/probe.ts'; + const helper = 'frontend/apps/web/src/test/helper.ts'; + const configHelper = 'frontend/packages/config/helper.test.mjs'; + for (const source of [ + "import '@/test/helper';", + "export * from '@/test/helper';", + "import helper = require('@/test/helper');", + "require.resolve('@/test/helper');", + ]) { + const files = productionDemoSources( + new Map([ + ['frontend/apps/web/tsconfig.json', '{"compilerOptions":{"paths":{"@/*":["./src/*"]}}}'], + [ + 'frontend/packages/config/package.json', + '{"name":"@learnstack/config","exports":{"./helper":"./helper.test.mjs"}}', + ], + [entry, source], + [helper, "export * from '@learnstack/config/helper';"], + [configHelper, `export const branch = ${JSON.stringify(identity)};`], + ]), + ); + expect(files.has(helper)).toBe(true); + expect(files.has(configHelper)).toBe(true); + expect( + [...files].flatMap(([path, source]) => demoIdentityFindings(path, source, vocabulary)), + ).toEqual([identity]); + } + }); + it('includes ambiguous alias and native JS/TS targets and ignores fixture package identities', () => { + const entry = 'frontend/apps/web/src/server/probe.ts'; + const alias = 'frontend/apps/web/src/test/dirty.ts'; + const native = 'frontend/apps/web/src/test/native.js'; + const workspace = 'frontend/packages/sdk/src/runtime.test.ts'; + const files = productionDemoSources( + new Map([ + [ + 'frontend/apps/web/tsconfig.json', + JSON.stringify({ + compilerOptions: { + paths: { + '@/*': ['./src/*'], + '@/special/*': ['./src/test/*'], + }, + }, + }), + ], + [entry, "import '@/special/dirty'; import '@learnstack/sdk/runtime';"], + ['frontend/apps/web/src/special/dirty.ts', 'export const clean = true;'], + [alias, `export const special = ${JSON.stringify(identity)};`], + ['scripts/probe.cjs', "require('../frontend/apps/web/src/test/native');"], + ['frontend/apps/web/src/test/native.ts', 'export const clean = true;'], + [native, `exports.special = ${JSON.stringify(identity)};`], + [ + 'frontend/apps/web/src/test/package-fixture/package.json', + JSON.stringify({ + name: '@learnstack/sdk', + exports: { './runtime': './clean.ts' }, + }), + ], + ['frontend/apps/web/src/test/package-fixture/clean.ts', 'export const clean = true;'], + [ + 'frontend/packages/sdk/package.json', + JSON.stringify({ + name: '@learnstack/sdk', + exports: { './runtime': './src/runtime.test.ts' }, + }), + ], + [workspace, `export const special = ${JSON.stringify(identity)};`], + ]), + ); + for (const path of [alias, native, workspace]) { + expect(files.has(path)).toBe(true); + expect(demoIdentityFindings(path, files.get(path)!, vocabulary)).toEqual([identity]); + } + for (const path of [alias, native, workspace]) files.set(path, 'export const clean = true;'); + expect( + [...files].flatMap(([path, source]) => demoIdentityFindings(path, source, vocabulary)), + ).toEqual([]); + expect(() => + productionDemoSources( + new Map([ + [entry, "import '@learnstack/sdk/runtime';"], + ['frontend/packages/sdk/package.json', '{"name":"@learnstack/sdk"}'], + ['frontend/packages/duplicate/package.json', '{"name":"@learnstack/sdk"}'], + ]), + ), + ).toThrow('Unmodeled'); + }); + it('refuses missing, unknown-format, inherited-alias and untrusted external edges', () => { + const entry = 'frontend/apps/web/scripts/entry.mjs'; + for (const edge of ['./missing.mjs', './helper.runtime', 'unknown-package', '#local']) + expect(() => + productionDemoSources( + new Map([ + [entry, `require(${JSON.stringify(edge)})`], + ['frontend/apps/web/scripts/helper.runtime', `specialize('${identity}')`], + ]), + ), + ).toThrow('Unmodeled'); + expect(() => + productionDemoSources( + new Map([[entry, "import 'linked-package';"]]), + () => '../foreign/index.js', + ), + ).toThrow('Unmodeled'); + expect(() => + productionDemoSources( + new Map([ + [entry, "import 'external-package';"], + ['frontend/apps/web/tsconfig.json', '{"extends":"./src/test/base"}'], + ['frontend/apps/web/src/test/base.ts', '{}'], + [ + 'frontend/apps/web/src/test/base.json', + '{"compilerOptions":{"paths":{"hidden/*":["./*"]}}}', + ], + ]), + () => 'frontend/node_modules/external-package/index.js', + ), + ).toThrow('Unmodeled'); + const helper = 'frontend/apps/web/src/test/helper.ts'; + expect( + productionDemoSources( + new Map([ + [entry, "import 'linked-package';"], + [helper, 'export const clean = 1;'], + ]), + () => helper, + ).has(helper), + ).toBe(true); + expect(() => + productionDemoSources( + new Map([ + [entry, "import 'external-package';"], + ['frontend/apps/web/tsconfig.json', '{"extends":"./base.json"}'], + [ + 'frontend/apps/web/base.json', + '{"compilerOptions":{"paths":{"hidden/*":["./src/test/*"]}}}', + ], + ]), + () => 'frontend/node_modules/external-package/index.js', + ), + ).toThrow('Unmodeled'); + }); + it('admits only the validated native generated-output mapping and known stylesheet', () => { + const entry = 'scripts/demo.mjs'; + const source = 'frontend/apps/web/src/server/ingress.ts'; + const config = { + compilerOptions: { rootDir: 'src/server', outDir: '.server' }, + include: ['src/server/ingress.ts', 'src/server/public-admission-runtime.ts'], + }; + const files = new Map([ + [ + entry, + "import '../frontend/apps/web/.server/ingress.js'; import '../frontend/apps/web/src/app/globals.css';", + ], + [source, 'export const clean = true;'], + ['frontend/apps/web/src/app/globals.css', ':root { color: black }'], + ['frontend/apps/web/tsconfig.server.json', JSON.stringify(config)], + ]); + expect(productionDemoSources(files).has(source)).toBe(true); + files.set( + 'frontend/apps/web/tsconfig.server.json', + JSON.stringify({ + ...config, + compilerOptions: { ...config.compilerOptions, outDir: 'unmodeled' }, + }), + ); + expect(() => productionDemoSources(files)).toThrow('Unmodeled'); + }); + it('fails loudly on unreadable sources and an empty or malformed vocabulary', () => { + expect(() => demoIdentityFindings('probe.ts', 'const = ;', vocabulary)).toThrow('Unreadable'); + expect(() => demoIdentityFindings('probe.json', '{', vocabulary)).toThrow(); + expect(() => demoIdentityFindings('probe.py', 'pass', vocabulary)).toThrow('Unsupported'); + expect(() => demoIdentityFindings('probe.ts', '', { ...vocabulary, values: [] })).toThrow( + 'empty', + ); + expect(() => + demoIdentityFindings('probe.ts', '', { + ...vocabulary, + values: [{ kind: 'id', value: 'bad' }], + }), + ).toThrow('malformed'); + }); +}); diff --git a/frontend/apps/web/src/test/demo-lifecycle.test.ts b/frontend/apps/web/src/test/demo-lifecycle.test.ts new file mode 100644 index 00000000..b7e68af7 --- /dev/null +++ b/frontend/apps/web/src/test/demo-lifecycle.test.ts @@ -0,0 +1,747 @@ +// @vitest-environment node +import { execFileSync, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer as createHttpsServer } from 'node:https'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { composeSelection } from '../../../../../scripts/compose.mjs'; +import { boundedBody, demoRequest, vacant } from '../../../../../scripts/demo-http.mjs'; +import { + apiCompletionObserver, + finalCompletionCensus, +} from '../../../../../scripts/demo-observation.mjs'; +import { DemoProcesses } from '../../../../../scripts/demo-process.mjs'; +import { + disposeDemoResources, + isApiListenerEvent, + validateCertificate, +} from '../../../../../scripts/demo.mjs'; +import { + publicApiEnvironment, + publicDemoApiEnvironment, + publicWebEnvironment, +} from '../../../../../scripts/public-runtime.mjs'; +import { defaultServices, healthyServices } from '../../../../../scripts/wait-compose.mjs'; + +const roots: string[] = []; +const owners: DemoProcesses[] = []; +afterEach(async () => { + await Promise.all(owners.splice(0).map((owner) => owner.dispose())); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); +function temporary() { + const root = mkdtempSync(join(tmpdir(), 'learnstack-demo-control-')); + roots.push(root); + return root; +} +function owner(signal = new AbortController().signal) { + const processes = new DemoProcesses(signal); + owners.push(processes); + return processes; +} + +describe('shared explicit demo stack selection', () => { + it.each([ + 'COMPOSE_PROFILES=gated', + ' export COMPOSE_PROFILES = gated', + 'COMPOSE_FILE: foreign.yml', + 'COMPOSE_PROJECT_NAME=foreign', + 'COMPOSE_ENV_FILES=foreign.env', + ])('refuses a selected dotenv Compose control before Docker: %s', (assignment) => { + const root = temporary(); + writeFileSync(join(root, '.env'), `POSTGRES_PASSWORD=private\n${assignment}\n`); + expect(() => composeSelection('dev', {}, root)).toThrow('Reserved COMPOSE_*'); + writeFileSync(join(root, '.env'), '# COMPOSE_PROFILES=gated\nPOSTGRES_PASSWORD=private\n'); + expect(() => composeSelection('dev', {}, root)).not.toThrow(); + }); + it('refuses stale ambient seed credentials before stack or database actions', () => { + const root = temporary(); + mkdirSync(join(root, 'scripts')); + const script = join(root, 'scripts/seed.sh'); + writeFileSync(script, readFileSync(new URL('../../../../../scripts/seed.sh', import.meta.url))); + const migration = 'Host=localhost;Username=learnstack_migration'; + const app = 'Host=localhost;Username=learnstack_app'; + writeFileSync( + join(root, 'selected.env'), + `ConnectionStrings__Migration=${migration}\nConnectionStrings__Default=${app}\n`, + ); + const env = { + PATH: process.env.PATH, + LEARNSTACK_COMPOSE_ENV_FILE: 'selected.env', + ConnectionStrings__Migration: migration, + ConnectionStrings__Default: app, + }; + const run = (environment: Record) => + spawnSync('bash', [script, '--validate-config'], { + cwd: root, + env: { ...environment, NODE_ENV: 'test' }, + encoding: 'utf8', + timeout: 5000, + }); + expect(run(env).status).toBe(0); + for (const key of ['ConnectionStrings__Migration', 'ConnectionStrings__Default']) { + const result = run({ ...env, [key]: 'Host=foreign;Username=stale' }); + expect(result.error).toBeUndefined(); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('disagrees'); + expect(result.stderr).not.toContain('foreign'); + } + }); + it('launches the argument-free Seeder from the documented shell path', () => { + const root = temporary(); + for (const directory of ['scripts', 'backend', 'bin']) mkdirSync(join(root, directory)); + for (const file of ['seed.sh', 'connection-string.awk']) + writeFileSync( + join(root, 'scripts', file), + readFileSync(new URL(`../../../../../scripts/${file}`, import.meta.url)), + ); + writeFileSync( + join(root, '.env'), + 'ConnectionStrings__Migration=Username=learnstack_migration\nConnectionStrings__Default=Username=learnstack_app\n', + ); + for (const [name, body] of Object.entries({ + docker: '[ "$*" = "compose version" ]', + node: '[ "$*" = "scripts/wait-compose.mjs" ]', + dotnet: + '[ "$#" = 3 ] && [ "$1" = run ] && [ "$2" = --project ] && [ "$3" = src/LearnStack.Tools.Seeder ] && [ "$PWD" = "$TEST_ROOT/backend" ] && [ "$ConnectionStrings__Default" = Username=learnstack_app ] && printf launched > "$TEST_ROOT/launched"', + })) + writeFileSync(join(root, 'bin', name), `#!/bin/sh\n${body}\n`, { mode: 0o700 }); + const launch = () => + spawnSync('bash', [join(root, 'scripts/seed.sh')], { + cwd: root, + env: { + PATH: `${join(root, 'bin')}:${process.env.PATH}`, + TEST_ROOT: root, + NODE_ENV: 'test', + }, + encoding: 'utf8', + timeout: 5000, + }); + expect(launch().status).toBe(0); + expect(readFileSync(join(root, 'launched'), 'utf8')).toBe('launched'); + const script = join(root, 'scripts/seed.sh'); + writeFileSync( + script, + readFileSync(script, 'utf8').replace( + 'dotnet run --project src/LearnStack.Tools.Seeder', + 'dotnet run --project src/LearnStack.Tools.Seeder --nologo', + ), + ); + const refused = launch(); + expect(refused.status).not.toBe(0); + expect(refused.stderr).toContain('tenant seeding failed'); + }); + it('reads the selected migration env file and refuses its runtime role before any database act', () => { + const root = temporary(); + writeFileSync( + join(root, 'Makefile'), + readFileSync(new URL('../../../../../Makefile', import.meta.url)), + ); + mkdirSync(join(root, 'scripts')); + writeFileSync( + join(root, 'scripts/connection-string.awk'), + readFileSync(new URL('../../../../../scripts/connection-string.awk', import.meta.url)), + ); + writeFileSync( + join(root, '.env'), + 'ConnectionStrings__Migration=Username=learnstack_platform\n', + ); + writeFileSync( + join(root, 'alternate.env'), + 'ConnectionStrings__Migration=Username=learnstack_app\n', + ); + const result = spawnSync('make', ['migrate'], { + cwd: root, + env: { + PATH: process.env.PATH, + NODE_ENV: 'test', + LEARNSTACK_COMPOSE_ENV_FILE: 'alternate.env', + }, + encoding: 'utf8', + timeout: 5000, + }); + expect(result.error).toBeUndefined(); + expect(result.status).not.toBe(0); + expect(result.stdout).toContain("Username='learnstack_app'"); + expect(result.stdout).not.toContain("Username='learnstack_platform'"); + }); + it('accepts only the owned API address in a structured hosting lifetime event', () => { + const origin = 'http://127.0.0.1:5080'; + const event = { SourceContext: 'Microsoft.Hosting.Lifetime', address: origin }; + expect(isApiListenerEvent(JSON.stringify(event), origin)).toBe(true); + expect(isApiListenerEvent(JSON.stringify({ ...event, address: origin + '1' }), origin)).toBe( + false, + ); + expect(isApiListenerEvent(JSON.stringify({ ...event, SourceContext: 'foreign' }), origin)).toBe( + false, + ); + expect(isApiListenerEvent(`Now listening on: ${origin}`, origin)).toBe(false); + expect(isApiListenerEvent('null', origin)).toBe(false); + }); + it('keeps project/files/env stable and removes inherited Compose and dotenv shadows', () => { + const root = temporary(); + writeFileSync(join(root, '.env'), 'POSTGRES_PASSWORD=private-file-value\n'); + const selected = composeSelection( + 'e2e', + { + PATH: '/bin', + COMPOSE_PROFILES: 'gated', + COMPOSE_FILE: 'foreign.yml', + COMPOSE_PROJECT_NAME: 'foreign', + POSTGRES_PASSWORD: 'ambient-shadow', + LEARNSTACK_COMPOSE_PROJECT: 'owned-demo', + }, + root, + ); + expect(selected.project).toBe('owned-demo'); + expect(selected.args).toEqual([ + 'compose', + '--project-name', + 'owned-demo', + '--env-file', + join(root, '.env'), + '-f', + join(root, 'infra/compose/dev.yml'), + '-f', + join(root, 'infra/compose/e2e.yml'), + ]); + expect(selected.env).not.toHaveProperty('COMPOSE_PROFILES'); + expect(selected.env).not.toHaveProperty('COMPOSE_FILE'); + expect(selected.env).not.toHaveProperty('COMPOSE_PROJECT_NAME'); + expect(selected.env).not.toHaveProperty('POSTGRES_PASSWORD'); + expect(selected.env.PATH).toBe('/bin'); + expect(Object.isFrozen(selected.args)).toBe(true); + }); + it('refuses missing environment, unsupported mode and unsafe project names', () => { + const root = temporary(); + expect(() => composeSelection('dev', {}, root)).toThrow(); + writeFileSync(join(root, '.env'), 'A=B\n'); + expect(() => composeSelection('unknown', {}, root)).toThrow(); + expect(() => + composeSelection('dev', { LEARNSTACK_COMPOSE_PROJECT: '../foreign' }, root), + ).toThrow(); + }); + it('waits for postgres first, then refuses missing/extra/unhealthy full-stack rows', () => { + const names = ['postgres', 'keycloak', 'one', 'two', 'three', 'four', 'five']; + const model = { + services: Object.fromEntries(names.map((name) => [name, { healthcheck: {} }])), + }; + const rows = names.map((name) => ({ Service: name, State: 'running', Health: 'healthy' })); + const withGated = { services: { ...model.services, gated: { profiles: ['gated'] } } }; + expect(defaultServices(withGated)).toEqual(names); + expect(healthyServices(withGated, rows)).toBe(true); + expect(healthyServices(model, rows.slice(0, 1), true)).toBe(true); + expect(healthyServices(model, rows)).toBe(true); + expect(healthyServices(model, rows.slice(0, -1))).toBe(false); + expect( + healthyServices(model, [...rows, { Service: 'orphan', State: 'running', Health: 'healthy' }]), + ).toBe(false); + expect( + healthyServices( + model, + rows.map((row) => ({ ...row, Health: '' })), + ), + ).toBe(false); + expect(() => healthyServices({ services: {} }, [])).toThrow(); + }); +}); + +describe('demo runtime boundaries', () => { + it('isolates the Development API user-secret profile within the invocation', () => { + const root = temporary(); + const env = publicDemoApiEnvironment( + { ConnectionStrings__Default: 'application' }, + { apiOrigin: 'http://127.0.0.1:5080', secret: 'private' }, + root, + { + PATH: '/bin', + HOME: '/foreign-home', + APPDATA: '/foreign-secrets', + DOTNET_CLI_HOME: '/foreign', + }, + ); + expect(env.HOME).toBe(realpathSync(root)); + expect(env.APPDATA).toBe(realpathSync(root)); + expect(env.TMPDIR).toBe(realpathSync(root)); + expect(env).not.toHaveProperty('DOTNET_CLI_HOME'); + expect(env.ConnectionStrings__Default).toBe('application'); + expect(env.ASPNETCORE_ENVIRONMENT).toBe('Development'); + }); + it('gives native web addons an owned cache without ambient credentials or diagnostics', () => { + const root = temporary(); + const values = { + LEARNSTACK_PUBLIC_API_ORIGIN: 'http://127.0.0.1:5080', + LEARNSTACK_PUBLIC_HOP_SECRET: 'private', + LEARNSTACK_PUBLIC_TLS_CERT: '/owned/cert', + LEARNSTACK_PUBLIC_TLS_KEY: '/owned/key', + ConnectionStrings__Migration: 'owner', + }; + expect( + publicWebEnvironment(values, root, { PATH: '/bin', DEBUG: '*', HOME: '/foreign' }), + ).toEqual({ + PATH: '/bin', + TMPDIR: root, + SWC_NATIVE_BINDING_CACHE: join(realpathSync(root), 'swc-native-cache'), + NODE_ENV: 'production', + NEXT_TELEMETRY_DISABLED: '1', + NODE_TLS_REJECT_UNAUTHORIZED: '1', + ...Object.fromEntries( + Object.entries(values).filter(([key]) => key.startsWith('LEARNSTACK_PUBLIC_')), + ), + }); + }); + it('passes only request runtime credentials and explicit hop configuration', () => { + const env = publicApiEnvironment( + { ConnectionStrings__Default: 'application' }, + { apiOrigin: 'http://127.0.0.1:5080', secret: 'private' }, + { + PATH: '/bin', + ConnectionStrings__Migration: 'owner', + ConnectionStrings__OutboxDispatcher: 'worker', + LEARNSTACK_MIGRATION_PW: 'owner', + ASPNETCORE_FORWARDEDHEADERS_ENABLED: 'true', + Tenancy__TrustedHop__Secrets__1: 'stale', + }, + ); + expect(env).toEqual({ + PATH: '/bin', + ConnectionStrings__Default: 'application', + ASPNETCORE_ENVIRONMENT: 'Development', + ASPNETCORE_URLS: 'http://127.0.0.1:5080', + Tenancy__TrustedHop__Networks__0: '127.0.0.1/32', + Tenancy__TrustedHop__Secrets__0: 'private', + }); + }); + it('refuses a foreign listener without closing it', async () => { + const server = createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address === null || typeof address === 'string') throw new Error('Missing listener'); + try { + await expect(vacant(address.port)).rejects.toThrow('occupied'); + expect(server.listening).toBe(true); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + await expect(vacant(address.port)).resolves.toBeUndefined(); + }); + it('bounds response bodies and cancels oversized input', async () => { + await expect(boundedBody(new Response('ready'), 5)).resolves.toBe('ready'); + await expect(boundedBody(new Response('too large'), 3)).rejects.toThrow('bound'); + }); + it('requires matching, valid TLS and keeps hostname checks with a loopback resolver', async () => { + const root = temporary(); + const cert = join(root, 'cert.pem'); + const key = join(root, 'key.pem'); + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + key, + '-out', + cert, + '-days', + '1', + '-subj', + '/CN=demo.test', + '-addext', + 'subjectAltName=DNS:demo.test', + ], + { stdio: 'ignore' }, + ); + expect( + validateCertificate(readFileSync(cert), readFileSync(key), ['demo.test']).checkHost( + 'demo.test', + ), + ).toBe('demo.test'); + expect(() => + validateCertificate(readFileSync(cert), readFileSync(key), ['other.test']), + ).toThrow(); + const otherKey = join(root, 'other-key.pem'); + execFileSync( + 'openssl', + ['genpkey', '-algorithm', 'RSA', '-pkeyopt', 'rsa_keygen_bits:2048', '-out', otherKey], + { stdio: 'ignore' }, + ); + expect(() => + validateCertificate(readFileSync(cert), readFileSync(otherKey), ['demo.test']), + ).toThrow(); + expect(() => + validateCertificate( + readFileSync(cert), + readFileSync(key), + ['demo.test'], + Date.now() + 2 * 86400000, + ), + ).toThrow(); + const server = createHttpsServer( + { cert: readFileSync(cert), key: readFileSync(key) }, + (_request, response) => response.end('ready'), + ); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address === null || typeof address === 'string') throw new Error('Missing TLS listener'); + const options = { + ca: readFileSync(cert), + signal: new AbortController().signal, + loopback: true, + }; + try { + const url = `https://demo.test:${address.port}/`; + expect((await demoRequest(url, options)).body).toBe('ready'); + await expect(demoRequest(url, { ...options, ca: [] })).rejects.toThrow(); + await expect(demoRequest(`https://other.test:${address.port}/`, options)).rejects.toThrow(); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); + +describe('owned real process lifecycle', () => { + it('captures split multibyte stdout intact and bounds accumulated bytes', async () => { + const processes = owner(); + let observed: (chunk: Buffer) => void = () => {}; + const first = new Promise((resolve) => { + observed = resolve; + }); + const running = processes.start( + process.execPath, + [ + '-e', + ` + const text=Buffer.from('Ağaç duruşu'); + const keepAlive=setInterval(()=>{},1000); + process.once('SIGUSR2',()=>{clearInterval(keepAlive);process.stdout.write(text.subarray(2));}); + process.stdout.write(text.subarray(0,2)); + `, + ], + { capture: true, label: 'Split UTF-8 control', observe: observed }, + ); + expect((await first).length).toBe(2); + running.child.kill('SIGUSR2'); + expect((await running.exit).code).toBe(0); + expect(running.result().output).toBe('Ağaç duruşu'); + await processes.dispose(); + await expect( + owner().run( + process.execPath, + ['-e', "process.stdout.write(Buffer.from('ğ'.repeat(1024*1024+1)));"], + { capture: true, label: 'Byte bound control' }, + ), + ).rejects.toThrow(); + }); + it('routes real terminal hangup through the supervisor cancellation path', async () => { + const processes = owner(); + let ready: () => void = () => {}; + const started = new Promise((resolve) => { + ready = resolve; + }); + const moduleUrl = new URL('../../../../../scripts/demo-process.mjs', import.meta.url).href; + const running = processes.start( + process.execPath, + [ + '--input-type=module', + '-e', + ` + import {installDemoCancellation} from ${JSON.stringify(moduleUrl)}; + const remove=installDemoCancellation(()=>{console.log('cancelled');remove();}); + console.log('ready'); + `, + ], + { + capture: true, + observe: (chunk) => { + if (chunk.toString().includes('ready')) ready(); + }, + }, + ); + await started; + running.child.kill('SIGHUP'); + expect((await running.exit).code).toBe(0); + expect(running.result().output).toContain('cancelled'); + }); + it('cancels when the actual parent exits and leaves no owned group behind', async () => { + const moduleUrl = new URL('../../../../../scripts/demo-process.mjs', import.meta.url).href; + const watcher = ` + import {installDemoCancellation} from ${JSON.stringify(moduleUrl)}; + const remove=installDemoCancellation(()=>{console.log('parent-loss handled');remove();}); + process.send('ready'); + `; + const output = await owner().run( + process.execPath, + [ + '--input-type=module', + '-e', + ` + import {spawn} from 'node:child_process'; + const child=spawn(process.execPath,['--input-type=module','-e',${JSON.stringify(watcher)}], + {stdio:['ignore','inherit','inherit','ipc']}); + child.once('message',()=>process.exit(0)); + `, + ], + { capture: true, label: 'Parent-loss control', timeout: 3000 }, + ); + expect(output).toContain('parent-loss handled'); + }); + it('joins a daemon and its descendant, and closes ownership against later spawns', async () => { + const processes = owner(); + let release: (pid: number) => void = () => {}; + const ready = new Promise((resolve) => { + release = resolve; + }); + let output = ''; + const running = processes.daemon( + process.execPath, + [ + '-e', + ` + const {spawn}=require('node:child_process'); + const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'}); + let stopping=false; + const finish=()=>{if(stopping) process.exit(0);}; + child.once('exit',finish); + process.on('SIGTERM',()=>{ + stopping=true; + if(child.exitCode!==null || child.signalCode!==null) finish(); + else child.kill('SIGTERM'); + }); + console.log(child.pid); + setInterval(()=>{},1000); + `, + ], + { + label: 'tree', + observe: (chunk) => { + output += chunk.toString(); + if (output.includes('\n')) release(Number(output.trim())); + }, + }, + ); + const descendant = await ready; + expect(Number.isInteger(descendant)).toBe(true); + expect(() => process.kill(descendant, 0)).not.toThrow(); + await processes.dispose(); + await running.exit; + expect(running.child.exitCode).toBe(0); + expect(() => process.kill(descendant, 0)).toThrow(); + expect(() => processes.start(process.execPath, ['-e', 'process.exit(0)'])).toThrow('closed'); + }); + it('joins deferred stdout through child close before finalizing completion counts', async () => { + const processes = owner(); + const observer = apiCompletionObserver(); + const record = (status: number) => + JSON.stringify({ + SourceContext: 'Microsoft.AspNetCore.Hosting.Diagnostics', + EventId: { Id: 2 }, + Path: '/api/v1/public/site', + StatusCode: status, + Method: 'GET', + }) + '\n'; + const running = processes.start( + process.execPath, + [ + '-e', + ` + const keepAlive = setInterval(() => {}, 1000); + process.once('SIGUSR2', () => { + clearInterval(keepAlive); + process.stdout.write(${JSON.stringify(record(429))}); + }); + process.stdout.write(${JSON.stringify(record(200))}); + `, + ], + { label: 'Deferred output control', observe: observer.consume }, + ); + const stream = running.child.stdout; + if (!stream) throw new Error('Owned child must have piped stdout'); + await once(stream, 'data'); + const beforeCleanup = structuredClone(observer.state); + expect(beforeCleanup.total).toBe(1); + // child_process auto-resumes at exit; retain the planted backpressure until + // we explicitly release it, after confirming cleanup waits for EOF. + const hold = () => stream.pause(); + stream.on('resume', hold); + stream.pause(); + let disposed = false; + let disposal: Promise | undefined; + try { + const exited = once(running.child, 'exit'); + running.child.kill('SIGUSR2'); + await exited; + disposal = processes.dispose().then(() => { + disposed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(disposed).toBe(false); + stream.off('resume', hold); + stream.resume(); + await disposal; + observer.finish(); + expect(observer.state.statuses[429]).toBe(1); + expect(() => finalCompletionCensus(beforeCleanup, observer.state, 1)).toThrow(); + } finally { + stream.off('resume', hold); + stream.resume(); + await disposal; + } + }); + it('captures a successful command and refuses its nonzero control', async () => { + const processes = owner(); + await expect( + processes.run(process.execPath, ['-e', 'process.stdout.write("ready")'], { + capture: true, + label: 'control', + }), + ).resolves.toBe('ready'); + await expect( + processes.run(process.execPath, ['-e', 'process.exit(2)'], { label: 'control' }), + ).rejects.toThrow('control failed'); + }); + it('aborts and joins a real setup child', async () => { + const controller = new AbortController(); + const processes = owner(controller.signal); + const running = processes.run(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { + label: 'setup', + }); + controller.abort(); + await expect(running).rejects.toThrow(); + await expect(processes.dispose()).resolves.toBeUndefined(); + }); + it('refuses a hung command at its deadline and a daemon that exits early', async () => { + const processes = owner(); + await expect( + processes.run(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { + timeout: 20, + label: 'hung', + }), + ).rejects.toThrow('hung failed'); + processes.daemon(process.execPath, ['-e', 'process.exit(1)'], { label: 'daemon' }); + await expect(processes.failure).resolves.toEqual( + expect.objectContaining({ message: 'demo: daemon exited failed' }), + ); + expect(() => processes.assertActive()).toThrow('daemon exited'); + }); +}); + +describe('independent owned cleanup attempts', () => { + it.each(['processes', 'observer', 'compose'])( + 'retains private state and failure after %s fails, without skipping other teardown', + async (stage) => { + const actions: string[] = []; + const cause = new Error('private diagnostic sentinel'); + const attempt = (name: string) => () => { + actions.push(name); + if (stage === name) throw cause; + }; + const error = await disposeDemoResources({ + processes: { dispose: async () => attempt('processes')() }, + finish: attempt('observer'), + teardown: attempt('compose'), + removePrivate: attempt('private'), + }).catch((error: unknown) => error); + expect(actions).toEqual(['processes', 'observer', 'compose']); + expect(error).toBeInstanceOf(AggregateError); + expect((error as AggregateError).errors).toEqual([cause]); + expect((error as Error).message).not.toContain(cause.message); + }, + ); + it.each([false, true])( + 'removes private state only after every applicable stage confirms cleanup (CI=%s)', + async (ci) => { + const actions: string[] = []; + await disposeDemoResources({ + processes: { + dispose: async () => { + actions.push('processes'); + }, + }, + finish: () => { + actions.push('observer'); + }, + teardown: ci + ? async () => { + actions.push('compose'); + } + : undefined, + removePrivate: () => { + actions.push('private'); + }, + }); + expect(actions).toEqual( + ci ? ['processes', 'observer', 'compose', 'private'] : ['processes', 'observer', 'private'], + ); + }, + ); + it('retains observer causes internally without printing private output', async () => { + const cause = new Error('private observer sentinel'); + const processes = new DemoProcesses(new AbortController().signal); + const error = await processes + .run(process.execPath, ['-e', 'process.stdout.write("output")'], { + label: 'Fixed observer stage', + observe: () => { + throw cause; + }, + }) + .catch((error: unknown) => error); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('demo: Fixed observer stage failed'); + expect(((error as Error).cause as Error).cause).toBe(cause); + await processes.dispose(); + }); +}); + +describe('listener bind diagnostics', () => { + it.each(['EADDRINUSE', 'EACCES'])( + 'keeps %s distinct without exposing socket diagnostics', + (code) => { + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + ` + import net from 'node:net'; + import { EventEmitter } from 'node:events'; + import { syncBuiltinESMExports } from 'node:module'; + const cause = Object.assign(new Error('private bind diagnostic'), { code: process.argv[1] }); + net.createServer = () => { + const server = new EventEmitter(); + server.listen = () => queueMicrotask(() => server.emit('error', cause)); + return server; + }; + syncBuiltinESMExports(); + const { vacant } = await import(process.argv[2]); + try { await vacant(3000); process.exitCode = 2; } + catch (error) { console.log(JSON.stringify({ message: error.message, code: error.cause?.code })); } + `, + code, + new URL('../../../../../scripts/demo-http.mjs', import.meta.url).href, + ], + { + encoding: 'utf8', + timeout: 5000, + }, + ); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({ + message: + code === 'EADDRINUSE' + ? 'Port 3000 is occupied; stop its owner before make demo' + : 'Demo listener bind check failed', + code, + }); + }, + ); +}); diff --git a/frontend/apps/web/src/test/fixtures/demo-identities.json b/frontend/apps/web/src/test/fixtures/demo-identities.json new file mode 100644 index 00000000..cb2b090e --- /dev/null +++ b/frontend/apps/web/src/test/fixtures/demo-identities.json @@ -0,0 +1,191 @@ +{ + "values": [ + { "kind": "band-key", "value": "a1" }, + { "kind": "band-key", "value": "a2" }, + { "kind": "band-key", "value": "advanced" }, + { "kind": "band-key", "value": "b1" }, + { "kind": "band-key", "value": "b2" }, + { "kind": "band-key", "value": "beginner" }, + { "kind": "band-key", "value": "c1" }, + { "kind": "band-key", "value": "c2" }, + { "kind": "band-key", "value": "developing" }, + { "kind": "band-key", "value": "foundation" }, + { "kind": "band-key", "value": "intermediate" }, + { "kind": "band-label", "value": "A1 \u00B7 Beginner" }, + { "kind": "band-label", "value": "A2 \u00B7 Elementary" }, + { "kind": "band-label", "value": "Advanced" }, + { "kind": "band-label", "value": "B1 \u00B7 Intermediate" }, + { "kind": "band-label", "value": "B2 \u00B7 Upper intermediate" }, + { "kind": "band-label", "value": "Ba\u015Flang\u0131\u00E7" }, + { "kind": "band-label", "value": "Beginner" }, + { "kind": "band-label", "value": "C1 \u00B7 Advanced" }, + { "kind": "band-label", "value": "C2 \u00B7 Proficient" }, + { "kind": "band-label", "value": "Developing" }, + { "kind": "band-label", "value": "Foundation" }, + { "kind": "band-label", "value": "Geli\u015Fen" }, + { "kind": "band-label", "value": "Intermediate" }, + { "kind": "band-label", "value": "Orta" }, + { "kind": "band-label", "value": "Temel" }, + { "kind": "band-label", "value": "\u0130leri" }, + { "kind": "content-type-key", "value": "asana-pose" }, + { "kind": "content-type-key", "value": "card" }, + { "kind": "content-type-key", "value": "grammar-topic" }, + { "kind": "course-key", "value": "branch-conversation" }, + { "kind": "course-key", "value": "foundations" }, + { "kind": "course-key", "value": "guided-flow" }, + { "kind": "course-key", "value": "guided-practice" }, + { "kind": "course-key", "value": "next-steps" }, + { "kind": "course-key", "value": "shared-practice" }, + { "kind": "course-key", "value": "studio-flow" }, + { "kind": "course-key", "value": "studio-foundations" }, + { "kind": "course-slug", "value": "branch-conversation" }, + { "kind": "course-slug", "value": "foundation" }, + { "kind": "course-slug", "value": "guided-flow" }, + { "kind": "course-slug", "value": "guided-practice" }, + { "kind": "course-slug", "value": "next-steps" }, + { "kind": "course-slug", "value": "ortak-pratik" }, + { "kind": "course-slug", "value": "rehberli-akis" }, + { "kind": "course-slug", "value": "shared-practice" }, + { "kind": "course-slug", "value": "studio-flow" }, + { "kind": "course-slug", "value": "studyo-akisi" }, + { "kind": "course-slug", "value": "studyo-temelleri" }, + { "kind": "course-title", "value": "Branch conversation" }, + { "kind": "course-title", "value": "English foundations" }, + { "kind": "course-title", "value": "Guided flow" }, + { "kind": "course-title", "value": "Guided practice" }, + { "kind": "course-title", "value": "Next steps" }, + { "kind": "course-title", "value": "Ortak pratik" }, + { "kind": "course-title", "value": "Rehberli ak\u0131\u015F" }, + { "kind": "course-title", "value": "Shared practice" }, + { "kind": "course-title", "value": "Studio flow" }, + { "kind": "course-title", "value": "Studio foundations" }, + { "kind": "course-title", "value": "St\u00FCdyo ak\u0131\u015F\u0131" }, + { "kind": "course-title", "value": "St\u00FCdyo temelleri" }, + { "kind": "definition-label", "value": "Asana duru\u015Fu" }, + { "kind": "definition-label", "value": "Asana pose" }, + { "kind": "definition-label", "value": "CEFR" }, + { "kind": "definition-label", "value": "Card" }, + { "kind": "definition-label", "value": "D\u00FCz" }, + { "kind": "definition-label", "value": "Grammar topic" }, + { "kind": "definition-label", "value": "Kart" }, + { "kind": "definition-label", "value": "Plain" }, + { "kind": "definition-label", "value": "Practice difficulty" }, + { "kind": "definition-label", "value": "Pratik d\u00FCzeyi" }, + { "kind": "field-label", "value": "Breathing" }, + { "kind": "field-label", "value": "Concept" }, + { "kind": "field-label", "value": "Duru\u015F" }, + { "kind": "field-label", "value": "Example" }, + { "kind": "field-label", "value": "Instruction" }, + { "kind": "field-label", "value": "Nefes" }, + { "kind": "field-label", "value": "Pose" }, + { "kind": "field-label", "value": "Y\u00F6nerge" }, + { "kind": "host", "value": "demo-english.learnstack.local" }, + { "kind": "host", "value": "demo-yoga.learnstack.local" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000001" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000002" }, + { "kind": "id", "value": "01930000-0000-7000-8000-0000000000a1" }, + { "kind": "id", "value": "01930000-0000-7000-8000-0000000000a2" }, + { "kind": "id", "value": "01930000-0000-7000-8000-0000000000b1" }, + { "kind": "id", "value": "01930000-0000-7000-8000-0000000000b2" }, + { "kind": "id", "value": "01930000-0000-7000-8000-0000000000c1" }, + { "kind": "id", "value": "01930000-0000-7000-8000-0000000000c2" }, + { "kind": "id", "value": "01930000-0000-7000-8000-0000000000c3" }, + { "kind": "id", "value": "01930000-0000-7000-8000-0000000000c4" }, + { "kind": "id", "value": "01930000-0000-7000-8000-0000000000d1" }, + { "kind": "id", "value": "01930000-0000-7000-8000-0000000000d2" }, + { "kind": "id", "value": "01930000-0000-7000-8000-0000000000d3" }, + { "kind": "id", "value": "01930000-0000-7000-8000-0000000000d4" }, + { "kind": "id", "value": "01930000-0000-7000-8000-0000000000e1" }, + { "kind": "id", "value": "01930000-0000-7000-8000-0000000000e2" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000e11" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000e12" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000e13" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000e14" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000e21" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000e22" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000e23" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000e24" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000e25" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000f11" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000f12" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000f13" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000f14" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000f21" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000f22" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000f23" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000f24" }, + { "kind": "id", "value": "01930000-0000-7000-8000-000000000f25" }, + { "kind": "lesson-slug", "value": "agac-durusu" }, + { "kind": "lesson-slug", "value": "bridge-pose" }, + { "kind": "lesson-slug", "value": "child-pose" }, + { "kind": "lesson-slug", "value": "cocuk-durusu" }, + { "kind": "lesson-slug", "value": "dag-durusu" }, + { "kind": "lesson-slug", "value": "giving-reasons" }, + { "kind": "lesson-slug", "value": "introductions" }, + { "kind": "lesson-slug", "value": "kopru-durusu" }, + { "kind": "lesson-slug", "value": "mountain-pose" }, + { "kind": "lesson-slug", "value": "past-simple" }, + { "kind": "lesson-slug", "value": "present-simple" }, + { "kind": "lesson-slug", "value": "questions" }, + { "kind": "lesson-slug", "value": "savasci-durusu" }, + { "kind": "lesson-slug", "value": "tree-pose" }, + { "kind": "lesson-slug", "value": "warrior-pose" }, + { "kind": "lesson-title", "value": "A\u011Fa\u00E7 duru\u015Fu" }, + { "kind": "lesson-title", "value": "Bridge pose" }, + { "kind": "lesson-title", "value": "Child pose" }, + { "kind": "lesson-title", "value": "Da\u011F duru\u015Fu" }, + { "kind": "lesson-title", "value": "Giving reasons" }, + { "kind": "lesson-title", "value": "Introductions" }, + { "kind": "lesson-title", "value": "K\u00F6pr\u00FC duru\u015Fu" }, + { "kind": "lesson-title", "value": "Mountain pose" }, + { "kind": "lesson-title", "value": "Past simple" }, + { "kind": "lesson-title", "value": "Present simple" }, + { "kind": "lesson-title", "value": "Questions" }, + { "kind": "lesson-title", "value": "Sava\u015F\u00E7\u0131 duru\u015Fu" }, + { "kind": "lesson-title", "value": "Tree pose" }, + { "kind": "lesson-title", "value": "Warrior pose" }, + { "kind": "lesson-title", "value": "\u00C7ocuk duru\u015Fu" }, + { "kind": "organization-name", "value": "Be\u015Fikta\u015F Branch" }, + { "kind": "organization-name", "value": "Kad\u0131k\u00F6y Branch" }, + { "kind": "organization-name", "value": "Studio One" }, + { "kind": "organization-name", "value": "Studio Two" }, + { "kind": "organization-slug", "value": "besiktas" }, + { "kind": "organization-slug", "value": "kadikoy" }, + { "kind": "organization-slug", "value": "studio-one" }, + { "kind": "organization-slug", "value": "studio-two" }, + { "kind": "property-name", "value": "body" }, + { "kind": "property-name", "value": "breathing" }, + { "kind": "property-name", "value": "concept" }, + { "kind": "property-name", "value": "example" }, + { "kind": "property-name", "value": "instruction" }, + { "kind": "property-name", "value": "pose" }, + { "kind": "property-name", "value": "title" }, + { "kind": "taxonomy-key", "value": "cefr" }, + { "kind": "taxonomy-key", "value": "plain" }, + { "kind": "taxonomy-key", "value": "yoga-difficulty" }, + { "kind": "tenant-name", "value": "Anatolia Yoga" }, + { "kind": "tenant-name", "value": "English Hero" }, + { "kind": "tenant-slug", "value": "demo-english" }, + { "kind": "tenant-slug", "value": "demo-yoga" } + ], + "platformValues": [ + "Advanced", + "Ba\u015Flang\u0131\u00E7", + "Beginner", + "Card", + "D\u00FCz", + "Intermediate", + "Kart", + "Orta", + "Plain", + "advanced", + "beginner", + "body", + "card", + "default-card", + "intermediate", + "plain", + "title", + "\u0130leri" + ] +} diff --git a/frontend/apps/web/src/test/public-demo-ci.test.ts b/frontend/apps/web/src/test/public-demo-ci.test.ts new file mode 100644 index 00000000..472060be --- /dev/null +++ b/frontend/apps/web/src/test/public-demo-ci.test.ts @@ -0,0 +1,111 @@ +// @vitest-environment node +import { spawnSync } from 'node:child_process'; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, expect, it } from 'vitest'; + +const job = readFileSync( + new URL('../../../../../.github/workflows/ci.yml', import.meta.url), + 'utf8', +) + .split(' public-demo:\n')[1] + ?.split('\n meta:')[0]; +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'learnstack-ci-budget-')); + roots.push(root); + const environment = join(root, 'environment'); + const invocation = join(root, 'invocation'); + for (const [name, body] of [ + ['date', 'printf "1000\\n"'], + ['timeout', 'printf "%s\\n" "$@" > "$INVOCATION"'], + ] as const) { + writeFileSync(join(root, name), '#!/bin/sh\n' + body + '\n'); + chmodSync(join(root, name), 0o700); + } + const env = { + PATH: root, + NODE_ENV: 'test' as const, + GITHUB_ENV: environment, + INVOCATION: invocation, + }; + return { root, environment, invocation, env }; +} + +function proofScript() { + expect(job).toBeDefined(); + const script = job?.match(/ {8}id: demo\n {8}run: \|\n((?: {10}.*\n|\n)+)/)?.[1]; + expect(script).toBeDefined(); + if (!script) throw new Error('Missing real workflow proof command'); + return script; +} + +it('starts the absolute budget before checkout and reserves four minutes of the job ceiling', () => { + const files = fixture(); + const first = job?.match( + /steps:\n(?: {6}#.*\n)* {6}- name: Reserve demo cleanup and artifact time\n {8}run: (.+)\n/, + ); + expect(first).not.toBeNull(); + expect(job).toContain('timeout-minutes: 20'); + if (!first?.[1]) throw new Error('Missing first-step budget reservation'); + const result = spawnSync('/bin/bash', ['-eu', '-c', first[1]], { env: files.env }); + expect(result.status).toBe(0); + expect(readFileSync(files.environment, 'utf8')).toBe('PUBLIC_DEMO_DEADLINE=1960\n'); +}); + +it('bounds the real workflow command by the remaining budget and requests owned SIGINT cleanup', () => { + const files = fixture(); + const result = spawnSync('/bin/bash', ['-eu', '-c', proofScript()], { + env: { ...files.env, PUBLIC_DEMO_DEADLINE: '1123' }, + }); + expect(result.status).toBe(0); + expect(readFileSync(files.invocation, 'utf8').split('\n')).toEqual([ + '--signal=INT', + '--kill-after=35s', + '123s', + 'make', + 'demo', + 'DEMO_MODE=ci', + '', + ]); +}); + +it.each(['1000', '999'])( + 'refuses an exhausted absolute budget before starting owned resources: %s', + (deadline) => { + const files = fixture(); + const result = spawnSync('/bin/bash', ['-eu', '-c', proofScript()], { + env: { ...files.env, PUBLIC_DEMO_DEADLINE: deadline }, + encoding: 'utf8', + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Demo setup exhausted'); + expect(() => readFileSync(files.invocation)).toThrow(); + }, +); + +it('uploads only named public reports, screenshots and sanitized receipts', () => { + const paths = (source: string) => { + const block = source.match(/ {10}path: \|\n((?: {12}.+\n)+)/)?.[1]; + if (!block) throw new Error('Missing actual workflow artifact allowlist'); + return block + .trim() + .split('\n') + .map((path) => path.trim()); + }; + const expected = [ + 'artifacts/public-demo/receipt.json', + 'artifacts/public-demo/audit-*.json', + 'artifacts/public-demo/audit-*.html', + 'artifacts/public-demo/browser-*.png', + 'artifacts/public-demo-ci/workflow.json', + ]; + expect(paths(job!)).toEqual(expected); + expect(paths(job!.replace('audit-*.json', '*.json'))).not.toEqual(expected); +}); diff --git a/frontend/apps/web/src/test/public-demo.test.ts b/frontend/apps/web/src/test/public-demo.test.ts new file mode 100644 index 00000000..13ab87ab --- /dev/null +++ b/frontend/apps/web/src/test/public-demo.test.ts @@ -0,0 +1,587 @@ +// @vitest-environment node +import { EventEmitter } from 'node:events'; +import { readFileSync } from 'node:fs'; +import { gzipSync } from 'node:zlib'; + +import { describe, expect, it } from 'vitest'; + +import { + apiCompletionObserver, + completionDelta, + finalCompletionCensus, +} from '../../../../../scripts/demo-observation.mjs'; +import { + admitProduct, + assertConfiguration, + decodedSource, + normalizedJavaScript, + reconcileScriptInventory, + reportAdvisories, + reportInventory, + validateReport, +} from '../../scripts/public-demo-contract.mjs'; +import { PublicDemoDocument, resolvedDemoCensus } from '../../scripts/public-demo-gatherer.mjs'; +import { + readDemoToolchain, + toolchainVersions, + type DemoToolchain, +} from '../../scripts/public-demo-toolchain.mjs'; + +const census = JSON.parse( + readFileSync(new URL('../../scripts/public-demo-census.json', import.meta.url), 'utf8'), +) as { + versions: DemoToolchain; + audits: string[]; + categories: Record>; +}; +const source = 'console.log("source");'; +const html = + ''; +const inventory = () => [ + { + id: 'one', + url: 'https://public.example/one.js', + status: 200, + complete: true, + body: { body: source, base64Encoded: false }, + }, +]; +const lane = { + locale: 'en', + displayName: 'Independent institution', + catalogUrl: 'https://public.example/en/courses', + courseUrl: 'https://public.example/en/courses/practice', + lessonUrl: 'https://public.example/en/courses/practice/lessons/first', + courseTitle: 'A practice', + lessonTitle: 'A lesson', + fields: [{ label: 'Label', value: 'Value' }], + theme: { primary: '#112233', background: '#ffffff', foreground: '#111111', muted: '#555555' }, + excludedTitles: ['Foreign title'], + excludedContent: ['Protected lesson'], +}; +const document = () => ({ + status: 200, + headers: { 'content-type': 'text/html', 'cache-control': 'no-store' }, + redirects: 0, + html, + dom: { + url: lane.lessonUrl, + lang: 'en', + dir: 'ltr', + headings: [lane.lessonTitle], + institutions: [lane.displayName], + mains: 1, + states: 0, + noindex: false, + kinds: ['lesson'], + theme: { primary: '#112233', bg: '#ffffff', fg: '#111111', muted: '#555555' }, + foreground: 'rgb(17, 17, 17)', + background: 'rgb(255, 255, 255)', + muted: 'rgb(85, 85, 85)', + fields: [ + { + label: 'Label', + value: 'Value', + labelLang: 'en', + labelDir: 'ltr', + valueLang: 'en', + adjacent: true, + children: 0, + }, + ], + links: [], + }, +}); + +describe('Public_Demo_Uses_Seeded_Production_Pages', () => { + it('admits the actual seeded document and computed complete palette', () => { + expect(() => admitProduct(document(), lane, 'lesson', 'Catalog')).not.toThrow(); + }); + it.each([ + 'status', + 'url', + 'locale', + 'institution', + 'heading', + 'error', + 'noindex', + 'theme', + 'field', + 'active-field', + 'containment', + ])('refuses a planted %s product offender', (kind) => { + const value = document(); + switch (kind) { + case 'status': + value.status = 503; + break; + case 'url': + value.dom.url += '?wrong=1'; + break; + case 'locale': + value.dom.lang = 'tr-TR'; + break; + case 'institution': + value.dom.institutions = ['Foreign institution']; + break; + case 'heading': + value.dom.headings = ['Fallback']; + break; + case 'error': + value.dom.states = 1; + break; + case 'noindex': + value.dom.noindex = true; + break; + case 'theme': + value.dom.theme.bg = '#000000'; + break; + case 'field': + value.dom.fields[0]!.value = 'Different'; + break; + case 'active-field': + value.dom.fields[0]!.children = 1; + break; + case 'containment': + value.html += '

Protected lesson

'; + break; + } + expect(() => admitProduct(value, lane, 'lesson', 'Catalog')).toThrow(); + expect(() => admitProduct(document(), lane, 'lesson', 'Catalog')).not.toThrow(); + }); +}); + +describe('Public_Initial_JavaScript_Gzip_Budget_Is_Measured', () => { + it('counts each delivered source, async/cross-origin included, and original inline CRLF bytes', () => { + const rows = [ + ...inventory(), + { ...inventory()[0]!, id: 'two', url: 'https://other.example/lazy.js' }, + ]; + const result = normalizedJavaScript(html, rows); + expect(result.scripts).toHaveLength(2); + expect(result.inline.count).toBe(2); + expect(result.inline.bytes).toBe(Buffer.byteLength('one\r\ntwo\nthree')); + expect(result.total).toBe( + 2 * gzipSync(source, { level: 9 }).length + + gzipSync('one\r\ntwo\nthree', { level: 9 }).length, + ); + }); + it.each([ + 'empty', + 'duplicate', + 'incomplete', + 'failed', + 'missing', + 'malformed-base64', + 'invalid-utf8', + 'replacement', + 'surrogate', + ])('refuses %s sources with a clean control', (kind) => { + const rows = inventory(); + if (kind === 'empty') rows.length = 0; + else if (kind === 'duplicate') rows.push({ ...rows[0]! }); + else if (kind === 'incomplete') rows[0]!.complete = false; + else if (kind === 'failed') rows[0]!.status = 404; + else + rows[0]!.body = + kind === 'missing' + ? { body: '', base64Encoded: false } + : kind === 'invalid-utf8' + ? { body: '/w==', base64Encoded: true } + : kind === 'malformed-base64' + ? { body: 'abc!', base64Encoded: true } + : { body: kind === 'replacement' ? '\ufffd' : '\ud800', base64Encoded: false }; + expect(() => normalizedJavaScript(html, rows)).toThrow(); + expect(() => normalizedJavaScript(html, inventory())).not.toThrow(); + }); + it('decodes complete strict UTF-8 base64 and refuses an absent CDP body', () => { + expect( + decodedSource({ body: Buffer.from('Türkçe').toString('base64'), base64Encoded: true }), + ).toBe('Türkçe'); + expect(() => decodedSource(undefined)).toThrow(); + }); + it('counts executable legacy JavaScript MIME types and excludes inert data', () => { + const types = [ + 'application/x-javascript', + 'text/javascript1.5', + 'text/jscript', + 'text/livescript', + 'text/x-ecmascript', + 'TEXT/JAVASCRIPT; charset=utf-8', + ]; + const original = + types.map((type) => ``).join('') + + ''; + expect(normalizedJavaScript(original, inventory()).inline.count).toBe(types.length); + }); + it('refuses measured over-budget source bytes through the report policy', () => { + let state = 1; + const body = Array.from({ length: 350000 }, () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + return String.fromCharCode(33 + ((state >>> 0) % 90)); + }).join(''); + const measured = normalizedJavaScript(html, [ + { ...inventory()[0]!, body: { body, base64Encoded: false } }, + ]); + expect(measured.total).toBeGreaterThanOrEqual(200000); + const report = cleanReport(); + report.audits['initial-js-gzip-bytes']!.numericValue = measured.total; + expect(() => validateReport(report, { url: lane.lessonUrl }, census)).toThrow(); + expect(() => validateReport(cleanReport(), { url: lane.lessonUrl }, census)).not.toThrow(); + }); +}); + +function cleanReport() { + const audits: Record = + Object.fromEntries(census.audits.map((id) => [id, { scoreDisplayMode: 'binary' }])); + for (const id of [ + 'initial-js-gzip-bytes', + 'cumulative-layout-shift', + 'largest-contentful-paint', + 'server-response-time', + 'total-blocking-time', + ]) + audits[id]!.numericValue = 0; + return { + lighthouseVersion: census.versions.lighthouse, + runWarnings: [] as string[], + requestedUrl: lane.lessonUrl, + mainDocumentUrl: lane.lessonUrl, + finalDisplayedUrl: lane.lessonUrl, + categories: Object.fromEntries( + Object.entries(census.categories).map(([id, auditRefs]) => [id, { auditRefs, score: 1 }]), + ), + audits, + }; +} +describe('Public_Lighthouse_Reports_Enforce_Budgets', () => { + it('resolves the installed pinned Lighthouse configuration against the independent receipt', async () => { + expect(() => assertConfiguration({ ...census, audits: [] }, census)).toThrow(); + assertConfiguration( + await resolvedDemoCensus({ + port: 12345, + hostname: '127.0.0.1', + logLevel: 'error', + output: ['json', 'html'], + maxWaitForLoad: 15000, + }), + census, + ); + }); + it('derives the installed tool/browser versions and refuses an independently stale census', () => { + expect(readDemoToolchain()).toEqual(census.versions); + expect(() => + assertConfiguration( + { ...census, versions: { ...census.versions, chromiumRevision: 'stale' } }, + census, + ), + ).toThrow(); + expect(() => assertConfiguration(census, census)).not.toThrow(); + }); + it.each([ + 'lighthouse', + 'playwright', + 'core', + 'unpinned', + 'missing', + 'duplicate', + 'revision', + 'browser-version', + ])('refuses %s tool metadata without weakening clean admission', (kind) => { + const pins = { lighthouse: census.versions.lighthouse, playwright: census.versions.playwright }; + const clean = { + lighthouse: pins.lighthouse, + playwright: pins.playwright, + core: pins.playwright, + browsers: [ + { + name: 'chromium', + revision: census.versions.chromiumRevision, + browserVersion: census.versions.browserVersion, + }, + ], + }; + const installed = structuredClone(clean); + if (kind === 'lighthouse' || kind === 'playwright' || kind === 'core') + installed[kind] = '0.0.0'; + if (kind === 'unpinned') pins.playwright = '^' + pins.playwright; + if (kind === 'missing') installed.browsers = []; + if (kind === 'duplicate') installed.browsers.push({ ...installed.browsers[0]! }); + if (kind === 'revision') installed.browsers[0]!.revision = ''; + if (kind === 'browser-version') installed.browsers[0]!.browserVersion = 'unknown'; + expect(() => toolchainVersions(pins, installed)).toThrow(); + expect(toolchainVersions(census.versions, clean)).toEqual(census.versions); + }); + it.each([ + 'missing', + 'runtime', + 'warning', + 'audit', + 'nonfinite', + 'gzip', + 'cls', + 'accessibility', + 'weights', + 'redirect', + 'version', + ])('refuses a planted %s report', (kind) => { + const value = structuredClone(cleanReport()); + if (kind === 'missing') delete value.audits[census.audits[0]!]; + if (kind === 'runtime') Object.assign(value, { runtimeError: { code: 'failure' } }); + if (kind === 'warning') value.runWarnings.push('Results may be incomplete'); + if (kind === 'audit') value.audits[census.audits[0]!]!.scoreDisplayMode = 'error'; + if (kind === 'nonfinite') value.audits['largest-contentful-paint']!.numericValue = NaN; + if (kind === 'gzip') value.audits['initial-js-gzip-bytes']!.numericValue = 200000; + if (kind === 'cls') value.audits['cumulative-layout-shift']!.numericValue = 0.05; + if (kind === 'accessibility') value.categories.accessibility!.score = 0.99; + if (kind === 'weights') value.categories.accessibility!.auditRefs[0]!.weight = 999; + if (kind === 'redirect') value.finalDisplayedUrl = lane.catalogUrl; + if (kind === 'version') value.lighthouseVersion = '0.0.0'; + expect(() => validateReport(value, { url: lane.lessonUrl }, census)).toThrow(); + expect(() => validateReport(cleanReport(), { url: lane.lessonUrl }, census)).not.toThrow(); + }); + it('allows high advisory timing and legal N/A weight zeroing, preserving configured census', () => { + const value = structuredClone(cleanReport()); + value.audits['largest-contentful-paint']!.numericValue = 99999; + const reference = value.categories.accessibility!.auditRefs.find((row) => row.weight > 0)!; + value.audits[reference.id]!.scoreDisplayMode = 'notApplicable'; + reference.weight = 0; + expect(() => validateReport(value, { url: lane.lessonUrl }, census)).not.toThrow(); + expect(() => assertConfiguration({ ...census, audits: [] }, census)).toThrow(); + expect(() => assertConfiguration(census, census)).not.toThrow(); + }); + it('refuses duplicate, stale, omitted and cross-invocation reports', () => { + const expected = Array.from({ length: 27 }, (_, index) => ({ + url: 'https://public.example/' + Math.floor(index / 3), + run: (index % 3) + 1, + commit: 'a', + build: 'b', + invocation: 'c', + configDigest: 'd', + browserVersion: 'e', + })); + expect(() => reportInventory(expected, expected)).not.toThrow(); + for (const rows of [ + expected.slice(1), + expected.map((row, index) => (index ? row : expected[1])), + expected.map((row, index) => (index ? row : { ...row, commit: 'stale' })), + expected.map((row, index) => (index ? row : { ...row, invocation: 'foreign' })), + expected.map((row, index) => (index ? row : { ...row, configDigest: 'different' })), + expected.map((row, index) => (index ? row : { ...row, browserVersion: 'different' })), + ]) + expect(() => reportInventory(rows, expected)).toThrow(); + }); +}); + +describe('Public_Demo_Preserves_Admission_And_Cleanup observer', () => { + const completion = (status: number) => + JSON.stringify({ + SourceContext: 'Microsoft.AspNetCore.Hosting.Diagnostics', + EventId: { Id: 2 }, + Path: '/api/v1/public/site', + StatusCode: status, + Method: 'GET', + }); + it.each([ + 'unterminated', + 'malformed', + 'missing-brace', + 'prefixed', + 'unexpected-endpoint', + 'eof', + 'late-success', + 'late-refusal', + ])('refuses actual %s records after a successful positive control', (kind) => { + const observer = apiCompletionObserver(); + const before = structuredClone(observer.state); + observer.consume(Buffer.from(completion(200) + '\n')); + expect(completionDelta(before, observer.state, 1).count).toBe(1); + const beforeCleanup = structuredClone(observer.state); + if (kind === 'malformed') observer.consume(Buffer.from('{"StatusCode":429\n')); + else if (kind === 'missing-brace') + observer.consume(Buffer.from(completion(429).slice(1) + '\n')); + else if (kind === 'prefixed') observer.consume(Buffer.from('prefix ' + completion(429) + '\n')); + else if (kind === 'unexpected-endpoint') + observer.consume( + Buffer.from(completion(429).replace('/api/v1/public/site', '/api/v1/other') + '\n'), + ); + else if (kind === 'late-success' || kind === 'late-refusal') + observer.consume(Buffer.from(completion(kind === 'late-success' ? 200 : 429) + '\n')); + else observer.consume(Buffer.from(completion(429))); + if (kind === 'unterminated') expect(() => completionDelta(before, observer.state, 1)).toThrow(); + observer.finish(); + expect(() => finalCompletionCensus(beforeCleanup, observer.state, 1)).toThrow(); + }); + it('keeps interleaved streams separate and confirms joined clean output', () => { + const observer = apiCompletionObserver(); + const before = structuredClone(observer.state); + const value = completion(200); + observer.consume(Buffer.from(value.slice(0, 30)), 'stdout'); + observer.consume( + Buffer.from(JSON.stringify({ SourceContext: 'Startup', Message: 'Ready' }) + '\n'), + 'stderr', + ); + expect(() => completionDelta(before, observer.state, 0)).toThrow(); + observer.consume(Buffer.from(value.slice(30) + '\n'), 'stdout'); + expect(completionDelta(before, observer.state, 1).count).toBe(1); + const beforeCleanup = structuredClone(observer.state); + observer.finish(); + expect(() => finalCompletionCensus(beforeCleanup, observer.state, 1)).not.toThrow(); + expect(() => finalCompletionCensus(beforeCleanup, observer.state, 2)).toThrow(); + }); + it('observes split UTF-8 completion lines while retaining only bounded aggregates', () => { + const observer = apiCompletionObserver(); + const before = structuredClone(observer.state); + const event = Buffer.from( + JSON.stringify({ + SourceContext: 'Microsoft.AspNetCore.Hosting.Diagnostics', + EventId: { Id: 2 }, + Path: '/api/v1/public/site', + StatusCode: 200, + Method: 'GET', + private: 'Türkçe', + }) + '\n', + ); + for (const byte of event) observer.consume(Buffer.from([byte])); + expect(completionDelta(before, observer.state, 1).count).toBe(1); + expect(JSON.stringify(observer.state)).not.toContain('private'); + expect(() => completionDelta(before, { ...observer.state, total: 2 }, 1)).toThrow(); + expect(() => completionDelta(before, { ...observer.state, statuses: { 429: 1 } }, 1)).toThrow(); + expect(() => completionDelta(before, { ...observer.state, malformed: true }, 1)).toThrow(); + }); +}); + +describe('actual initial source gatherer completeness controls', () => { + it.each(['clean', 'missing-body', 'incomplete', 'foreign-target', 'truncated'])( + 'exercises the actual collector with %s', + async (kind) => { + const session = Object.assign(new EventEmitter(), { + id: () => 'main', + sendCommand: async (name: string, args: { requestId?: string } = {}) => { + if (name === 'Page.getFrameTree') return { frameTree: { frame: { id: 'frame' } } }; + if (name !== 'Network.getResponseBody') return {}; + if (kind === 'missing-body') throw new Error('unavailable'); + return { body: args.requestId === 'doc' ? html : source, base64Encoded: false }; + }, + }); + const targets = new EventEmitter(); + const context = { + driver: { + defaultSession: session, + targetManager: targets, + executionContext: { evaluate: async () => ({ url: lane.lessonUrl }) }, + }, + }; + const gatherer = new PublicDemoDocument(); + await gatherer.startInstrumentation(context); + for (const [id, type] of [ + ['doc', 'Document'], + ['js', 'Script'], + ]) { + session.emit('Network.requestWillBeSent', { + requestId: id, + type, + frameId: 'frame', + request: { url: lane.lessonUrl + (id === 'js' ? '.js' : '') }, + }); + session.emit('Network.responseReceived', { + requestId: id, + response: { status: 200, headers: {} }, + }); + if (kind === 'truncated') + session.emit('Network.dataReceived', { requestId: id, dataLength: 1 }); + if (kind !== 'incomplete') session.emit('Network.loadingFinished', { requestId: id }); + } + if (kind === 'foreign-target') + targets.emit('protocolevent', { + method: 'Network.responseReceived', + sessionId: 'other', + params: { type: 'Script' }, + }); + if (kind === 'clean') + await expect(gatherer.getArtifact(context)).resolves.toHaveProperty('gzip'); + else await expect(gatherer.getArtifact(context)).rejects.toThrow(); + expect(session.eventNames()).toEqual([]); + expect(targets.eventNames()).toEqual([]); + }, + ); +}); + +describe('public proof independent reconciliation helpers', () => { + const script = { + method: 'Network.responseReceived', + params: { + type: 'Script', + requestId: 'request-one', + response: { url: 'https://public.example/chunk.js' }, + }, + }; + const matching = { id: script.params.requestId, url: script.params.response.url }; + it.each(['empty', 'missing-id', 'wrong-url', 'non-script'])( + 'refuses %s network overlap with a clean control', + (kind) => { + const log = + kind === 'empty' + ? [] + : kind === 'non-script' + ? [{ ...script, params: { ...script.params, type: 'Document' } }] + : [script]; + const rows = + kind === 'missing-id' + ? [{ ...matching, id: 'other' }] + : kind === 'wrong-url' + ? [{ ...matching, url: 'https://public.example/other.js' }] + : [matching]; + expect(() => reconcileScriptInventory(log, rows)).toThrow(); + expect(() => reconcileScriptInventory([script], [matching])).not.toThrow(); + }, + ); + const timing = (url: string, value: number) => ({ + url, + metrics: { + 'largest-contentful-paint': value * 1500, + 'server-response-time': value * 300, + 'total-blocking-time': value * 200, + }, + }); + it('sorts each URL independently and warns at the exact thresholds', () => { + const rows = [ + timing('one', 2), + timing('two', 0.8), + timing('one', 0.5), + timing('two', 0.2), + timing('one', 1), + timing('two', 0.4), + ]; + const result = reportAdvisories(rows); + expect(result).toEqual([ + { + url: 'one', + metrics: { + 'largest-contentful-paint': { median: 1500, threshold: 1500, warning: true }, + 'server-response-time': { median: 300, threshold: 300, warning: true }, + 'total-blocking-time': { median: 200, threshold: 200, warning: true }, + }, + }, + { + url: 'two', + metrics: { + 'largest-contentful-paint': { median: 600, threshold: 1500, warning: false }, + 'server-response-time': { median: 120, threshold: 300, warning: false }, + 'total-blocking-time': { median: 80, threshold: 200, warning: false }, + }, + }, + ]); + expect(rows[0]?.metrics['largest-contentful-paint']).toBe(3000); + }); + it.each(['missing', 'extra', 'nonfinite'])('refuses %s advisory samples', (kind) => { + const rows = [timing('one', 0.2), timing('one', 0.4), timing('one', 0.8)]; + expect(() => reportAdvisories(rows)).not.toThrow(); + if (kind === 'missing') rows.pop(); + if (kind === 'extra') rows.push(timing('one', 1)); + if (kind === 'nonfinite') rows[0]!.metrics['server-response-time'] = Number.NaN; + expect(() => reportAdvisories(rows)).toThrow(); + }); +}); diff --git a/frontend/apps/web/src/test/public-rendering-assertions.test.ts b/frontend/apps/web/src/test/public-rendering-assertions.test.ts index 9aa8209d..64c8683d 100644 --- a/frontend/apps/web/src/test/public-rendering-assertions.test.ts +++ b/frontend/apps/web/src/test/public-rendering-assertions.test.ts @@ -7,6 +7,7 @@ import { absentFromSerializedText, absentFromWholeResponse, documentViewport, + orderedLessonFields, documentOpenGraphLocales, productContainment, productTheme, @@ -243,3 +244,35 @@ describe('production Open Graph locale assertion controls', () => { ).not.toThrow(); }); }); + +describe('successor-revision ordered field assertion', () => { + const content = { + fields: [ + { name: 'first', label: { value: 'First label', locale: 'en' }, value: 'One & ' }, + { name: 'second', label: { value: 'Second label', locale: 'en' }, value: 'Other value' }, + ], + }; + const html = + '
First label
One & <two>
Second label
Other value
'; + const article = (source: string) => new JSDOM(source).window.document.querySelector('article')!; + it('accepts exact escaped values and the authored descriptor order', () => { + expect(() => orderedLessonFields(article(html), content, 'en')).not.toThrow(); + expect(() => + orderedLessonFields(article('
'), { fields: [] }, 'en'), + ).not.toThrow(); + }); + it.each([ + ['label', html.replace('First label', 'wrong label')], + ['value', html.replace('Other value', 'wrong value')], + [ + 'order', + '
Second label
Other value
First label
One & <two>
', + ], + ['active value', html.replace('Other value', 'Other value')], + ['fallback language', html.replace('lang="en" dir="ltr"', 'lang="tr" dir="ltr"')], + ['internal name', html.replace('First label', 'first')], + ])('rejects a planted %s mismatch in the same production assertion', (_kind, source) => { + expect(() => orderedLessonFields(article(source), content, 'en')).toThrow(); + expect(() => orderedLessonFields(article(html), content, 'en')).not.toThrow(); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index a6cb41af..7aa9fe2c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,8 +15,10 @@ "test": "node ../scripts/run-frontend-tests.mjs" }, "devDependencies": { - "typescript": "5.6.3", - "prettier": "3.3.3" + "lighthouse": "13.5.0", + "playwright": "1.64.0", + "prettier": "3.3.3", + "typescript": "5.6.3" }, "pnpm": { "overrides": { diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 1dbbda0d..68c277c1 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -13,6 +13,12 @@ importers: .: devDependencies: + lighthouse: + specifier: 13.5.0 + version: 13.5.0(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1)) + playwright: + specifier: 1.64.0 + version: 1.64.0 prettier: specifier: 3.3.3 version: 3.3.3 @@ -30,10 +36,10 @@ importers: version: link:../../packages/ui next: specifier: 15.5.27 - version: 15.5.27(@babel/core@7.29.7)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 15.5.27(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) next-intl: specifier: 4.14.9 - version: 4.14.9(@types/react@19.2.14)(next@15.5.27(@babel/core@7.29.7)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) + version: 4.14.9(@types/react@19.2.14)(next@15.5.27(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) react: specifier: 19.0.0 version: 19.0.0 @@ -94,7 +100,7 @@ importers: version: 6.4.3(@types/node@22.19.19)(jiti@1.21.7) vitest: specifier: 4.1.11 - version: 4.1.11(@types/node@22.19.19)(jsdom@26.1.0)(vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(jsdom@26.1.0)(vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7)) packages/config: dependencies: @@ -139,7 +145,7 @@ importers: version: 6.4.3(@types/node@22.19.19)(jiti@1.21.7) vitest: specifier: 4.1.11 - version: 4.1.11(@types/node@22.19.19)(jsdom@26.1.0)(vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(jsdom@26.1.0)(vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7)) packages/ui: dependencies: @@ -508,21 +514,36 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@formatjs/ecma402-abstract@2.3.6': + resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==} + + '@formatjs/fast-memoize@2.2.7': + resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==} + '@formatjs/fast-memoize@3.1.7': resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==} + '@formatjs/icu-messageformat-parser@2.11.4': + resolution: {integrity: sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==} + '@formatjs/icu-messageformat-parser@3.5.18': resolution: {integrity: sha512-wX1efcL8d7K5QapOTRsP/OLTA/kpwe4LwqWyNxDNg5iRs8i9jJbCyxB5P8jc4KOW5oiLzz2DJr+BRY5xMV9+bA==} '@formatjs/icu-messageformat-parser@3.5.21': resolution: {integrity: sha512-Vnl7IOSyMRaP4Pjv48etkT2nlcIwzh4QWIa3hewzGlZZPBbft+TbMir4MijpDViOLN/1hvkGC10oYMuilsG+fw==} + '@formatjs/icu-skeleton-parser@1.8.16': + resolution: {integrity: sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==} + '@formatjs/icu-skeleton-parser@2.1.11': resolution: {integrity: sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==} '@formatjs/icu-skeleton-parser@2.1.12': resolution: {integrity: sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==} + '@formatjs/intl-localematcher@0.6.2': + resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + '@formatjs/intl-localematcher@0.8.14': resolution: {integrity: sha512-8O7V38QmbB11+ryJ3KM/znrzXtiqgpXzqNoqDsndHZZ5/zDKTTL+cf5F0mzXkQEwhx8IlWnqYvEovf2S5EwLTw==} @@ -784,6 +805,48 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@opentelemetry/api-logs@0.220.0': + resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.12.0': + resolution: {integrity: sha512-1HsSAuvT4/my0QrXWsyXFdWjKaPeCHsismKZgEEIb8NK13LP6DrMRlsUbvD/0F9mDWK3xDom9AMv3IscxgOnOQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/instrumentation@0.220.0': + resolution: {integrity: sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.12.0': + resolution: {integrity: sha512-+rSRoOdln6NhXTVcBdHSlZxJDNzwxadeweK2ltZO3tVtfp6aklABc06xclDk6ovRR+EiGgIcSbh/6NgjFhnb3A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.12.0': + resolution: {integrity: sha512-JOERgZCxRM06FEJ5kEWxlSRChah6MmXDx3KwCruqEv/iEyLkTokCSemf2y1w8oIHtfrr8EVTpzebq/31vn2Cpg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.12.0': + resolution: {integrity: sha512-ImVuRQ6faOsTi1Wna88s2avgrrf/iDgXPigIeynkUhjstMuoQUDOJXgkez2j1mlU4btjXcXBGa+q7qKFYR3GsA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@parcel/watcher-android-arm64@2.6.0': resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} engines: {node: '>= 10.0.0'} @@ -860,6 +923,22 @@ packages: resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} engines: {node: '>= 10.0.0'} + '@paulirish/trace_engine@0.0.65': + resolution: {integrity: sha512-Qsm6F5C8xf6ZzQXbQc2+wcpe6sggfs/gvc/ytqSurdvYg3kyW0ECHCqE0CWBKZpqgjVfPNX9c7SCS3r2nEIRGg==} + + '@puppeteer/browsers@3.2.4': + resolution: {integrity: sha512-okJ6yQMc0SBexOKxMi966omwAV1uAPwgF/YMZdb04gZQPXVjWHl9bmXjZYtIcsN1+PN3Bz4iFfGK3g+c6lZJ0Q==} + engines: {node: '>=22.12.0'} + hasBin: true + peerDependencies: + proxy-agent: '>=8.0.1' + yauzl: ^2.10.0 || ^3.4.0 + peerDependenciesMeta: + proxy-agent: + optional: true + yauzl: + optional: true + '@redocly/ajv@8.11.2': resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} @@ -1007,6 +1086,51 @@ packages: '@schummar/icu-type-parser@1.21.5': resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@sentry/conventions@0.16.0': + resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==} + engines: {node: '>=14'} + + '@sentry/core@10.76.2': + resolution: {integrity: sha512-vndrQalRiwYatTkjlLWvyf69KocXIb9TKJ4A9oiyWguL8PJYtqIRTlRe4OpkpjLrxS18fvRdV0jEm29c/9p3hQ==} + engines: {node: '>=18'} + + '@sentry/node-core@10.76.2': + resolution: {integrity: sha512-TrgAc/SvU5kQfjw8pz6I8LLIUiblOSf9BPPNkTbS0Dzcqa4yQLVbmsbS3rvGT7ot64m9Vjzn327ZRy63MlI6/w==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 + '@opentelemetry/exporter-trace-otlp-http': '>=0.57.0 <1' + '@opentelemetry/instrumentation': '>=0.57.1 <1' + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/core': + optional: true + '@opentelemetry/exporter-trace-otlp-http': + optional: true + '@opentelemetry/instrumentation': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + + '@sentry/node@10.76.2': + resolution: {integrity: sha512-LSwGGE/GceTbkDHRoQQr3lq/dpxxA6RdooBbuGTJ/S6BdqKX2kw2FdV7Se9eJzZJJJ5AxCZum37+ejYS7N2pJg==} + engines: {node: '>=18'} + + '@sentry/opentelemetry@10.76.2': + resolution: {integrity: sha512-deqWrKJGaku3nhiZchg34z8W9v6kUwtzoQ4cmEq6CeT8/4X93X4YfeJgoh1dhbswFRpwor4pQ6kj0KttagKtfA==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + + '@sentry/server-utils@10.76.2': + resolution: {integrity: sha512-wogj6Trj8fAu+aD7HbpvHEBlyANMOvvO9MP5NFJaiQbprLKugtCdE6rFTxDhofXvRo4nswL9m4Cvysg3k5KcvQ==} + engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1378,9 +1502,20 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1389,6 +1524,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -1397,6 +1536,10 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1460,6 +1603,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + atomically@2.1.1: + resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==} + autoprefixer@10.5.0: resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} engines: {node: ^10 || ^12 || >=14} @@ -1475,6 +1621,10 @@ packages: resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} engines: {node: '>=4'} + axe-core@4.14.0: + resolution: {integrity: sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==} + engines: {node: '>=4'} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -1555,9 +1705,31 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chrome-launcher@1.2.2: + resolution: {integrity: sha512-sI90u5qf3854bTrWsQMMdsQfSjjMbeSn7QtxVb9zrikf+QgMfHQ71KepHW7ZfixQKrDlAFKb+qnVlSR+xepBzw==} + engines: {node: '>=12.13.0'} + hasBin: true + + chromium-bidi@157.0.8090-0: + resolution: {integrity: sha512-KJw7IQgqXwxpJgJlEIPg5mr2ZoqA+jkTRtc5EABI7sUJ3bu3cpI7NuEBoj4Gn1AsF78CaT9So5WQWspK3/wYag==} + engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0'} + peerDependencies: + devtools-protocol: '*' + + cjs-module-lexer@2.3.0: + resolution: {integrity: sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1575,6 +1747,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + configstore@7.1.0: + resolution: {integrity: sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==} + engines: {node: '>=18'} + content-type@2.1.0: resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} engines: {node: '>=18'} @@ -1586,6 +1762,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csp_evaluator@1.1.8: + resolution: {integrity: sha512-EwOnfYuNbTytvbMKsLixTrRgnjOa0WZCxGy8A9nnSYAicrdwn+T/epU/yjgymmOxlgKnvH+8wXt+7p/8ak5Feg==} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -1647,6 +1826,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -1659,6 +1842,12 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devtools-protocol@0.0.1687809: + resolution: {integrity: sha512-t7kSb+UKYdyxcmqy98U2P86ne6rCve3DIJ4+lp54i/hhClgFuG35JrLj23rO14Jw3sWDsp7i+22QhNU0+/qk8g==} + + devtools-protocol@0.0.1696802: + resolution: {integrity: sha512-FO5/SENfvc2K8wRnWQflKcwuoTH4dBkXCWTQ9ebIWtuX8JM5+ZvCotaEmkfh21pO7TvIKGTM3hG4xFiq9WTwmg==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -1675,6 +1864,10 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dot-prop@9.0.0: + resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==} + engines: {node: '>=18'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1682,9 +1875,19 @@ packages: electron-to-chromium@1.5.452: resolution: {integrity: sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -1708,6 +1911,9 @@ packages: es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-module-lexer@3.0.3: + resolution: {integrity: sha512-yWGe1O/j+almnV4hOYrmZo8u58JdnmXEMneRQAYkWtqSiT86yoMIEKF4rpqEDj52tbYl3xceBD3DQIBwcsC9Pw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1888,6 +2094,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1949,6 +2158,14 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1984,6 +2201,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -2015,6 +2235,10 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + http-link-header@1.1.4: + resolution: {integrity: sha512-xT3GPW6/ZbGuw4UvwHqErSCEjNUlwbQJuZn9/q5U4WEKfp2kENVCAlousG1zLxHeaQ/ffOHUNpWamvkbBW0eNw==} + engines: {node: '>=6.0.0'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -2038,10 +2262,17 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + image-ssim@0.2.0: + resolution: {integrity: sha512-W7+sO6/yhxy83L0G7xR8YAc5Z5QFtYEXXRV6EaE8tuYBZJnA3gVgp3q7X7muhLZVodeb9UfvjSbwt9VJwjIYAg==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-in-the-middle@3.5.2: + resolution: {integrity: sha512-WseIA/4o56+GYO3RmlOPvejYi771OFeJEeK/ejROsr9bdjW+E5NXTOD1Di8TL40Qgg3JJX9aa97Hf8iiSVIKZw==} + engines: {node: '>=18'} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -2058,6 +2289,9 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + intl-messageformat@10.7.18: + resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==} + intl-messageformat@11.2.15: resolution: {integrity: sha512-W4VNytsXyWgW5lvVchmsb9xgA5rSKqoflu3/IQ9UCtB0z4R+Bx9MoEkwFT/+30RCl/KzUoH0QGGe9lppF5SuvQ==} @@ -2100,6 +2334,11 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2108,6 +2347,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -2171,6 +2414,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -2185,10 +2432,17 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + js-levenshtein@1.1.6: resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} engines: {node: '>=0.10.0'} + js-library-detector@6.7.0: + resolution: {integrity: sha512-c80Qupofp43y4cJ7+8TTDN/AsDwLi5oOm/plBrWI+iQt485vKXCco+yVmOwEgdo9VOdsYTuV0UlTeetVPTriXA==} + engines: {node: '>=12'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2245,10 +2499,24 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} + legacy-javascript@0.0.3: + resolution: {integrity: sha512-1r6MT4dVaJyXt4G07Cm2cQ0AUaLJdefiEp4SmIAylA++gnEQ82YvmzE58RkM5EP0fk5hROLCslUPGnwIYdZQVA==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lighthouse-logger@2.0.2: + resolution: {integrity: sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg==} + + lighthouse-stack-packs@1.12.3: + resolution: {integrity: sha512-d8IsOpE83kbANgnM+Tp8+x6HcMpX9o2ITBiUERssgzAIFdZCQzs/f4k6D0DLQTE59enml9mbAOU52Wu35exWtg==} + + lighthouse@13.5.0: + resolution: {integrity: sha512-w/yYIwg7mP9pCwkcbAXRztnvKHua2dJnZaNptPLa1cEw+akr6lbsIDiUOLsQwvaiAUr0xvxdmwvAzl3pj3Np0A==} + engines: {node: '>=22.19'} + hasBin: true + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -2260,9 +2528,15 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lookup-closest-locale@6.2.0: + resolution: {integrity: sha512-/c2kL+Vnp1jnV6K6RpDTHK3dgg0Tu2VVp+elEiJpjfS1UyY7AjOYHohRug6wT0OpoX2qFgNORndE9RqesfVxWQ==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -2280,6 +2554,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marky@1.3.0: + resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2310,6 +2587,16 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + modern-tar@0.8.5: + resolution: {integrity: sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==} + engines: {node: '>=18.0.0'} + + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2425,6 +2712,10 @@ packages: resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} engines: {node: '>=12.20.0'} + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + openapi-typescript@7.13.0: resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} hasBin: true @@ -2491,6 +2782,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.64.0: + resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.0: + resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==} + engines: {node: '>=20'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -2569,6 +2870,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + puppeteer-core@25.13.0: + resolution: {integrity: sha512-cyBS0OEx3e9NS53B5F6+UazXtR7f9Tw0cR1/FbPW59/hV6JPZbkIiMnxK2543IkFp3rsGlLn1iPDLF77TJsJsA==} + engines: {node: '>=22.12.0'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2610,10 +2915,18 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2635,6 +2948,10 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + robots-parser@3.0.1: + resolution: {integrity: sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ==} + engines: {node: '>=10.0.0'} + rollup@4.60.4: resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2737,6 +3054,10 @@ packages: resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} + speedline-core@1.4.3: + resolution: {integrity: sha512-DI7/OuAUD+GMpR6dmu8lliO2Wg5zfeh+/xsdyJZCzd8o5JgFUjCeLsBDuZjIQJdwXS3J0L/uZYrELKYqx+PXog==} + engines: {node: '>=8.0'} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -2750,6 +3071,18 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.3.0: + resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} + engines: {node: '>=20'} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -2773,6 +3106,14 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -2785,6 +3126,12 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stubborn-fs@2.0.0: + resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==} + + stubborn-utils@1.0.2: + resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -2830,6 +3177,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + third-party-web@0.30.0: + resolution: {integrity: sha512-p+PfyL5U0ediGzvwPzMSdD9MnC8rHW6nWJJVLIQvqN7RsSqfaZ+7n4c9GILqH36EYR0eXB9CzAeCRnKFr/9uJg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2848,6 +3198,12 @@ packages: tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + tldts-core@7.4.18: + resolution: {integrity: sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==} + + tldts-icann@7.4.18: + resolution: {integrity: sha512-+29GkEj4qOTb8oQRzg7Lxe1TQdkIDFvyqVTcu775faf9iLczNTix8L/wDFiQi4/Ncib7ZjyWPcVf0iRQCrmUjg==} + tldts@6.1.86: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true @@ -2903,6 +3259,9 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} + typed-query-selector@2.12.3: + resolution: {integrity: sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==} + typescript@5.6.3: resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} engines: {node: '>=14.17'} @@ -3027,6 +3386,12 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + web-features@3.42.0: + resolution: {integrity: sha512-Vq97bzyedg6EuPFnz1GIJqsI9me8CbxzwYrcBI/cNwjj66v77IsGmIZi9SxPHTu2t25F7ta4kZJPu4qvuYlhfQ==} + + webdriver-bidi-protocol@1.0.1: + resolution: {integrity: sha512-WXWs/aXF2a6Al1Z2/V/7Gp2oPZOAAmHjJOTmv4KCXRJ122IMj3tSSMc6a4q3K4QOmhQbse/Ahr+oTVJOU2dUiw==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -3044,6 +3409,9 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + when-exit@2.1.5: + resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -3074,6 +3442,26 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + ws@7.5.13: + resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.21.3: resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} @@ -3086,6 +3474,10 @@ packages: utf-8-validate: optional: true + xdg-basedir@5.1.0: + resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} + engines: {node: '>=12'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -3093,6 +3485,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3103,10 +3499,25 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yargs@18.2.0: + resolution: {integrity: sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + snapshots: '@adobe/css-tools@4.5.0': {} @@ -3411,8 +3822,25 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@formatjs/ecma402-abstract@2.3.6': + dependencies: + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/intl-localematcher': 0.6.2 + decimal.js: 10.6.0 + tslib: 2.8.1 + + '@formatjs/fast-memoize@2.2.7': + dependencies: + tslib: 2.8.1 + '@formatjs/fast-memoize@3.1.7': {} + '@formatjs/icu-messageformat-parser@2.11.4': + dependencies: + '@formatjs/ecma402-abstract': 2.3.6 + '@formatjs/icu-skeleton-parser': 1.8.16 + tslib: 2.8.1 + '@formatjs/icu-messageformat-parser@3.5.18': dependencies: '@formatjs/icu-skeleton-parser': 2.1.11 @@ -3421,10 +3849,19 @@ snapshots: dependencies: '@formatjs/icu-skeleton-parser': 2.1.12 + '@formatjs/icu-skeleton-parser@1.8.16': + dependencies: + '@formatjs/ecma402-abstract': 2.3.6 + tslib: 2.8.1 + '@formatjs/icu-skeleton-parser@2.1.11': {} '@formatjs/icu-skeleton-parser@2.1.12': {} + '@formatjs/intl-localematcher@0.6.2': + dependencies: + tslib: 2.8.1 + '@formatjs/intl-localematcher@0.8.14': dependencies: '@formatjs/fast-memoize': 3.1.7 @@ -3622,6 +4059,49 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@opentelemetry/api-logs@0.220.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + import-in-the-middle: 3.5.2 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/resources@2.12.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-trace@2.12.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/semantic-conventions@1.43.0': {} + '@parcel/watcher-android-arm64@2.6.0': optional: true @@ -3678,6 +4158,16 @@ snapshots: '@parcel/watcher-win32-arm64': 2.6.0 '@parcel/watcher-win32-x64': 2.6.0 + '@paulirish/trace_engine@0.0.65': + dependencies: + legacy-javascript: 0.0.3 + third-party-web: 0.30.0 + + '@puppeteer/browsers@3.2.4': + dependencies: + modern-tar: 0.8.5 + yargs: 18.2.0 + '@redocly/ajv@8.11.2': dependencies: fast-deep-equal: 3.1.3 @@ -3784,6 +4274,53 @@ snapshots: '@schummar/icu-type-parser@1.21.5': {} + '@sentry/conventions@0.16.0': {} + + '@sentry/core@10.76.2': + dependencies: + '@sentry/conventions': 0.16.0 + + '@sentry/node-core@10.76.2(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1))': + dependencies: + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.76.2 + '@sentry/opentelemetry': 10.76.2(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1)) + import-in-the-middle: 3.5.2 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.12.0(@opentelemetry/api@1.9.1) + + '@sentry/node@10.76.2(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.12.0(@opentelemetry/api@1.9.1) + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.76.2 + '@sentry/node-core': 10.76.2(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1)) + '@sentry/opentelemetry': 10.76.2(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1)) + '@sentry/server-utils': 10.76.2 + import-in-the-middle: 3.5.2 + transitivePeerDependencies: + - '@opentelemetry/core' + - '@opentelemetry/exporter-trace-otlp-http' + - supports-color + + '@sentry/opentelemetry@10.76.2(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.12.0(@opentelemetry/api@1.9.1) + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.76.2 + + '@sentry/server-utils@10.76.2': + dependencies: + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.76.2 + '@standard-schema/spec@1.1.0': {} '@swc/core-darwin-arm64@1.16.13': @@ -4151,6 +4688,10 @@ snapshots: agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -4158,16 +4699,27 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} + ansi-regex@6.4.0: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -4258,6 +4810,11 @@ snapshots: async-function@1.0.0: {} + atomically@2.1.1: + dependencies: + stubborn-fs: 2.0.0 + when-exit: 2.1.5 + autoprefixer@10.5.0(postcss@8.5.23): dependencies: browserslist: 4.28.7 @@ -4273,6 +4830,8 @@ snapshots: axe-core@4.11.4: {} + axe-core@4.14.0: {} + axobject-query@4.1.0: {} balanced-match@1.0.2: {} @@ -4354,8 +4913,37 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chrome-launcher@1.2.2: + dependencies: + '@types/node': 22.19.19 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 2.0.2 + transitivePeerDependencies: + - supports-color + + chromium-bidi@157.0.8090-0(devtools-protocol@0.0.1696802): + dependencies: + devtools-protocol: 0.0.1696802 + mitt: 3.0.1 + zod: 4.6.5 + + cjs-module-lexer@2.3.0: {} + client-only@0.0.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4368,6 +4956,13 @@ snapshots: concat-map@0.0.1: {} + configstore@7.1.0: + dependencies: + atomically: 2.1.1 + dot-prop: 9.0.0 + graceful-fs: 4.2.11 + xdg-basedir: 5.1.0 + content-type@2.1.0: {} convert-source-map@2.0.0: {} @@ -4378,6 +4973,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csp_evaluator@1.1.8: {} + css.escape@1.5.1: {} cssesc@3.0.0: {} @@ -4434,6 +5031,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@2.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -4444,6 +5043,10 @@ snapshots: detect-libc@2.1.2: {} + devtools-protocol@0.0.1687809: {} + + devtools-protocol@0.0.1696802: {} + didyoumean@1.2.2: {} dlv@1.1.3: {} @@ -4456,6 +5059,10 @@ snapshots: dom-accessibility-api@0.6.3: {} + dot-prop@9.0.0: + dependencies: + type-fest: 4.41.0 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4464,8 +5071,17 @@ snapshots: electron-to-chromium@1.5.452: {} + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + entities@6.0.1: {} es-abstract@1.24.2: @@ -4550,6 +5166,8 @@ snapshots: es-module-lexer@2.3.2: {} + es-module-lexer@3.0.3: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -4834,6 +5452,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.8: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -4888,6 +5508,10 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + + get-east-asian-width@1.7.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4933,6 +5557,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -4959,6 +5585,8 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + http-link-header@1.1.4: {} + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -4985,11 +5613,19 @@ snapshots: ignore@7.0.5: {} + image-ssim@0.2.0: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 resolve-from: 4.0.0 + import-in-the-middle@3.5.2: + dependencies: + cjs-module-lexer: 2.3.0 + es-module-lexer: 3.0.3 + module-details-from-path: 1.0.4 + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -5002,6 +5638,13 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 + intl-messageformat@10.7.18: + dependencies: + '@formatjs/ecma402-abstract': 2.3.6 + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/icu-messageformat-parser': 2.11.4 + tslib: 2.8.1 + intl-messageformat@11.2.15: dependencies: '@formatjs/fast-memoize': 3.1.7 @@ -5055,12 +5698,16 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-docker@2.2.1: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -5125,6 +5772,10 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + isarray@2.0.5: {} isexe@2.0.0: {} @@ -5140,8 +5791,12 @@ snapshots: jiti@1.21.7: {} + jpeg-js@0.4.4: {} + js-levenshtein@1.1.6: {} + js-library-detector@6.7.0: {} + js-tokens@4.0.0: {} js-yaml@4.3.2: @@ -5208,11 +5863,61 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 + legacy-javascript@0.0.3: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 + lighthouse-logger@2.0.2: + dependencies: + debug: 4.4.3(supports-color@10.2.2) + marky: 1.3.0 + transitivePeerDependencies: + - supports-color + + lighthouse-stack-packs@1.12.3: {} + + lighthouse@13.5.0(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1)): + dependencies: + '@paulirish/trace_engine': 0.0.65 + '@sentry/node': 10.76.2(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1)) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + axe-core: 4.14.0 + chrome-launcher: 1.2.2 + configstore: 7.1.0 + csp_evaluator: 1.1.8 + devtools-protocol: 0.0.1687809 + enquirer: 2.4.1 + http-link-header: 1.1.4 + intl-messageformat: 10.7.18 + jpeg-js: 0.4.4 + js-library-detector: 6.7.0 + lighthouse-logger: 2.0.2 + lighthouse-stack-packs: 1.12.3 + lodash-es: 4.18.1 + lookup-closest-locale: 6.2.0 + open: 8.4.2 + puppeteer-core: 25.13.0 + robots-parser: 3.0.1 + speedline-core: 1.4.3 + third-party-web: 0.30.0 + tldts-icann: 7.4.18 + web-features: 3.42.0 + ws: 7.5.13 + yargs: 17.7.3 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - '@opentelemetry/core' + - '@opentelemetry/exporter-trace-otlp-http' + - bufferutil + - proxy-agent + - supports-color + - utf-8-validate + - yauzl + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -5221,8 +5926,12 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.18.1: {} + lodash.merge@4.6.2: {} + lookup-closest-locale@6.2.0: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -5239,6 +5948,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marky@1.3.0: {} + math-intrinsics@1.1.0: {} merge2@1.4.1: {} @@ -5264,6 +5975,12 @@ snapshots: minimist@1.2.8: {} + mitt@3.0.1: {} + + modern-tar@0.8.5: {} + + module-details-from-path@1.0.4: {} + ms@2.1.3: {} mz@2.7.0: @@ -5284,7 +6001,7 @@ snapshots: next-intl-swc-plugin-extractor@4.14.9: {} - next-intl@4.14.9(@types/react@19.2.14)(next@15.5.27(@babel/core@7.29.7)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0): + next-intl@4.14.9(@types/react@19.2.14)(next@15.5.27(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0): dependencies: '@eloqnt/config': 0.1.1 '@eloqnt/format-json': 0.1.0 @@ -5294,7 +6011,7 @@ snapshots: '@swc/core': 1.16.13 icu-minify: 4.14.9 negotiator: 1.1.0 - next: 15.5.27(@babel/core@7.29.7)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next: 15.5.27(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) next-intl-swc-plugin-extractor: 4.14.9 react: 19.0.0 use-intl: 4.14.9(@types/react@19.2.14)(react@19.0.0) @@ -5303,7 +6020,7 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - next@15.5.27(@babel/core@7.29.7)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + next@15.5.27(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 15.5.27 '@swc/helpers': 0.5.15 @@ -5321,6 +6038,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.27 '@next/swc-win32-arm64-msvc': 15.5.27 '@next/swc-win32-x64-msvc': 15.5.27 + '@opentelemetry/api': 1.9.1 sharp: 0.35.5(@types/node@22.19.19) transitivePeerDependencies: - '@babel/core' @@ -5388,6 +6106,12 @@ snapshots: obug@2.2.1: {} + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + openapi-typescript@7.13.0(typescript@5.6.3): dependencies: '@redocly/openapi-core': 1.34.20(supports-color@10.2.2) @@ -5453,6 +6177,12 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.64.0: {} + + playwright@1.64.0: + dependencies: + playwright-core: 1.64.0 + pluralize@8.0.0: {} po-parser@2.2.0: {} @@ -5514,6 +6244,20 @@ snapshots: punycode@2.3.1: {} + puppeteer-core@25.13.0: + dependencies: + '@puppeteer/browsers': 3.2.4 + chromium-bidi: 157.0.8090-0(devtools-protocol@0.0.1696802) + devtools-protocol: 0.0.1696802 + typed-query-selector: 2.12.3 + webdriver-bidi-protocol: 1.0.1 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - proxy-agent + - utf-8-validate + - yauzl + queue-microtask@1.2.3: {} react-dom@19.0.0(react@19.0.0): @@ -5562,8 +6306,17 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3(supports-color@10.2.2) + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -5586,6 +6339,8 @@ snapshots: reusify@1.1.0: {} + robots-parser@3.0.1: {} + rollup@4.60.4: dependencies: '@types/estree': 1.0.8 @@ -5753,6 +6508,12 @@ snapshots: source-map-js@1.2.2: {} + speedline-core@1.4.3: + dependencies: + '@types/node': 22.19.19 + image-ssim: 0.2.0 + jpeg-js: 0.4.4 + stable-hash@0.0.5: {} stackback@0.0.2: {} @@ -5764,6 +6525,23 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + + string-width@8.3.0: + dependencies: + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.9 @@ -5814,6 +6592,14 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.4.0 + strip-bom@3.0.0: {} strip-indent@3.0.0: @@ -5822,6 +6608,12 @@ snapshots: strip-json-comments@3.1.1: {} + stubborn-fs@2.0.0: + dependencies: + stubborn-utils: 1.0.2 + + stubborn-utils@1.0.2: {} + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.0.0): dependencies: client-only: 0.0.1 @@ -5885,6 +6677,8 @@ snapshots: dependencies: any-promise: 1.3.0 + third-party-web@0.30.0: {} + tinybench@2.9.0: {} tinyexec@1.3.1: {} @@ -5898,6 +6692,12 @@ snapshots: tldts-core@6.1.86: {} + tldts-core@7.4.18: {} + + tldts-icann@7.4.18: + dependencies: + tldts-core: 7.4.18 + tldts@6.1.86: dependencies: tldts-core: 6.1.86 @@ -5968,6 +6768,8 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typed-query-selector@2.12.3: {} + typescript@5.6.3: {} unbox-primitive@1.1.0: @@ -6041,7 +6843,7 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 - vitest@4.1.11(@types/node@22.19.19)(jsdom@26.1.0)(vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(jsdom@26.1.0)(vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7)) @@ -6064,6 +6866,7 @@ snapshots: vite: 6.4.3(@types/node@22.19.19)(jiti@1.21.7) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 22.19.19 jsdom: 26.1.0 transitivePeerDependencies: @@ -6073,6 +6876,10 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + web-features@3.42.0: {} + + webdriver-bidi-protocol@1.0.1: {} + webidl-conversions@7.0.0: {} whatwg-encoding@3.1.1: @@ -6086,6 +6893,8 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 + when-exit@2.1.5: {} + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -6138,16 +6947,57 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + ws@7.5.13: {} + ws@8.21.3: {} + xdg-basedir@5.1.0: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} + y18n@5.0.8: {} + yallist@3.1.1: {} yaml-ast-parser@0.0.43: {} yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yargs@18.2.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 8.3.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + yocto-queue@0.1.0: {} + + zod@4.6.5: {} diff --git a/infra/compose/README.md b/infra/compose/README.md index a6a1dd5c..aa4b18dd 100644 --- a/infra/compose/README.md +++ b/infra/compose/README.md @@ -172,7 +172,7 @@ changes. ```bash make e2e-up # tmpfs-backed stack up -COMPOSE_PROFILES=gated make e2e-up # include clean Valkey + Kafka +node scripts/compose.mjs e2e --profile gated up -d # explicit gated selection make e2e-down # stop; tmpfs evaporates # Raw equivalent: @@ -180,9 +180,31 @@ docker compose --env-file .env -f infra/compose/dev.yml -f infra/compose/e2e.yml COMPOSE_PROFILES=gated docker compose --env-file .env -f infra/compose/dev.yml -f infra/compose/e2e.yml up -d ``` -Phase 06 Playwright + Phase 07 SDK contract tests run against this overlay -in CI; the Playwright project itself lives in `frontend/apps/web/e2e/` and -arrives in its owning phase. +P02d-7's isolated demo lifecycle uses this overlay with a unique project and +private configuration. The delivered Playwright library walkthrough covers nine +seeded product pages, followed by 27 Lighthouse audits over the same production +API/build/database with its Keycloak stopped. From a fresh checkout without root +`.env`, web `.env.local` or `artifacts/public-demo`: + +```bash +(cd frontend && pnpm install --frozen-lockfile) +(cd frontend && pnpm exec playwright install --with-deps chromium) +make demo DEMO_MODE=ci +``` + +Fixed ports must be free. The lifecycle owns disposable TLS/configuration, +certificate-specific browser trust and cleanup, without modifying system hosts or +trust. Review the sanitized `artifacts/public-demo/receipt.json`, all 27 JSON/HTML +report pairs and browser screenshots; proof and cleanup must both pass. See +[Running tests locally](../../.claude/skills/run-tests-locally/SKILL.md#public-demo-managed-browser-and-lighthouse) +for the complete workflow and +[branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) +for actual required-check activation. Local success alone does not establish it. +Human `make demo` retains manual hosts/CA/leaf setup and persistent local data. + +The full Playwright Test/axe suite remains Phase 06. Explicit Compose selection ignores +ambient `COMPOSE_*` overrides; use an explicit `--profile gated` for that opt-in. +Host ports remain fixed, so a foreign stack/listener is refused rather than stopped. ### Dev credentials are dev credentials @@ -210,12 +232,11 @@ Phase 01 is complete; the remaining deferrals belong to later phases and NOT to this compose stack: - The .NET API host (`LearnStack.Api`) runs **outside** the compose network - via `dotnet run` on the developer's workstation. Moving it inside compose + via the production `make demo` supervisor or `make public-api` on the workstation. + Moving it inside compose is a Phase 11 (production hardening) decision — the dapr-sidecar-api service is already pointed at `host.docker.internal:5080` so the swap is a one-line `upstream` change. - `livekit-egress` (recording / consent) — Phase 08c. - OpenTelemetry Collector — Phase 11. -- Application-level tenant seeding via `LearnStack.Tools.Seeder` — - Phase 02a (the `scripts/seed.sh` orchestrator stubs the activation point). - Production-grade Vault (HA + auto-unseal + AppRole) — Phase 11. diff --git a/infra/compose/e2e.yml b/infra/compose/e2e.yml index 920b18c9..8efbc9d9 100644 --- a/infra/compose/e2e.yml +++ b/infra/compose/e2e.yml @@ -2,8 +2,8 @@ # # Layered on top of `dev.yml`: # docker compose -f infra/compose/dev.yml -f infra/compose/e2e.yml up -# (the canonical `make` target is `make COMPOSE=$(COMPOSE_E2E) ` — -# see the repo-root `Makefile`). +# Use `make e2e-up` / `make e2e-down`, or the isolated P02d-7 CI demo +# lifecycle. `scripts/compose.mjs` freezes project/files/env selection. # # Why an overlay instead of a separate file: the service definitions, ports, # images, and credentials are identical to dev — only the *operational diff --git a/scripts/ci-public-chromium.mjs b/scripts/ci-public-chromium.mjs new file mode 100644 index 00000000..c4a38d79 --- /dev/null +++ b/scripts/ci-public-chromium.mjs @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import { + accessSync, + constants, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + renameSync, + rmdirSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { DemoProcesses, installDemoCancellation } from './demo-process.mjs'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const restrictionPath = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns'; + +async function runCommand(command, args, options) { + const controller = new AbortController(); + const removeCancellation = installDemoCancellation(() => controller.abort()); + const processes = new DemoProcesses(controller.signal); + try { + return await processes.run(command, args, { + ...options, + capture: true, + observe: (chunk, stream) => { + if (stream === 'stderr') process.stderr.write(chunk); + }, + }); + } finally { + try { + await processes.dispose(); + } finally { + removeCancellation(); + } + } +} + +/** AppArmor attachment syntax is a pattern language: accept literal paths only. */ +export function chromiumProfile(name, executable) { + assert.match(name, /^learnstack-public-chromium-[a-f0-9]{32}$/); + assert.match(executable, /^\/[a-zA-Z0-9_./-]+\/chrome$/); + assert.equal(resolve(executable), executable); + // Chromium's documented userns exception, narrowed to this executable. + // https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md + return `abi ,\ninclude \nprofile ${name} "${executable}" flags=(unconfined) {\n userns,\n}\n`; +} + +function statOrMissing(path) { + try { + return lstatSync(path); + } catch (error) { + if (error.code === 'ENOENT') return undefined; + throw error; + } +} + +/** Injections are for tests only; the CLI always uses fixed host paths/commands. */ +export function chromiumPrerequisite({ + env = process.env, + platform = process.platform, + run = runCommand, + restriction = restrictionPath, +} = {}) { + assert.equal(platform, 'linux', 'Chromium prerequisite requires Linux'); + assert.equal(env.GITHUB_ACTIONS, 'true', 'Chromium prerequisite requires GitHub Actions'); + assert.equal(env.RUNNER_ENVIRONMENT, 'github-hosted', 'Requires an ephemeral hosted runner'); + assert.match(env.GITHUB_RUN_ID ?? '', /^\d+$/); + assert.match(env.GITHUB_RUN_ATTEMPT ?? '', /^\d+$/); + assert.match(env.GITHUB_JOB ?? '', /^[a-zA-Z0-9_-]+$/); + assert.ok(env.RUNNER_TEMP && resolve(env.RUNNER_TEMP) === realpathSync(env.RUNNER_TEMP)); + const runner = `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}-${env.GITHUB_JOB}`; + const directory = join(env.RUNNER_TEMP, `learnstack-public-chromium-${runner}`); + const marker = join(directory, 'owner.json'); + const profile = join(directory, 'profile'); + const phase = join(directory, 'phase'); + const uid = process.getuid(); + const policy = (action) => + run('sudo', ['-n', 'apparmor_parser', '--skip-cache', action, profile], { + cwd: root, + timeout: 10000, + label: `Owned Chromium AppArmor ${action}`, + }); + const updatePhase = (value) => { + writeFileSync(join(directory, 'phase-next'), value, { + flag: 'wx', + mode: 0o600, + }); + renameSync(join(directory, 'phase-next'), phase); + }; + function ownedState() { + const dir = lstatSync(directory); + assert.ok(dir.isDirectory() && dir.uid === uid && (dir.mode & 0o777) === 0o700); + assert.deepEqual(readdirSync(directory).sort(), ['owner.json', 'phase', 'profile']); + for (const file of [marker, profile, phase]) { + const stat = lstatSync(file); + assert.ok(stat.isFile() && stat.uid === uid && stat.nlink === 1); + assert.equal(stat.mode & 0o777, 0o600); + } + const state = JSON.parse(readFileSync(marker, 'utf8')); + assert.deepEqual(Object.keys(state).sort(), [ + 'executable', + 'name', + 'required', + 'runner', + 'schema', + ]); + assert.equal(state.schema, 1); + assert.equal(state.runner, runner); + assert.equal(typeof state.required, 'boolean'); + assert.equal(readFileSync(profile, 'utf8'), chromiumProfile(state.name, state.executable)); + const status = readFileSync(phase, 'utf8'); + assert.ok(['pending', 'loaded', 'unloaded', 'unneeded'].includes(status)); + assert.ok(state.required ? status !== 'unneeded' : status === 'unneeded'); + return { state, status }; + } + async function cleanup() { + if (!statOrMissing(directory)) return; + const { status } = ownedState(); + // An interrupted load is not proof of ownership. Retain evidence and fail. + assert.notEqual(status, 'pending', 'Chromium AppArmor load was not confirmed'); + if (status === 'loaded') { + await policy('--remove'); + updatePhase('unloaded'); + } + ownedState(); + for (const file of [profile, phase, marker]) unlinkSync(file); + rmdirSync(directory); + } + async function prepare() { + assert.ok(!statOrMissing(directory), 'Chromium prerequisite marker must be fresh'); + const discovered = ( + await run( + 'pnpm', + [ + 'exec', + 'node', + '--input-type=module', + '-e', + "import { chromium } from 'playwright'; process.stdout.write(chromium.executablePath());", + ], + { + cwd: join(root, 'frontend'), + timeout: 10000, + label: 'Managed Chromium executable', + }, + ) + ).trim(); + const executable = realpathSync(discovered); + accessSync(executable, constants.X_OK); + assert.ok(lstatSync(executable).isFile(), 'Managed Chromium must be a regular executable'); + const name = `learnstack-public-chromium-${randomBytes(16).toString('hex')}`; + const source = chromiumProfile(name, executable); + let required = false; + try { + const setting = readFileSync(restriction, 'utf8').trim(); + assert.ok(['0', '1'].includes(setting), 'Unknown AppArmor userns restriction'); + required = setting === '1'; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + mkdirSync(directory, { mode: 0o700 }); + writeFileSync(marker, JSON.stringify({ schema: 1, runner, name, executable, required }), { + flag: 'wx', + mode: 0o600, + }); + writeFileSync(profile, source, { flag: 'wx', mode: 0o600 }); + writeFileSync(phase, required ? 'pending' : 'unneeded', { + flag: 'wx', + mode: 0o600, + }); + try { + if (required) { + // --add never replaces another definition. If it fails or is interrupted, + // retain pending state: neither ownership nor absence has been proven. + await policy('--add'); + updatePhase('loaded'); + } + const smoke = join(directory, 'smoke'); + mkdirSync(smoke, { mode: 0o700 }); + try { + const document = await run( + executable, + [ + '--headless=new', + `--user-data-dir=${smoke}`, + '--no-first-run', + '--no-default-browser-check', + '--disable-background-networking', + '--disable-component-update', + '--disable-sync', + '--no-proxy-server', + '--dump-dom', + 'about:blank', + ], + { + cwd: root, + timeout: 20000, + label: 'Sandbox-enabled managed Chromium smoke', + }, + ); + assert.match(document, /]/i, 'Chromium smoke must render a document'); + } finally { + rmSync(smoke, { recursive: true }); + } + } catch (error) { + try { + await cleanup(); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'Chromium prerequisite and cleanup failed'); + } + throw error; + } + return { executable, apparmor: required }; + } + return { prepare, cleanup }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const [action, ...extra] = process.argv.slice(2); + assert.ok(['prepare', 'cleanup'].includes(action) && extra.length === 0); + const result = await chromiumPrerequisite()[action](); + console.log( + `Chromium prerequisite ${action} passed${result ? ` (AppArmor: ${result.apparmor})` : ''}`, + ); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/ci-public-chromium.test.mjs b/scripts/ci-public-chromium.test.mjs new file mode 100644 index 00000000..ad47e744 --- /dev/null +++ b/scripts/ci-public-chromium.test.mjs @@ -0,0 +1,246 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { promisify } from 'node:util'; +import { chromiumPrerequisite, chromiumProfile } from './ci-public-chromium.mjs'; + +const execute = promisify(execFile); + +function fixture(t, { setting = '1', loadFails = false, smokeFails = false } = {}) { + const temp = mkdtempSync(join(realpathSync(tmpdir()), 'ci-public-chromium-test-')); + t.after(() => rmSync(temp, { recursive: true, force: true })); + const executable = join(temp, 'managed/chrome'); + mkdirSync(join(temp, 'managed')); + writeFileSync(executable, '#!/bin/sh\nprintf "\\n"\n', { + mode: 0o700, + }); + const restriction = join(temp, 'userns'); + if (setting !== null) writeFileSync(restriction, setting); + const env = { + GITHUB_ACTIONS: 'true', + RUNNER_ENVIRONMENT: 'github-hosted', + GITHUB_RUN_ID: '123', + GITHUB_RUN_ATTEMPT: '2', + GITHUB_JOB: 'public-demo', + RUNNER_TEMP: temp, + }; + const directory = join(temp, 'learnstack-public-chromium-123-2-public-demo'); + const calls = []; + const loaded = new Set(['foreign-profile']); + const control = { removeFails: false }; + async function run(command, args, options) { + calls.push({ command, args, options }); + if (command === 'pnpm') { + assert.deepEqual(args.slice(0, 4), ['exec', 'node', '--input-type=module', '-e']); + assert.match(args[4], /chromium\.executablePath\(\)/); + assert.ok(options.cwd.endsWith('/frontend')); + return executable; + } + if (command === 'sudo') { + assert.deepEqual(args.slice(0, 3), ['-n', 'apparmor_parser', '--skip-cache']); + const source = readFileSync(args[4], 'utf8'); + const name = source.match(/profile (\S+) /)[1]; + if (args[3] === '--add') { + // Model --add refusing a foreign definition with this same name. + if (loadFails) { + loaded.add(name); + throw new Error('Definition already exists'); + } + assert.ok(!loaded.has(name)); + loaded.add(name); + } else { + assert.equal(args[3], '--remove'); + if (control.removeFails) throw new Error('Removal failed'); + assert.ok(loaded.delete(name)); + } + return ''; + } + assert.equal(command, executable); + assert.equal(options.timeout, 20000); + assert.ok(args.includes('--headless=new') && args.includes('--dump-dom')); + assert.equal(args.at(-1), 'about:blank'); + assert.ok(!args.some((arg) => /no-sandbox|disable.*sandbox|ignore-certificate/.test(arg))); + assert.equal( + args.find((arg) => arg.startsWith('--user-data-dir=')), + `--user-data-dir=${directory}/smoke`, + ); + assert.ok(existsSync(join(directory, 'smoke'))); + if (smokeFails) throw new Error('Sandbox launch refused'); + return (await execute(command, args, { timeout: options.timeout })).stdout; + } + const prerequisite = chromiumPrerequisite({ + env, + platform: 'linux', + run, + restriction, + }); + return { + prerequisite, + directory, + executable, + env, + restriction, + calls, + loaded, + control, + temp, + }; +} + +test('AppArmor source attaches only a literal executable path', () => { + const name = `learnstack-public-chromium-${'a'.repeat(32)}`; + const profile = chromiumProfile( + name, + '/home/runner/.cache/ms-playwright/chromium-1248/chrome-linux64/chrome', + ); + assert.match(profile, /^abi ,\ninclude \n/); + assert.match(profile, /flags=\(unconfined\) \{\n userns,\n\}\n$/); + for (const path of [ + '/tmp/**/chrome', + '/tmp/[ab]/chrome', + '/tmp/../bin/chrome', + '/tmp/"/chrome', + '/tmp/\n/chrome', + ]) + assert.throws(() => chromiumProfile(name, path)); +}); + +test('prepare loads one exact profile, renders sandbox-enabled smoke and cleans only its policy', async (t) => { + const f = fixture(t); + const foreign = join(f.temp, 'foreign-profile-file'); + writeFileSync(foreign, 'untouched'); + assert.deepEqual(await f.prerequisite.prepare(), { + executable: f.executable, + apparmor: true, + }); + assert.equal(f.loaded.size, 2); + const source = readFileSync(join(f.directory, 'profile'), 'utf8'); + assert.ok(source.includes(`"${f.executable}" flags=(unconfined)`)); + assert.ok(!existsSync(join(f.directory, 'smoke'))); + await f.prerequisite.cleanup(); + await f.prerequisite.cleanup(); + assert.deepEqual([...f.loaded], ['foreign-profile']); + assert.equal(readFileSync(foreign, 'utf8'), 'untouched'); + assert.ok(!existsSync(f.directory)); + assert.deepEqual( + f.calls.filter((call) => call.command === 'sudo').map((call) => call.args[3]), + ['--add', '--remove'], + ); +}); + +for (const setting of ['0', null]) { + test(`restriction ${setting ?? 'absent'} needs no policy but still requires browser smoke`, async (t) => { + const f = fixture(t, { setting }); + assert.equal((await f.prerequisite.prepare()).apparmor, false); + await f.prerequisite.cleanup(); + assert.ok(f.calls.some((call) => call.command === f.executable)); + assert.ok(!f.calls.some((call) => call.command === 'sudo')); + }); +} + +test('--add failure never removes the existing foreign definition', async (t) => { + const f = fixture(t, { loadFails: true }); + await assert.rejects(f.prerequisite.prepare(), (error) => { + assert.ok(error instanceof AggregateError); + assert.match(error.errors[0].message, /Definition already exists/); + assert.match(error.errors[1].message, /load was not confirmed/); + return true; + }); + assert.equal(f.loaded.size, 2); + assert.deepEqual( + f.calls.filter((call) => call.command === 'sudo').map((call) => call.args[3]), + ['--add'], + ); + assert.equal(readFileSync(join(f.directory, 'phase'), 'utf8'), 'pending'); + await assert.rejects(f.prerequisite.cleanup(), /load was not confirmed/); + assert.equal(f.loaded.size, 2); +}); + +test('smoke failure unloads its successfully created policy and removes its profile', async (t) => { + const f = fixture(t, { smokeFails: true }); + await assert.rejects(f.prerequisite.prepare(), /Sandbox launch refused/); + assert.deepEqual([...f.loaded], ['foreign-profile']); + assert.ok(!existsSync(f.directory)); +}); + +test('duplicate prepare refuses the existing marker without loading again', async (t) => { + const f = fixture(t); + await f.prerequisite.prepare(); + await assert.rejects(f.prerequisite.prepare(), /marker must be fresh/); + assert.equal(f.calls.filter((call) => call.command === 'sudo').length, 1); + await f.prerequisite.cleanup(); +}); + +for (const mutation of ['runner', 'profile', 'extra-file', 'symlink', 'pending']) { + test(`cleanup refuses ${mutation} ownership mismatch before any policy command`, async (t) => { + const f = fixture(t); + await f.prerequisite.prepare(); + const commands = f.calls.length; + if (mutation === 'runner') { + const marker = join(f.directory, 'owner.json'); + const state = JSON.parse(readFileSync(marker, 'utf8')); + state.runner = '456-1-other-job'; + writeFileSync(marker, JSON.stringify(state)); + } else if (mutation === 'profile') { + writeFileSync(join(f.directory, 'profile'), 'profile foreign-profile { }'); + } else if (mutation === 'extra-file') { + writeFileSync(join(f.directory, 'foreign'), 'untouched'); + } else if (mutation === 'symlink') { + const marker = join(f.directory, 'owner.json'); + const foreign = join(f.temp, 'foreign-marker'); + writeFileSync(foreign, readFileSync(marker)); + rmSync(marker); + symlinkSync(foreign, marker); + } else { + writeFileSync(join(f.directory, 'phase'), 'pending'); + } + await assert.rejects(f.prerequisite.cleanup()); + assert.equal(f.calls.length, commands); + assert.equal(f.loaded.size, 2); + assert.ok(existsSync(f.directory)); + }); +} + +test('failed unload retains owned marker and allows a successful cleanup retry', async (t) => { + const f = fixture(t); + await f.prerequisite.prepare(); + f.control.removeFails = true; + await assert.rejects(f.prerequisite.cleanup(), /Removal failed/); + assert.equal(readFileSync(join(f.directory, 'phase'), 'utf8'), 'loaded'); + f.control.removeFails = false; + await f.prerequisite.cleanup(); + assert.deepEqual([...f.loaded], ['foreign-profile']); + assert.ok(!existsSync(f.directory)); +}); + +test('unknown kernel setting and missing executable fail before creating owned state', async (t) => { + const f = fixture(t, { setting: 'unexpected' }); + await assert.rejects(f.prerequisite.prepare(), /Unknown AppArmor/); + assert.ok(!existsSync(f.directory)); + rmSync(f.executable); + await assert.rejects(f.prerequisite.prepare(), /ENOENT/); + assert.ok(!existsSync(f.directory)); + assert.ok(!f.calls.some((call) => call.command === 'sudo')); +}); + +test('host policy operations refuse a local or self-hosted environment', (t) => { + const f = fixture(t); + for (const change of [ + { GITHUB_ACTIONS: 'false' }, + { RUNNER_ENVIRONMENT: 'self-hosted' }, + { GITHUB_RUN_ID: '../other' }, + ]) + assert.throws(() => chromiumPrerequisite({ env: { ...f.env, ...change }, platform: 'linux' })); +}); diff --git a/scripts/compose.d.mts b/scripts/compose.d.mts new file mode 100644 index 00000000..223ed256 --- /dev/null +++ b/scripts/compose.d.mts @@ -0,0 +1,13 @@ +export const repositoryRoot: string; +export function composeSelection( + mode: string, + environment?: Partial, + root?: string, +): { + readonly mode: string; + readonly project: string; + readonly file: string; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + readonly args: readonly string[]; +}; diff --git a/scripts/compose.mjs b/scripts/compose.mjs new file mode 100644 index 00000000..130d8053 --- /dev/null +++ b/scripts/compose.mjs @@ -0,0 +1,69 @@ +import { spawn } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const repositoryRoot = fileURLToPath(new URL('../', import.meta.url)); + +/** One explicit selection, shared by start, readiness, seeding and teardown. */ +export function composeSelection(mode, environment = process.env, root = repositoryRoot) { + if (!['dev', 'e2e'].includes(mode)) throw new Error('Unsupported Compose mode'); + const project = environment.LEARNSTACK_COMPOSE_PROJECT ?? `learnstack-${mode}`; + if (!/^[a-z0-9][a-z0-9_-]{0,62}$/.test(project)) throw new Error('Invalid Compose project'); + const file = resolve(root, environment.LEARNSTACK_COMPOSE_ENV_FILE ?? '.env'); + if (!existsSync(file)) + throw new Error('Compose environment file is missing; run make public-env'); + const source = readFileSync(file, 'utf8'); + // Docker also reads its own controls from --env-file, after this process's + // environment has been sanitized. Reject every supported assignment spelling. + if (/^\s*(?:export\s+)?COMPOSE_[A-Za-z0-9_]*\s*(?:[=:]|$)/im.test(source)) + throw new Error('Reserved COMPOSE_* controls cannot appear in the selected environment'); + const env = { ...environment }; + // Explicit selection cannot be widened by an inherited profile/file/project. + for (const key of Object.keys(env)) if (/^COMPOSE_/i.test(key)) delete env[key]; + // Compose's process environment otherwise silently overrides --env-file. + for (const match of source.matchAll(/^([A-Za-z_][A-Za-z0-9_]*)=/gm)) delete env[match[1]]; + const files = [resolve(root, 'infra/compose/dev.yml')]; + if (mode === 'e2e') files.push(resolve(root, 'infra/compose/e2e.yml')); + return Object.freeze({ + mode, + project, + file, + cwd: root, + env: Object.freeze(env), + args: Object.freeze([ + 'compose', + '--project-name', + project, + '--env-file', + file, + ...files.flatMap((value) => ['-f', value]), + ]), + }); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const selected = composeSelection( + process.argv[2] === 'selected' + ? (process.env.LEARNSTACK_COMPOSE_MODE ?? 'dev') + : process.argv[2], + ); + const child = spawn('docker', [...selected.args, ...process.argv.slice(3)], { + cwd: selected.cwd, + env: selected.env, + stdio: 'inherit', + }); + child.on('error', () => { + console.error('Compose command failed'); + process.exitCode = 1; + }); + child.on('exit', (code) => { + process.exitCode = code ?? 1; + }); + for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => child.kill(signal)); + } catch { + console.error('Invalid Compose selection or missing private environment'); + process.exitCode = 1; + } +} diff --git a/scripts/demo-http.d.mts b/scripts/demo-http.d.mts new file mode 100644 index 00000000..e166156e --- /dev/null +++ b/scripts/demo-http.d.mts @@ -0,0 +1,15 @@ +export function boundedBody(response: Response, maximum: number): Promise; +export function vacant(port: number): Promise; +export function demoRequest( + url: string, + options: { + ca: string[] | Buffer; + signal: AbortSignal; + loopback?: boolean; + maximum?: number; + }, +): Promise<{ + status: number; + headers: import('node:http').IncomingHttpHeaders; + body: string; +}>; diff --git a/scripts/demo-http.mjs b/scripts/demo-http.mjs new file mode 100644 index 00000000..9b930fd3 --- /dev/null +++ b/scripts/demo-http.mjs @@ -0,0 +1,83 @@ +import { request } from 'node:https'; +import { createServer } from 'node:net'; + +export async function boundedBody(response, maximum) { + const reader = response.body?.getReader(); + if (!reader) throw new Error('Missing response body'); + const chunks = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maximum) throw new Error('Response exceeds bound'); + chunks.push(Buffer.from(value)); + } + return Buffer.concat(chunks).toString('utf8'); + } finally { + await reader.cancel(); + reader.releaseLock(); + } +} + +export function vacant(port) { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', (cause) => + reject( + new Error( + cause.code === 'EADDRINUSE' + ? `Port ${port} is occupied; stop its owner before make demo` + : 'Demo listener bind check failed', + { cause }, + ), + ), + ); + server.listen(port, '127.0.0.1', () => + server.close((error) => (error ? reject(error) : resolve())), + ); + }); +} + +/** Certificate validation and SNI keep the public hostname; only CI resolves it locally. */ +export function demoRequest(url, { ca, signal, loopback = false, maximum = 8 * 1024 * 1024 }) { + return new Promise((resolve, reject) => { + const target = new URL(url); + const req = request( + target, + { + ca, + rejectUnauthorized: true, + signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]), + ...(loopback + ? { + lookup: (_name, options, callback) => + options.all + ? callback(null, [{ address: '127.0.0.1', family: 4 }]) + : callback(null, '127.0.0.1', 4), + } + : {}), + }, + (response) => { + const chunks = []; + let size = 0; + response.on('data', (chunk) => { + size += chunk.length; + if (size > maximum) response.destroy(new Error('Response exceeds bound')); + else chunks.push(chunk); + }); + response.once('error', reject); + response.once('end', () => + resolve({ + status: response.statusCode, + headers: response.headers, + body: Buffer.concat(chunks).toString('utf8'), + }), + ); + }, + ); + req.once('error', reject); + req.end(); + }); +} diff --git a/scripts/demo-observation.d.mts b/scripts/demo-observation.d.mts new file mode 100644 index 00000000..bbdf621e --- /dev/null +++ b/scripts/demo-observation.d.mts @@ -0,0 +1,25 @@ +export interface CompletionState { + total: number; + statuses: Record; + paths: Record; + malformed: boolean; + pendingBytes: number; +} +export function apiCompletionObserver(): { + state: CompletionState; + consume(chunk: Buffer, stream?: 'stdout' | 'stderr'): void; + finish(): void; +}; +export function finalCompletionCensus( + beforeCleanup: CompletionState, + afterCleanup: CompletionState, + expected: number, +): void; +export function completionDelta( + before: CompletionState, + after: CompletionState, + expected: number, +): { + count: number; + statuses: Record; +}; diff --git a/scripts/demo-observation.mjs b/scripts/demo-observation.mjs new file mode 100644 index 00000000..50e25bbf --- /dev/null +++ b/scripts/demo-observation.mjs @@ -0,0 +1,104 @@ +import { StringDecoder } from 'node:string_decoder'; + +/** Consume structured completion events; retain counts, never raw logs or scope data. */ +export function apiCompletionObserver() { + const state = { total: 0, statuses: {}, paths: {}, malformed: false, pendingBytes: 0 }; + const streams = new Map(); + function consume(chunk, stream = 'stdout') { + let input = streams.get(stream); + if (!input) { + input = { decoder: new StringDecoder('utf8'), tail: '' }; + streams.set(stream, input); + } + input.tail += input.decoder.write(chunk); + if (input.tail.length > 1024 * 1024) throw new Error('Unbounded API completion line'); + const lines = input.tail.split('\n'); + input.tail = lines.pop(); + state.pendingBytes = [...streams.values()].reduce( + (count, value) => count + Buffer.byteLength(value.tail), + 0, + ); + for (const line of lines) { + let value; + try { + value = JSON.parse(line); + } catch { + // The owned API uses structured console output, including startup. + // Missing prefixes/braces must not turn damaged completion events into + // an ignored plaintext banner. Only empty separators are harmless. + if (line.trim()) state.malformed = true; + continue; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + state.malformed = true; + continue; + } + if ( + value.SourceContext !== 'Microsoft.AspNetCore.Hosting.Diagnostics' || + value.EventId?.Id !== 2 + ) + continue; + if (typeof value.Path !== 'string' || !Number.isInteger(value.StatusCode)) { + state.malformed = true; + continue; + } + if ( + !['GET', 'HEAD'].includes(value.Method) || + !/^\/api\/v1\/public\/(?:site|courses(?:\/[^/]+(?:\/lessons(?:\/[^/]+)?)?)?)$/.test( + value.Path, + ) + ) { + state.malformed = true; + continue; + } + state.total++; + state.statuses[value.StatusCode] = (state.statuses[value.StatusCode] ?? 0) + 1; + // Resource slugs are public, but receipts need only the endpoint family. + const family = + value.Path === '/api/v1/public/site' + ? 'site' + : value.Path.includes('/lessons/') + ? 'lesson' + : 'course'; + state.paths[family] = (state.paths[family] ?? 0) + 1; + } + } + function finish() { + for (const input of streams.values()) { + if ((input.tail + input.decoder.end()).trim()) state.malformed = true; + input.tail = ''; + } + state.pendingBytes = 0; + } + return { state, consume, finish }; +} + +export function completionDelta(before, after, expected) { + if ( + after.malformed || + after.pendingBytes !== 0 || + after.statuses[429] || + after.total - before.total !== expected || + expected > 48 + ) + throw new Error('Public API completion census refused'); + const statuses = Object.fromEntries( + Object.entries(after.statuses).map(([code, count]) => [ + code, + count - (before.statuses[code] ?? 0), + ]), + ); + if (Object.entries(statuses).some(([code, count]) => count && code !== '200')) + throw new Error('Public API completion status refused'); + return { count: expected, statuses }; +} + +/** Only joined API output can establish the final, unchanged successful census. */ +export function finalCompletionCensus(beforeCleanup, afterCleanup, expected) { + completionDelta(beforeCleanup, afterCleanup, 0); + if ( + afterCleanup.total !== expected || + Object.entries(afterCleanup.statuses).some(([code, count]) => count && code !== '200') + ) + throw new Error('Final API completion census refused'); +} diff --git a/scripts/demo-process.d.mts b/scripts/demo-process.d.mts new file mode 100644 index 00000000..96a9a11f --- /dev/null +++ b/scripts/demo-process.d.mts @@ -0,0 +1,27 @@ +import type { ChildProcess } from 'node:child_process'; +export function installDemoCancellation(cancel: () => void): () => void; +export class DemoCommandError extends Error { + constructor(label: string, cause?: unknown); +} +interface Options { + cwd?: string; + env?: NodeJS.ProcessEnv; + label?: string; + capture?: boolean; + timeout?: number; + observe?: (chunk: Buffer, stream: 'stdout' | 'stderr') => void; +} +interface Running { + child: ChildProcess; + exit: Promise<{ code: number }>; + result(): { output: string; failed?: Error }; +} +export class DemoProcesses { + constructor(signal: AbortSignal); + failure: Promise; + assertActive(): void; + start(command: string, args: string[], options?: Options): Running; + daemon(command: string, args: string[], options: Options): Running; + run(command: string, args: string[], options?: Options): Promise; + dispose(): Promise; +} diff --git a/scripts/demo-process.mjs b/scripts/demo-process.mjs new file mode 100644 index 00000000..f42b9827 --- /dev/null +++ b/scripts/demo-process.mjs @@ -0,0 +1,172 @@ +import { spawn } from 'node:child_process'; +import { stopTestChild } from '../frontend/apps/web/scripts/stop-test-child.mjs'; + +/** Only fixed caller-owned stage labels reach the foreground CLI. */ +export class DemoCommandError extends Error { + constructor(label, cause) { + super(`demo: ${label} failed`, { cause }); + } +} + +/** Terminal hangup and parent loss must enter the same owned teardown path. */ +export function installDemoCancellation(cancel) { + const removers = []; + const parent = process.ppid; + for (const event of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + process.on(event, cancel); + removers.push(() => process.off(event, cancel)); + } + for (const stream of [process.stdout, process.stderr]) { + stream.on('error', cancel); + removers.push(() => stream.off('error', cancel)); + } + const parentWatch = setInterval(() => { + if (process.ppid !== parent) cancel(); + }, 500); + return () => { + clearInterval(parentWatch); + for (const remove of removers) remove(); + }; +} + +/** Own every command tree, including setup commands, from spawn until confirmed exit. */ +export class DemoProcesses { + #children = new Map(); + #signal; + #failed; + #failure; + #closed = false; + constructor(signal) { + this.#signal = signal; + this.failure = new Promise((resolve) => { + this.#failure = resolve; + }); + } + assertActive() { + if (this.#closed) throw new Error('Demo process owner is closed'); + this.#signal.throwIfAborted(); + if (this.#failed) throw this.#failed; + } + start(command, args, { cwd, env, label, observe = () => {}, capture = false } = {}) { + this.assertActive(); + const child = spawn(command, args, { + cwd, + env, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }); + const closed = new Promise((resolve) => child.once('close', resolve)); + const output = []; + let outputBytes = 0; + let failed; + for (const stream of [child.stdout, child.stderr]) { + stream.once('error', (cause) => { + failed ??= new Error(`${label} output refused`, { cause }); + void stopTestChild(child); + }); + stream.on('data', (chunk) => { + try { + observe(chunk, stream === child.stdout ? 'stdout' : 'stderr'); + if (capture && stream === child.stdout) { + outputBytes += chunk.length; + if (outputBytes > 2 * 1024 * 1024) throw new Error('Command output exceeds bound'); + output.push(chunk); + } + } catch (cause) { + failed ??= new Error(`${label} output refused`, { cause }); + void stopTestChild(child); + } + }); + } + const exit = new Promise((resolve) => { + child.once('error', () => resolve({ code: 1 })); + child.once('close', (code) => resolve({ code: code ?? 1 })); + }); + const running = { + child, + exit, + result: () => ({ + output: Buffer.concat(output).toString('utf8'), + failed, + }), + }; + this.#children.set(child, { + closed, + result: running.result, + daemon: false, + }); + return running; + } + daemon(command, args, options) { + const running = this.start(command, args, options); + this.#children.get(running.child).daemon = true; + void running.exit.then(() => { + this.#failed = new DemoCommandError(`${options.label} exited`, running.result().failed); + this.#failure(this.#failed); + }); + return running; + } + async run(command, args, options = {}) { + const running = this.start(command, args, options); + const timeout = AbortSignal.timeout(options.timeout ?? 180000); + const signal = AbortSignal.any([this.#signal, timeout]); + let cancelled; + const aborted = new Promise((resolve) => { + cancelled = () => resolve({ code: 1, cancelled: true }); + signal.addEventListener('abort', cancelled, { once: true }); + if (signal.aborted) cancelled(); + }); + try { + const result = await Promise.race([ + running.exit, + aborted, + this.failure.then(() => ({ code: 1 })), + ]); + if (result.code !== 0 || running.result().failed) + throw new DemoCommandError( + options.label ?? command, + running.result().failed ?? this.#failed, + ); + this.assertActive(); + return running.result().output; + } finally { + signal.removeEventListener('abort', cancelled); + await stopTestChild(running.child); + } + } + async dispose() { + this.#closed = true; + const results = await Promise.allSettled( + [...this.#children].map(async ([child, { closed, result, daemon }]) => { + const deadline = Date.now() + 5000; + await stopTestChild(child); + // Group absence does not establish stream EOF. A closed child has also + // delivered its buffered stdout/stderr; bound this separate drain so + // inherited/leaked pipes cannot hang teardown. + let timer; + try { + await Promise.race([ + closed, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('Owned output did not close')), + Math.max(0, deadline - Date.now()), + ); + }), + ]); + } finally { + clearTimeout(timer); + } + if (daemon && result().failed) + throw new Error('Owned output was refused', { + cause: result().failed, + }); + }), + ); + if (results.some((row) => row.status === 'rejected')) + throw new AggregateError( + results.filter((row) => row.status === 'rejected').map((row) => row.reason), + 'Owned process cleanup failed; private files retained', + ); + } +} diff --git a/scripts/demo.d.mts b/scripts/demo.d.mts new file mode 100644 index 00000000..1ffc8e2b --- /dev/null +++ b/scripts/demo.d.mts @@ -0,0 +1,58 @@ +export function isApiListenerEvent(line: string, origin: string): boolean; +export function validateCertificate( + certificate: Buffer, + privateKey: Buffer, + hosts: string[], + now?: number, +): import('node:crypto').X509Certificate; + +export function disposeDemoResources(options: { + processes: Pick; + finish?: () => void | Promise; + teardown?: () => void | Promise; + removePrivate: () => void | Promise; +}): Promise; +export function startDemo(mode: 'local' | 'ci'): Promise<{ + mode: 'local' | 'ci'; + manifest: DemoLane[]; + configuration: import('../frontend/apps/web/src/server/ingress').PublicServerConfiguration; + ca: string[] | Buffer; + leaf: import('node:crypto').X509Certificate; + privateRoot: string; + observation: { + apiReady: boolean; + webReady: boolean; + counts: Map; + malformed: boolean; + }; + completions: import('./demo-observation.mjs').CompletionState; + processes: import('./demo-process.mjs').DemoProcesses; + signal: AbortSignal; + dispose(): Promise<{ + passed: true; + milliseconds: number; + processesStopped: true; + composeEmpty: boolean; + privateFilesRemoved: boolean; + environmentRemoved: boolean; + lockRemoved: boolean; + }>; + compose(args: string[], options?: { timeout?: number; capture?: boolean }): Promise; + signalRequested(): boolean; + setupMilliseconds: number; +}>; + +interface DemoLane { + host: string; + locale: string; + displayName: string; + courseTitle: string; + lessonTitle: string; + catalogUrl: string; + courseUrl: string; + lessonUrl: string; + fields: { name: string; label: string; value: string }[]; + theme: Record; + excludedTitles: string[]; + excludedContent: string[]; +} diff --git a/scripts/demo.mjs b/scripts/demo.mjs new file mode 100644 index 00000000..3b59ae2b --- /dev/null +++ b/scripts/demo.mjs @@ -0,0 +1,561 @@ +import { randomBytes, X509Certificate, createPublicKey } from 'node:crypto'; +import { lookup } from 'node:dns/promises'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, + unlinkSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve, join } from 'node:path'; +import { getCACertificates } from 'node:tls'; +import { setTimeout as pause } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { composeSelection, repositoryRoot } from './compose.mjs'; +import { DemoCommandError, DemoProcesses, installDemoCancellation } from './demo-process.mjs'; +import { demoRequest, vacant } from './demo-http.mjs'; +import { defaultServices, waitCompose } from './wait-compose.mjs'; +import { publicDemoApiEnvironment, publicWebEnvironment } from './public-runtime.mjs'; +import { apiCompletionObserver } from './demo-observation.mjs'; + +const appRoot = join(repositoryRoot, 'frontend/apps/web'); +const backendRoot = join(repositoryRoot, 'backend'); +const seederDll = join( + backendRoot, + 'src/LearnStack.Tools.Seeder/bin/Release/net10.0/LearnStack.Tools.Seeder.dll', +); + +/** Only the owned host's structured lifetime event establishes listener readiness. */ +export function isApiListenerEvent(line, origin) { + try { + const event = JSON.parse(line); + return event?.SourceContext === 'Microsoft.Hosting.Lifetime' && event.address === origin; + } catch { + return false; + } +} + +export function validateCertificate(certificate, privateKey, hosts, now = Date.now()) { + const leaf = new X509Certificate(certificate); + if ( + Date.parse(leaf.validFrom) > now || + Date.parse(leaf.validTo) <= now || + !leaf.publicKey + .export({ type: 'spki', format: 'der' }) + .equals(createPublicKey(privateKey).export({ type: 'spki', format: 'der' })) || + hosts.some((host) => !leaf.checkHost(host)) + ) + throw new Error('TLS leaf/key/validity/host mismatch; follow README Quickstart'); + return leaf; +} + +/** Attempt every owned teardown stage; private files survive any unconfirmed stage. */ +export async function disposeDemoResources({ processes, finish, teardown, removePrivate }) { + const failures = []; + for (const attempt of [() => processes.dispose(), finish, teardown]) { + if (!attempt) continue; + try { + await attempt(); + } catch (error) { + failures.push(error); + } + } + if (failures.length) + throw new AggregateError( + failures, + 'Owned cleanup could not be confirmed; private files retained', + ); + await removePrivate(); +} + +/** Start the same seeded, production-built topology for foreground use and CI proof. */ +export async function startDemo(mode) { + if (!['local', 'ci'].includes(mode)) throw new Error('Use make demo DEMO_MODE=local or ci'); + const setupStarted = Date.now(); + const controller = new AbortController(); + const processes = new DemoProcesses(controller.signal); + const lock = join(repositoryRoot, '.data/demo.lock'); + const rootEnv = join(repositoryRoot, '.env'); + let privateRoot; + let ownedEnvironment; + let selected; + let startedStack = false; + let locked = false; + let disposed; + let completions; + let signalRequested = false; + const cancel = () => { + signalRequested = true; + controller.abort(); + }; + const removeCancellation = installDemoCancellation(cancel); + const run = (command, args, label, options = {}) => + processes.run(command, args, { + cwd: repositoryRoot, + env: process.env, + label, + ...options, + }); + const compose = (args, options = {}) => + run('docker', [...selected.args, ...args], 'Selected Compose command', { + cwd: selected.cwd, + env: selected.env, + ...options, + }); + async function dispose() { + disposed ??= (async () => { + const started = Date.now(); + const cleanupTimeout = (bound) => { + if (mode !== 'ci') return bound; + // Reserve the final five seconds for an owned command's forced stop. + const remaining = 25000 - (Date.now() - started); + if (remaining <= 0) throw new Error('Owned cleanup deadline'); + return Math.min(bound, remaining); + }; + controller.abort(); + try { + await disposeDemoResources({ + processes, + finish: () => completions?.finish(), + // Cleanup has a separate signal: cancellation must not cancel teardown. + teardown: + mode === 'ci' && startedStack + ? async () => { + const cleanup = new DemoProcesses(new AbortController().signal); + try { + await cleanup.run( + 'docker', + [...selected.args, '--profile', '*', 'down', '--volumes', '--remove-orphans'], + { + cwd: selected.cwd, + env: selected.env, + label: 'Owned Compose teardown', + timeout: cleanupTimeout(20000), + }, + ); + const remaining = await cleanup.run( + 'docker', + [...selected.args, '--profile', '*', 'ps', '-a', '-q'], + { + cwd: selected.cwd, + env: selected.env, + label: 'Owned Compose teardown verification', + capture: true, + timeout: cleanupTimeout(5000), + }, + ); + if (remaining.trim()) throw new Error('Owned Compose cleanup incomplete'); + } finally { + await cleanup.dispose(); + } + } + : undefined, + removePrivate: () => { + if (ownedEnvironment !== undefined) { + if (readFileSync(rootEnv, 'utf8') !== ownedEnvironment) + throw new Error('CI environment changed; private files retained'); + unlinkSync(rootEnv); + } + if (privateRoot) rmSync(privateRoot, { recursive: true }); + if (locked) rmSync(lock, { recursive: true }); + }, + }); + const milliseconds = Date.now() - started; + if (mode === 'ci' && milliseconds > 30000) throw new Error('Owned cleanup deadline'); + return { + passed: true, + milliseconds, + processesStopped: true, + composeEmpty: mode === 'ci' && startedStack, + privateFilesRemoved: !privateRoot || !existsSync(privateRoot), + environmentRemoved: ownedEnvironment === undefined || !existsSync(rootEnv), + lockRemoved: !locked || !existsSync(lock), + }; + } finally { + // Repeated terminal signals must not interrupt asynchronous teardown. + removeCancellation(); + } + })(); + return disposed; + } + try { + if (process.platform === 'win32') + throw new Error('The demo requires the documented POSIX/WSL environment'); + mkdirSync(join(repositoryRoot, '.data'), { recursive: true }); + try { + mkdirSync(lock, { mode: 0o700 }); + locked = true; + } catch { + throw new Error( + 'Another demo owns .data/demo.lock; verify its shutdown before removing a stale lock', + ); + } + await vacant(3000); + for (const [command, args] of [ + ['node', ['--version']], + ['pnpm', ['--version']], + ['dotnet', ['--version']], + ['docker', ['compose', 'version']], + ['openssl', ['version']], + ['make', ['--version']], + ]) + await run(command, args, `Required ${command}`, { + cwd: command === 'dotnet' ? backendRoot : repositoryRoot, + timeout: 15000, + }); + privateRoot = mkdtempSync(join(tmpdir(), 'learnstack-demo-')); + // Git/hook configuration is untouched. The frontend lock and pinned SDK/tool + // manifests are the same dependency inputs used by required checks. + console.log('demo: restoring pinned dependencies'); + await run('pnpm', ['install', '--frozen-lockfile'], 'Locked frontend restore', { + cwd: join(repositoryRoot, 'frontend'), + timeout: 180000, + }); + await run('dotnet', ['restore', 'LearnStack.slnx'], 'Backend restore', { + cwd: backendRoot, + timeout: 180000, + }); + await run('dotnet', ['tool', 'restore'], 'Pinned tools restore', { + cwd: backendRoot, + timeout: 60000, + }); + await run('pnpm', ['--filter', '@learnstack/web', 'build:ingress'], 'Ingress compilation', { + cwd: join(repositoryRoot, 'frontend'), + }); + await run( + 'dotnet', + [ + 'build', + 'src/LearnStack.Tools.Seeder', + '--configuration', + 'Release', + '--no-restore', + '--nologo', + ], + 'Seeder build', + { cwd: backendRoot }, + ); + const manifest = JSON.parse( + await run('dotnet', [seederDll, '--demo-manifest'], 'Seed-derived manifest', { + capture: true, + cwd: backendRoot, + }), + ); + const hosts = [...new Set(manifest.map((lane) => lane.host))]; + const { loadLocalEnvironment, parseLocalEnvironment, publicServerConfiguration } = await import( + '../frontend/apps/web/.server/ingress.js' + ); + if (mode === 'ci') { + if (existsSync(rootEnv) || existsSync(join(appRoot, '.env.local'))) + throw new Error('CI requires an isolated checkout without private root/web configuration'); + const values = parseLocalEnvironment( + readFileSync(join(repositoryRoot, '.env.example'), 'utf8'), + ); + for (const [name, suffix] of [ + ['LEARNSTACK_MIGRATION_PW', 'Migration'], + ['LEARNSTACK_APP_PW', 'Default'], + ['LEARNSTACK_PLATFORM_PW', 'PlatformAdmin'], + ['LEARNSTACK_OUTBOX_PW', 'OutboxDispatcher'], + ]) { + const password = randomBytes(24).toString('hex'); + values[name] = password; + values[`ConnectionStrings__${suffix}`] = values[`ConnectionStrings__${suffix}`].replace( + /Password=[^;]*/, + `Password=${password}`, + ); + } + values.POSTGRES_PASSWORD = randomBytes(24).toString('hex'); + values.LEARNSTACK_PUBLIC_HOP_SECRET = randomBytes(32).toString('base64url'); + values.LEARNSTACK_PUBLIC_TLS_CERT = join(privateRoot, 'leaf.pem'); + values.LEARNSTACK_PUBLIC_TLS_KEY = join(privateRoot, 'leaf-key.pem'); + ownedEnvironment = + Object.entries(values) + .map(([key, value]) => `${key}=${value}`) + .join('\n') + '\n'; + writeFileSync(rootEnv, ownedEnvironment, { flag: 'wx', mode: 0o600 }); + await run( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + values.LEARNSTACK_PUBLIC_TLS_KEY, + '-out', + values.LEARNSTACK_PUBLIC_TLS_CERT, + '-days', + '2', + '-subj', + '/CN=LearnStack temporary demo', + '-addext', + `subjectAltName=DNS:localhost,${hosts.map((host) => `DNS:${host}`).join(',')}`, + ], + 'Disposable CI TLS', + { timeout: 15000 }, + ); + } else { + if (!existsSync(rootEnv)) + writeFileSync(rootEnv, readFileSync(join(repositoryRoot, '.env.example')), { + flag: 'wx', + mode: 0o600, + }); + await run('node', ['scripts/public-local.mjs', 'prepare'], 'Private local preparation'); + } + const rawValues = parseLocalEnvironment(readFileSync(rootEnv, 'utf8')); + for (const [key, value] of Object.entries(rawValues)) + if (process.env[key] !== undefined && process.env[key] !== value) + throw new Error('Environment disagrees with private root configuration'); + const values = loadLocalEnvironment(repositoryRoot, process.env); + const configuration = publicServerConfiguration(values, repositoryRoot); + await vacant(Number(new URL(configuration.apiOrigin).port || '80')); + await run( + 'dotnet', + [seederDll, '--validate-demo-config'], + 'Local database configuration validation', + { cwd: backendRoot, env: { ...process.env, ...rawValues } }, + ); + const certificate = readFileSync(configuration.certificate); + const leaf = validateCertificate(certificate, readFileSync(configuration.privateKey), [ + ...hosts, + 'localhost', + ]); + const ca = + mode === 'ci' + ? certificate + : [...getCACertificates('default'), ...getCACertificates('system')]; + if (mode === 'local') { + for (const host of hosts) { + const addresses = await lookup(host, { all: true }); + if ( + !addresses.length || + addresses.some((row) => !['127.0.0.1', '::1'].includes(row.address)) + ) + throw new Error('Seed hosts must resolve to loopback; follow README Quickstart'); + } + // Trust is checked before infrastructure starts, against the same leaf and + // CA set used by readiness. No trust-store writes are performed. + const trustFile = join(privateRoot, 'trusted-roots.pem'); + writeFileSync(trustFile, ca.join('\n'), { mode: 0o600 }); + await run( + 'openssl', + ['verify', '-CAfile', trustFile, configuration.certificate], + 'TLS trust verification', + { timeout: 15000 }, + ); + } + const composeEnvironment = { + ...process.env, + LEARNSTACK_COMPOSE_PROJECT: + mode === 'ci' ? `learnstack-demo-${randomBytes(8).toString('hex')}` : 'learnstack-dev', + LEARNSTACK_COMPOSE_ENV_FILE: rootEnv, + }; + selected = composeSelection(mode === 'ci' ? 'e2e' : 'dev', composeEnvironment); + const composeModel = JSON.parse( + await compose(['config', '--format', 'json'], { + capture: true, + timeout: 15000, + }), + ); + const existingRows = ( + await compose(['ps', '--status', 'running', '--format', '{{.Service}}'], { + capture: true, + timeout: 15000, + }) + ) + .split('\n') + .filter(Boolean); + if (mode === 'ci' && existingRows.length) throw new Error('CI project must be unoccupied'); + for (const service of defaultServices(composeModel)) { + const description = composeModel.services[service]; + if (mode === 'local' && existingRows.includes(service)) continue; + for (const port of description.ports ?? []) + if (port.protocol === 'tcp' && /^\d+$/.test(String(port.published))) + await vacant(Number(port.published)); + } + console.log('demo: starting the selected seven-service stack'); + startedStack = true; + await compose(['up', '-d'], { timeout: 180000 }); + await waitCompose(selected, processes, controller.signal, true); + await run('make', ['migrate'], 'All migration chains', { + env: { + ...process.env, + ConnectionStrings__Migration: rawValues.ConnectionStrings__Migration, + }, + timeout: 300000, + }); + await waitCompose(selected, processes, controller.signal); + await run('dotnet', [seederDll], 'Contextual seed convergence', { + cwd: backendRoot, + env: { + PATH: process.env.PATH, + HOME: process.env.HOME, + ConnectionStrings__Default: rawValues.ConnectionStrings__Default, + }, + timeout: 180000, + }); + if (mode === 'ci') { + await compose(['stop', 'keycloak'], { timeout: 20000 }); + const rows = await compose(['ps', '--status', 'running', '--format', '{{.Service}}'], { + capture: true, + }); + if (rows.split('\n').includes('keycloak')) throw new Error('Owned Keycloak did not stop'); + } + console.log('demo: building production API and renderer'); + await run( + 'dotnet', + ['build', 'src/LearnStack.Api', '--configuration', 'Release', '--no-restore', '--nologo'], + 'Production API build', + { cwd: backendRoot }, + ); + await run('pnpm', ['--filter', '@learnstack/web', 'build'], 'Production renderer build', { + cwd: join(repositoryRoot, 'frontend'), + timeout: 180000, + }); + const observation = { + apiReady: false, + webReady: false, + counts: new Map(), + malformed: false, + }; + completions = apiCompletionObserver(); + const apiTails = { stdout: '', stderr: '' }; + const observeApi = (chunk, stream) => { + completions.consume(chunk, stream); + let apiTail = apiTails[stream] + chunk.toString(); + if (apiTail.length > 1024 * 1024) throw new Error('Unbounded API diagnostic line'); + const lines = apiTail.split('\n'); + apiTails[stream] = lines.pop(); + for (const line of lines) { + if (isApiListenerEvent(line, configuration.apiOrigin)) observation.apiReady = true; + // Step 3's proof consumes sanitized structured public completion counts. + // No raw diagnostic line is written to artifacts or the terminal. + } + }; + let webTail = ''; + processes.daemon( + 'dotnet', + [join(backendRoot, 'src/LearnStack.Api/bin/Release/net10.0/LearnStack.Api.dll')], + { + cwd: join(backendRoot, 'src/LearnStack.Api'), + env: { + ...publicDemoApiEnvironment(values, configuration, privateRoot), + 'Serilog__MinimumLevel__Override__Microsoft.AspNetCore.Hosting.Diagnostics': + 'Information', + }, + label: 'Owned API', + observe: observeApi, + }, + ); + processes.daemon('node', ['scripts/public-server.mjs'], { + cwd: appRoot, + env: publicWebEnvironment(values, privateRoot), + label: 'Owned native web', + observe: (chunk) => { + webTail = (webTail + chunk.toString()).slice(-1024); + if (webTail.includes('Public HTTPS listener ready on 127.0.0.1:3000')) + observation.webReady = true; + }, + }); + const readinessDeadline = Date.now() + 60000; + while (!observation.apiReady || !observation.webReady) { + processes.assertActive(); + if (Date.now() >= readinessDeadline) throw new Error('Owned API/web startup deadline'); + await pause(100, undefined, { signal: controller.signal }); + } + for (const lane of manifest) { + processes.assertActive(); + const result = await demoRequest(lane.lessonUrl, { + ca, + signal: controller.signal, + loopback: mode === 'ci', + }); + if ( + result.status !== 200 || + !String(result.headers['cache-control']).includes('no-store') || + !result.body.includes(`

${lane.lessonTitle}

`) || + !result.body.includes(lane.displayName) || + !result.body.includes(`lang="${lane.locale}"`) || + lane.fields.some((field) => !result.body.includes(field.value)) + ) + throw new Error('Positive seeded host/locale/content readiness failed'); + } + console.log( + 'demo: ready; Ctrl+C stops only owned API/web processes. Local Compose data is retained.', + ); + for (const lane of manifest) + for (const url of [lane.catalogUrl, lane.courseUrl, lane.lessonUrl]) console.log(url); + return { + mode, + manifest, + configuration, + ca, + leaf, + privateRoot, + observation, + completions: completions.state, + processes, + signal: controller.signal, + dispose, + compose, + signalRequested: () => signalRequested, + setupMilliseconds: Date.now() - setupStarted, + }; + } catch (error) { + try { + await dispose(); + } catch { + throw new DemoCommandError('Cleanup could not be confirmed; owned private files retained'); + } + throw error; + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + let runtime; + try { + if (process.argv.length > 3) throw new Error('Use make demo DEMO_MODE=local or ci'); + runtime = await startDemo(process.argv[2] ?? process.env.DEMO_MODE ?? 'local'); + if (runtime.mode === 'ci') { + const { verifyPublicDemo } = await import( + '../frontend/apps/web/scripts/verify-public-demo.mjs' + ); + await verifyPublicDemo(runtime); + } else { + await Promise.race([ + runtime.processes.failure.then((error) => { + throw error; + }), + new Promise((resolve) => { + if (runtime.signal.aborted) resolve(); + else runtime.signal.addEventListener('abort', resolve, { once: true }); + }), + ]); + } + } catch (error) { + if (runtime?.mode !== 'local' || !runtime.signalRequested()) { + // Labels are fixed locally; never echo provider, configuration or build output. + console.error( + error instanceof DemoCommandError || + (error instanceof Error && + /^(?:Use make demo|Port \d+ is occupied|Another demo owns|TLS leaf|Seed hosts|Environment disagrees|CI requires)/.test( + error.message, + )) + ? error.message + : 'demo: startup/proof failed; inspect prerequisites, TLS, configuration and selected stack', + ); + process.exitCode = 1; + } + } finally { + try { + await runtime?.dispose(); + } catch { + console.error('demo: cleanup could not be confirmed; owned private files retained'); + process.exitCode = 1; + } + } +} diff --git a/scripts/public-local.mjs b/scripts/public-local.mjs index 67a03f85..9b7167ce 100644 --- a/scripts/public-local.mjs +++ b/scripts/public-local.mjs @@ -3,6 +3,7 @@ import { randomBytes } from 'node:crypto'; import { chmodSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { publicApiEnvironment } from './public-runtime.mjs'; import { loadLocalEnvironment, @@ -44,25 +45,7 @@ try { const values = loadLocalEnvironment(root, process.env); const configuration = publicServerConfiguration(values, root); if (!values.ConnectionStrings__Default) throw new Error(); - const environment = { ...process.env }; - // Migration/worker credentials and stale hop entries never reach this API launcher. - for (const key of Object.keys(environment)) { - if ( - /^ConnectionStrings__/i.test(key) || - /^Tenancy__TrustedHop__/i.test(key) || - /^(?:LEARNSTACK_MIGRATION_PW|LEARNSTACK_OUTBOX_PW)$/i.test(key) - ) { - delete environment[key]; - } - } - environment.ConnectionStrings__Default = values.ConnectionStrings__Default; - if (values.ConnectionStrings__PlatformAdmin) { - environment.ConnectionStrings__PlatformAdmin = values.ConnectionStrings__PlatformAdmin; - } - environment.ASPNETCORE_ENVIRONMENT = 'Development'; - environment.ASPNETCORE_URLS = configuration.apiOrigin; - environment.Tenancy__TrustedHop__Networks__0 = '127.0.0.1/32'; - environment.Tenancy__TrustedHop__Secrets__0 = configuration.secret; + const environment = publicApiEnvironment(values, configuration); const child = spawn( 'dotnet', ['run', '--no-launch-profile', '--project', 'src/LearnStack.Api'], diff --git a/scripts/public-runtime.d.mts b/scripts/public-runtime.d.mts new file mode 100644 index 00000000..31cefd37 --- /dev/null +++ b/scripts/public-runtime.d.mts @@ -0,0 +1,16 @@ +export function publicApiEnvironment( + values: Record, + configuration: { apiOrigin: string; secret: string }, + base?: Partial, +): NodeJS.ProcessEnv; +export function publicWebEnvironment( + values: Record, + privateRoot: string, + base?: Partial, +): NodeJS.ProcessEnv; +export function publicDemoApiEnvironment( + values: Record, + configuration: { apiOrigin: string; secret: string }, + privateRoot: string, + base?: Partial, +): NodeJS.ProcessEnv; diff --git a/scripts/public-runtime.mjs b/scripts/public-runtime.mjs new file mode 100644 index 00000000..69fc7a53 --- /dev/null +++ b/scripts/public-runtime.mjs @@ -0,0 +1,50 @@ +import { realpathSync } from 'node:fs'; +import { join } from 'node:path'; + +/** Keep privileged setup/worker credentials and arbitrary configuration out of runtime. */ +export function publicApiEnvironment(values, configuration, base = process.env) { + const environment = {}; + for (const name of ['PATH', 'HOME', 'DOTNET_ROOT', 'TMPDIR', 'APPDATA']) + if (base[name] !== undefined) environment[name] = base[name]; + if (!values.ConnectionStrings__Default) throw new Error('Application credential is missing'); + environment.ConnectionStrings__Default = values.ConnectionStrings__Default; + if (values.ConnectionStrings__PlatformAdmin) + environment.ConnectionStrings__PlatformAdmin = values.ConnectionStrings__PlatformAdmin; + return { + ...environment, + ASPNETCORE_ENVIRONMENT: 'Development', + ASPNETCORE_URLS: configuration.apiOrigin, + Tenancy__TrustedHop__Networks__0: '127.0.0.1/32', + Tenancy__TrustedHop__Secrets__0: configuration.secret, + }; +} +/** Development configuration must not consult the workstation's user secrets. */ +export function publicDemoApiEnvironment(values, configuration, privateRoot, base = process.env) { + const profile = realpathSync(privateRoot); + return publicApiEnvironment(values, configuration, { + ...base, + HOME: profile, + APPDATA: profile, + TMPDIR: profile, + }); +} + +/** Native configuration addons need an explicit, invocation-owned cache. */ +export function publicWebEnvironment(values, privateRoot, base = process.env) { + const environment = { + PATH: base.PATH, + TMPDIR: privateRoot, + SWC_NATIVE_BINDING_CACHE: join(realpathSync(privateRoot), 'swc-native-cache'), + NODE_ENV: 'production', + NEXT_TELEMETRY_DISABLED: '1', + NODE_TLS_REJECT_UNAUTHORIZED: '1', + }; + for (const key of [ + 'LEARNSTACK_PUBLIC_API_ORIGIN', + 'LEARNSTACK_PUBLIC_HOP_SECRET', + 'LEARNSTACK_PUBLIC_TLS_CERT', + 'LEARNSTACK_PUBLIC_TLS_KEY', + ]) + environment[key] = values[key]; + return environment; +} diff --git a/scripts/seed.sh b/scripts/seed.sh index df873a7e..60b892fd 100755 --- a/scripts/seed.sh +++ b/scripts/seed.sh @@ -12,7 +12,7 @@ # `LearnStack.Tools.Seeder`, which sends the same commands a request sends — # ProvisionTenantCommand, then CreateOrganizationCommand and # MapHostToTenantCommand under each tenant's own announcement. Idempotent: a -# second run recognises its own first by the uniqueness refusal and exits 0. +# second run verifies contextual postconditions without overwriting tenant data. # # Phase 02a Packet 8 scope (wired): a third act per tenant installs the two # built-in customizations — a `card` content type drawn by `default-card`, and @@ -27,16 +27,35 @@ set -eu -o pipefail -# Hard dependencies. python3 is not obvious from the name of this script and -# is not listed in any prerequisites document, so check it here rather than -# letting `set -o pipefail` kill the run mid-gate with a raw shell error. It -# parses `docker compose config --format json` to derive the per-service -# healthcheck exemption; jq is not used because it is less commonly present -# on a fresh macOS box than python3. -# curl is the second: the realm gate below calls it directly, and a box without it -# failed there with a bare `command not found` mid-run rather than here with a name. -# dotnet is the third — the seeder itself is a dotnet run. -for _dep in docker python3 curl dotnet; do +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +# The combined lifecycle must never start one database and migrate/seed another +# through stale exported connection strings. Standalone `make migrate` retains +# its explicit connection override; `make seed` validates before any stack act. +if [[ $# -gt 1 || ($# -eq 1 && "$1" != "--validate-config") ]]; then + echo "seed: unsupported arguments." >&2 + exit 2 +fi +seed_env="${LEARNSTACK_COMPOSE_ENV_FILE:-.env}" +if [[ ! -f "$seed_env" ]]; then + echo "seed: selected environment file is missing." >&2 + exit 1 +fi +for _suffix in Migration Default; do + _name="ConnectionStrings__${_suffix}" + _selected=$(sed -n "s/^${_name}=//p" "$seed_env" \ + | tail -1 | tr -d "\r" | sed "s/^['\"]//; s/['\"]$//") + _ambient="${!_name:-}" + if [[ -z "$_selected" || (-n "$_ambient" && "$_ambient" != "$_selected") ]]; then + echo "seed: connection configuration disagrees with the selected environment." >&2 + exit 1 + fi +done +if [[ "${1:-}" == "--validate-config" ]]; then exit 0; fi + +# Readiness uses Node for bounded Compose and exact OIDC issuer checks. +for _dep in docker node dotnet; do if ! command -v "$_dep" >/dev/null 2>&1; then echo "seed: '$_dep' is required and was not found on PATH." >&2 exit 1 @@ -52,163 +71,14 @@ if ! docker compose version >/dev/null 2>&1; then exit 1 fi -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$REPO_ROOT" - -COMPOSE_FILE="infra/compose/dev.yml" - -# Compose resolves its default env file from the PROJECT directory — the -# directory of the first `-f` file, i.e. `infra/compose/` — not from the cwd. -# Without this the repo-root `.env` that `.env.example` documents is silently -# ignored and every `${VAR:-default}` falls back. `--env-file` on a missing -# path is a hard error, so the flag is conditional. -# The `[@]+` guard is not decoration: macOS ships bash 3.2, where `set -u` -# treats an EMPTY array's `"${a[@]}"` as an unbound variable and aborts. Without -# it this script dies at its first docker call whenever `.env` is absent — -# before printing the "run make dev first" message written for exactly that case. -ENV_FILE_ARGS=() -[[ -f .env ]] && ENV_FILE_ARGS=(--env-file .env) -KEYCLOAK_REALM_TENANT="learnstack" -KEYCLOAK_REALM_HUB="learnstack-hub" -KEYCLOAK_URL="http://localhost:8080" -HEALTH_TIMEOUT_SECONDS=180 # Keycloak first boot + realm import can take ~90s on a cold cache. +# One explicit selection is shared with make/demo; readiness has real command +# and HTTP deadlines, plus exact service and both-realm issuer checks. +node scripts/wait-compose.mjs cyan() { printf "\033[36m%s\033[0m\n" "$*"; } green() { printf "\033[32m%s\033[0m\n" "$*"; } red() { printf "\033[31m%s\033[0m\n" "$*" >&2; } -# ─── Step 1: compose health ────────────────────────────────────────────── -# `make seed` declares `: dev` as a prereq, so compose `up -d` has just -# returned and most services are in `starting` state. Poll until every -# service reports `healthy` or until the per-step timeout expires; only -# the "literally no services running" case is an immediate error (means -# the developer ran seed.sh directly without `make dev`). -cyan "▶ Step 1/3: wait for compose services to be healthy" - -# Distinguish "nothing running at all" from "still starting". -running=$(docker compose "${ENV_FILE_ARGS[@]+"${ENV_FILE_ARGS[@]}"}" -f "$COMPOSE_FILE" ps --status running --quiet 2>/dev/null | wc -l | tr -d ' ') -if [[ "$running" == "0" ]]; then - red "No compose services running. Run \`make dev\` first." - exit 1 -fi - -elapsed=0 -while true; do - # Capture BOTH .State and .Health so we can distinguish: - # - service running + healthcheck reports `healthy` → ok - # - service running + healthcheck still `starting` → wait - # - service running + NO healthcheck defined → skip - # - service not running (exited, dead, restarting) → flag - # - # The empty-Health case is SKIPPED, not flagged. Two images in the stack - # cannot carry a healthcheck at all — daprio/placement and daprio/daprd are - # single-binary images on an empty base, with no shell and no wget/curl/nc - # (`docker run --entrypoint sh` fails with "executable file not found"). - # Flagging them made this gate time out on every run, which is a gate nobody - # can act on. Standards 12 § Healthchecks and the readiness gate names those - # two as the only exempt images and requires the exemption to be marked at - # the service in dev.yml, so the reason is readable where it applies rather - # than duplicated into a service list here. - # - # `ps -a`, not `ps`: without it Compose omits `exited` and `created` rows - # entirely, so the not-running branch below is unreachable for exactly the - # states it exists to catch. Stopping two services and re-running printed - # "All compose services running" and exited 0. - # - # The exemption is PER SERVICE, derived from the compose file — not "any row - # whose Health is empty". A crash-looping service reports - # `State=running, Health=""` for the instant after each restart attempt, - # before Docker's health subsystem writes `starting`; a value-based skip - # passes it. Forced with `chmod 000` on valkey's data dir, that read green on - # two of three runs while `docker inspect` showed `restarting` with a - # climbing RestartCount. - exempt=$(docker compose "${ENV_FILE_ARGS[@]+"${ENV_FILE_ARGS[@]}"}" -f "$COMPOSE_FILE" \ - config --format json \ - | python3 -c 'import json,sys; print(" ".join(n for n,s in json.load(sys.stdin)["services"].items() if "healthcheck" not in s or s["healthcheck"].get("disable")))') - - not_healthy=$(docker compose "${ENV_FILE_ARGS[@]+"${ENV_FILE_ARGS[@]}"}" -f "$COMPOSE_FILE" \ - ps -a --format '{{.Service}}\t{{.State}}\t{{.Health}}' \ - | awk -F'\t' -v exempt=" $exempt " ' - $2 != "running" { print $1 " (state=" $2 ")"; next } - index(exempt, " " $1 " ") > 0 { next } - $3 != "healthy" { print $1 " (health=" ($3 == "" ? "none reported" : $3) ")"; next } - ') - # A row that is absent is not a row that is healthy: compare the count we - # actually saw against what the compose file declares, so a service that - # never got created fails the gate instead of passing by omission. - declared=$(docker compose "${ENV_FILE_ARGS[@]+"${ENV_FILE_ARGS[@]}"}" -f "$COMPOSE_FILE" config --services | wc -l | tr -d ' ') - observed=$(docker compose "${ENV_FILE_ARGS[@]+"${ENV_FILE_ARGS[@]}"}" -f "$COMPOSE_FILE" ps -a --format '{{.Service}}' | wc -l | tr -d ' ') - # Compare the SETS, not just the counts. One service missing plus one - # orphan present gives observed == declared, and a count-only test would - # break out of the loop reporting success on a stack that is missing a - # declared service. - missing=$(comm -13 \ - <(docker compose "${ENV_FILE_ARGS[@]+"${ENV_FILE_ARGS[@]}"}" -f "$COMPOSE_FILE" ps -a --format '{{.Service}}' | sort -u) \ - <(docker compose "${ENV_FILE_ARGS[@]+"${ENV_FILE_ARGS[@]}"}" -f "$COMPOSE_FILE" config --services | sort -u) \ - | xargs) - extra=$(comm -23 \ - <(docker compose "${ENV_FILE_ARGS[@]+"${ENV_FILE_ARGS[@]}"}" -f "$COMPOSE_FILE" ps -a --format '{{.Service}}' | sort -u) \ - <(docker compose "${ENV_FILE_ARGS[@]+"${ENV_FILE_ARGS[@]}"}" -f "$COMPOSE_FILE" config --services | sort -u) \ - | xargs) - - if [[ -z "$not_healthy" && -z "$missing" && -z "$extra" ]]; then - break - fi - - if [[ -n "$missing" || -n "$extra" ]]; then - if [[ -n "$not_healthy" ]]; then - not_healthy="${not_healthy}"$'\n' - fi - detail="saw $observed of $declared declared services" - if [[ -n "$missing" ]]; then - detail="$detail; never created: $missing" - fi - # An orphan makes the count exceed the declaration, so the message has to - # point at --remove-orphans rather than at a service that failed to start. - if [[ -n "$extra" ]]; then - detail="$detail; orphaned (docker compose down --remove-orphans): $extra" - fi - not_healthy="${not_healthy}($detail)" - fi - if (( elapsed >= HEALTH_TIMEOUT_SECONDS )); then - red "Services still not healthy after ${HEALTH_TIMEOUT_SECONDS}s — inspect with:" - red " docker compose ${ENV_FILE_ARGS[*]+${ENV_FILE_ARGS[*]} }-f $COMPOSE_FILE ps" - red " docker compose ${ENV_FILE_ARGS[*]+${ENV_FILE_ARGS[*]} }-f $COMPOSE_FILE logs --tail=200" - red "Still pending:" - while IFS= read -r line; do red " - $line"; done <<<"$not_healthy" - exit 1 - fi - sleep 3 - elapsed=$(( elapsed + 3 )) -done -green " ✓ All compose services running; every healthcheck green." - -# ─── Step 2: Keycloak realm verification ───────────────────────────────── -# Realm import happens during Keycloak's first boot — even after the -# `keycloak` container reports healthy, the OIDC discovery endpoint can -# take a few more seconds to surface each realm. Both realms get the same -# bounded retry loop. -cyan "▶ Step 2/3: verify Keycloak realms imported" - -wait_for_realm() { - local realm="$1" - local elapsed=0 - while ! curl -sf "$KEYCLOAK_URL/realms/$realm/.well-known/openid-configuration" >/dev/null 2>&1; do - if (( elapsed >= HEALTH_TIMEOUT_SECONDS )); then - red "Keycloak realm '$realm' did not surface within ${HEALTH_TIMEOUT_SECONDS}s." - red " Was the realm JSON imported? → infra/keycloak/realms/${realm}.json" - red " Inspect with: docker compose -f $COMPOSE_FILE logs keycloak" - return 1 - fi - sleep 3 - elapsed=$(( elapsed + 3 )) - done - green " ✓ Realm '$realm' OIDC discovery responds." -} - -wait_for_realm "$KEYCLOAK_REALM_TENANT" || exit 1 -wait_for_realm "$KEYCLOAK_REALM_HUB" || exit 1 - # ─── Step 3: application-level tenant seeding ──────────────────────────── cyan "▶ Step 3/3: seeding the two demo tenants" @@ -225,8 +95,8 @@ cyan "▶ Step 3/3: seeding the two demo tenants" # and this mirrors its reader, CR-strip and quote-strip included: .env.example # single-quotes the values. seed_cs="${ConnectionStrings__Default:-}" -if [[ -z "$seed_cs" && -f .env ]]; then - seed_cs=$(sed -n "s/^ConnectionStrings__Default=//p" .env \ +if [[ -z "$seed_cs" && -f "$seed_env" ]]; then + seed_cs=$(sed -n "s/^ConnectionStrings__Default=//p" "$seed_env" \ | tail -1 | tr -d "\r" | sed "s/^['\"]//; s/['\"]$//") fi @@ -257,7 +127,7 @@ fi # repository root no global.json applies and whichever SDK is newest answers, which is # not the one CI and `make migrate` use. if ! (cd backend && ConnectionStrings__Default="$seed_cs" \ - dotnet run --project src/LearnStack.Tools.Seeder --nologo); then + dotnet run --project src/LearnStack.Tools.Seeder); then red "seed: tenant seeding failed." red " Has the schema been applied? → make migrate" exit 1 @@ -284,7 +154,7 @@ cat <<'HOSTS' make public-web (terminal 2) Web readiness: https://localhost:3000/api/healthz - Seed alone does not supply product pages (P02d-6) or make demo (P02d-7). + make demo starts both production processes together; Ctrl+C preserves local data. These commands never edit hosts or install system trust. HOSTS diff --git a/scripts/wait-compose.d.mts b/scripts/wait-compose.d.mts new file mode 100644 index 00000000..a673e2f4 --- /dev/null +++ b/scripts/wait-compose.d.mts @@ -0,0 +1,17 @@ +export function defaultServices(model: { + services: Record; +}): string[]; +export function healthyServices( + model: { + services: Record; + }, + rows: { Service: string; State: string; Health?: string }[], + postgresOnly?: boolean, +): boolean; + +export function waitCompose( + selected: ReturnType, + processes: import('./demo-process.mjs').DemoProcesses, + signal: AbortSignal, + postgresOnly?: boolean, +): Promise; diff --git a/scripts/wait-compose.mjs b/scripts/wait-compose.mjs new file mode 100644 index 00000000..e13fc64f --- /dev/null +++ b/scripts/wait-compose.mjs @@ -0,0 +1,104 @@ +import { setTimeout as pause } from 'node:timers/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { composeSelection } from './compose.mjs'; +import { DemoProcesses } from './demo-process.mjs'; +import { boundedBody } from './demo-http.mjs'; + +export function defaultServices(model) { + const expected = Object.keys(model.services).filter( + (key) => !model.services[key].profiles?.length, + ); + if (expected.length !== 7 || !expected.includes('postgres') || !expected.includes('keycloak')) + throw new Error('Default Compose inventory must contain the accepted seven services'); + return expected; +} + +export function healthyServices(model, rows, postgresOnly = false) { + const expected = defaultServices(model); + const selected = postgresOnly ? ['postgres'] : expected; + if ( + !postgresOnly && + (rows.length !== expected.length || rows.some((row) => !expected.includes(row.Service))) + ) + return false; + return selected.every((name) => { + const matches = rows.filter((row) => row.Service === name); + return ( + matches.length === 1 && + matches[0].State === 'running' && + (!model.services[name].healthcheck || + model.services[name].healthcheck.disable || + matches[0].Health === 'healthy') + ); + }); +} + +export async function waitCompose(selected, processes, signal, postgresOnly = false) { + const run = (args) => + processes.run('docker', [...selected.args, ...args], { + cwd: selected.cwd, + env: selected.env, + capture: true, + label: 'Compose readiness', + timeout: 15000, + }); + const model = JSON.parse(await run(['config', '--format', 'json'])); + const deadline = Date.now() + 180000; + while (true) { + signal.throwIfAborted(); + const raw = await run(['ps', '-a', '--format', 'json']); + const rows = raw.trim().startsWith('[') + ? JSON.parse(raw) + : raw + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); + if (healthyServices(model, rows, postgresOnly)) break; + if (Date.now() >= deadline) + throw new Error('Compose readiness deadline; inspect the selected project'); + await pause(1000, undefined, { signal }); + } + if (postgresOnly) return; + for (const realm of ['learnstack', 'learnstack-hub']) { + const issuer = `http://localhost:8080/realms/${realm}`; + while (true) { + signal.throwIfAborted(); + try { + const response = await fetch(`${issuer}/.well-known/openid-configuration`, { + signal: AbortSignal.any([signal, AbortSignal.timeout(3000)]), + redirect: 'error', + }); + const body = await boundedBody(response, 65536); + if (response.ok && JSON.parse(body).issuer === issuer) break; + } catch { + /* Bounded first-boot polling; never expose server responses. */ + } + if (Date.now() >= deadline) throw new Error('Both Keycloak realm discoveries must be ready'); + await pause(1000, undefined, { signal }); + } + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const controller = new AbortController(); + const processes = new DemoProcesses(controller.signal); + for (const name of ['SIGINT', 'SIGTERM']) process.on(name, () => controller.abort()); + try { + const args = process.argv.slice(2); + if (args.length > 1 || (args.length === 1 && args[0] !== '--postgres')) + throw new Error('Invalid readiness arguments'); + await waitCompose( + composeSelection(process.env.LEARNSTACK_COMPOSE_MODE ?? 'dev'), + processes, + controller.signal, + args[0] === '--postgres', + ); + } catch { + console.error('Stack/realm readiness failed; inspect the selected Compose project'); + process.exitCode = 1; + } finally { + await processes.dispose(); + } +}