From 848b3339b46d78554ce27be6910e58b740f80ff2 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 9 Oct 2026 18:54:28 +0300 Subject: [PATCH 01/58] docs(roadmap): close P02d-5 after PR merge Record the verified PR head, merge tree and both successful CI runs. Mark P02d-5 complete across current status surfaces while preserving all dated delivery history and append-only Accepted ADR amendments. Name the P02d-6 decision pass and remaining renderer work, keeping the browser demo, Lighthouse and phase exit with P02d-7. Validate corpus and ADR controls, relative links and fragments, prose wrapping, diff hygiene and preserved delivery/decision history. ADR: 0053, 0054 --- CLAUDE.md | 7 +- README.md | 2 +- docs/architecture/14-frontend-architecture.md | 6 +- .../0053-trusted-public-server-rendering.md | 9 ++ .../0054-bounded-public-renderer-admission.md | 9 ++ docs/decisions/README.md | 4 +- docs/roadmap/README.md | 5 +- docs/roadmap/phase-02d-walking-skeleton.md | 88 +++++++++++++++++-- 8 files changed, 113 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f576ce2d..6373fe94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,8 +110,11 @@ records final verification. P02d-5 delivers trusted public server rendering unde Accepted ADR-0053 and ADR-0054: native HTTPS ingress, coordinated visitor/peer admission, the configured bounded server caller, live-host/locale entry and source/runtime proof controls. The [remediation and closeout record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09) -is the current owner of implementation steps, independent reviews, validation and -PR readiness. P02d-5 remains unmerged in [PR #26](https://github.com/HodeTech/LearnStack/pull/26). +owns implementation steps, independent reviews and validation. **P02d-5 is complete +and merged** through [PR #26](https://github.com/HodeTech/LearnStack/pull/26) on +2026-10-09. Its +[merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09) +records final verification. Work stays on development. P02d-6's decision pass and product pages are next; P02d-7 owns the browser demo and Lighthouse. Neither packet is delivered by P5. diff --git a/README.md b/README.md index 1a7cfe13..06ff9abc 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ this launcher. Both processes read the same private root source; a web `.env.loc projection is optional and must match. No shell evaluates the values. Ordinary no-hop API startup remains supported outside this paired renderer recipe. -**P02d-5 implements ingress, visitor budgets and public entry.** The web +**P02d-5 is complete: ingress, visitor budgets and public entry.** The web bootstraps the captured host through the API, redirects using enabled/default locales and refuses unavailable scope. The server caller enforces no-store, deadline/body limits and the closed authenticated hop. All four steps completed diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index bdebe08c..f8680f39 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -147,8 +147,10 @@ provenance, a server-only configured transport and uncached institution renderin Its four-step [delivery plan](../roadmap/phase-02d-walking-skeleton.md#p02d-5-implementation-plan) records all four completed steps and both independent review rounds per step. Frontend fences, no-skip outcomes and real-API production HTML/RSC proofs pass; -P02d-5 remains unmerged while external-review remediation continues. P02d-6 owns -public page consumers. +P02d-5 and its external-review remediation are complete and merged through +[PR #26](https://github.com/HodeTech/LearnStack/pull/26); the +[merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09) +records final verification. P02d-6 owns public page consumers. ADR-0054 retains API-owned tenant authority and membership-first locale entry. Its accepted native boundary admits GET/HEAD on every callback path, including diff --git a/docs/decisions/0053-trusted-public-server-rendering.md b/docs/decisions/0053-trusted-public-server-rendering.md index bde7e726..91aa696e 100644 --- a/docs/decisions/0053-trusted-public-server-rendering.md +++ b/docs/decisions/0053-trusted-public-server-rendering.md @@ -342,6 +342,15 @@ or local topology changes. The [remediation record](../roadmap/phase-02d-walking owns current status. Acceptance delivers no runtime correction or new test/CI result; each implementation step still owes validation and two independent reviews. +### Amendment 8 — Merge closeout (2026-10-09) + +P02d-5 and its ADR-0054 remediation are complete and merged through +[PR #26](https://github.com/HodeTech/LearnStack/pull/26). The +[merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09) +owns the final head, merge verification and CI evidence. Original acceptance +metadata and prior amendments remain historical. This delivery note changes no +decision; P02d-6 product pages and P02d-7 browser/demo/Lighthouse remain separate. + ## References - [ADR-0036](0036-tenant-resolution-trusted-inputs.md) diff --git a/docs/decisions/0054-bounded-public-renderer-admission.md b/docs/decisions/0054-bounded-public-renderer-admission.md index aecfcbac..01e70421 100644 --- a/docs/decisions/0054-bounded-public-renderer-admission.md +++ b/docs/decisions/0054-bounded-public-renderer-admission.md @@ -316,6 +316,15 @@ reviews and unmerged PR readiness live in the [five-step remediation record](../ including its [corpus/PR closeout](../roadmap/phase-02d-walking-skeleton.md#remediation-step-5--corpus-and-pr-closeout). This adds delivery navigation, not a new decision or a claim that PR #26 is merged. +### Amendment 6 — Merge closeout (2026-10-09) + +All five remediation steps are complete and merged through +[PR #26](https://github.com/HodeTech/LearnStack/pull/26). The +[merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09) +owns the final head, merge verification and CI evidence. The accepted decision, +metadata and earlier delivery notes remain unchanged. This note records delivery +only; P02d-6/7 and Phase 11 retain their named work. + ## References - [ADR-0053 — Trusted Public Server Rendering](0053-trusted-public-server-rendering.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 4311ca16..71da6660 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -67,8 +67,8 @@ an amendment is not a lifecycle status change. | 0050 | [Publication and Course Content Access](0050-publication-and-course-content-access.md) | Accepted 2026-10-02: publication/access separation, restricted backfill and anonymous denial; supersedes ADR-0048; P02d-2 policy, migration, writers and seed delivered ([Amendment 1](0050-publication-and-course-content-access.md#amendment-1--p02d-2-implementation-delivery-2026-10-02)); P02d-4 public-read enforcement delivered, grants Phase 07 | | 0051 | [Ordered Text Card Presentation](0051-ordered-text-card-presentation.md) | Accepted 2026-10-02: optional root `x-fields`, localized ordered text cards; compatible extension linked by ADR-0043 Amendment 5; P02d-2 profile parsing, resolution and seed verification delivered ([Amendment 1](0051-ordered-text-card-presentation.md#amendment-1--p02d-2-implementation-delivery-2026-10-02)); P02d-4 public projection delivered, rendering P02d-6 | | 0052 | [Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md) | Accepted 2026-10-03: host-resolved bootstrap, immutable host ceiling, mandatory marked dispatch and read-only ambient frames; ADR-0036/0040 Amendment 8; P02d-4 Steps 1–3 deliver host provenance, physical read-only frames and all institution GET/HEAD reads; both Step 3 review rounds pass; Step 4 delivers OpenAPI/SDK/CI controls; both review rounds and focused fix verification pass; six real required CI jobs and live sixth-check activation verified; P02d-4 complete and merged through PR #25 on 2026-10-08 ([Amendment 13](0052-anonymous-public-read-boundary.md#amendment-13--merge-closeout-2026-10-08)) | -| 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. [The current packet record](../roadmap/phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09) owns delivery, reviews, CI and PR readiness; 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. [The five-step remediation record](../roadmap/phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09) owns current review, validation and unmerged PR #26 closeout | +| 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)) | ## Proposed ADRs diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 792f04a1..b9affd56 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -65,7 +65,10 @@ not deferred to the showcase phase. records final verification. P02d-5's decision package and ADR-0053 are Accepted — 2026-10-08. All four steps and both fresh review rounds per step are complete. The [remediation and closeout record](phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09) - owns current delivery, review and CI evidence; P02d-5 remains unmerged in PR #26. + owns delivery and review evidence. P02d-5 is complete and merged through + [PR #26](https://github.com/HodeTech/LearnStack/pull/26) on 2026-10-09; its + [merge closeout](phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09) + records final verification. P02d-6's public-renderer decision pass is next; the P7 browser demo remains later. - [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) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index eb200223..69ab3354 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -13,7 +13,7 @@ > | P02d-2 | Writers and seed | ✅ complete and merged — 2026-10-02; [merge closeout](#p02d-2-merge-and-closeout-2026-10-02) | > | 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 | Original four steps delivered and reviewed, unmerged; external-review remediation in progress; [approval package](#p02d-5-external-review-remediation-2026-10-09) | +> | 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 | not started | > | P02d-7 | Demo, full-stack CI and exit | not started | @@ -98,6 +98,13 @@ and proof corrections are delivered. The [current remediation record](#p02d-5-ex owns the five steps, their reviews and final PR/CI readiness. Earlier delivery notes remain historical; PR #26 is not merged. +**P02d-5 merge complete — 2026-10-09.** The preceding notes record pre-merge +milestones. P02d-5 is now closed through +[PR #26](https://github.com/HodeTech/LearnStack/pull/26); its +[merge closeout](#p02d-5-merge-and-closeout-2026-10-09) records the final head, +verification and merge. Phase 02d remains in progress. P02d-6's public-renderer +decision pass is next; P6/P7 implementation has not started. + ## Goal Put a working education site in a browser — twice, on two hosts, for two tenants in @@ -232,9 +239,11 @@ owner; where a choice is still open, it names the register row that answers it. P02d-1 implements Education's roots, translations, migration and isolation. P02d-2 implements its six commands and seeded data. P02d-3 supplies internal reads; -P02d-4 supplies public endpoints and contract/SDK controls. What remains unimplemented: -every renderer component; the configured trusted server SDK transport; -any trusted-hop configuration; any Lighthouse tooling; and a `make demo` target. +P02d-4 supplies public endpoints and contract/SDK controls. P02d-5 supplies native +HTTPS ingress, coordinated anonymous admission, the configured trusted server +caller and live-host/locale entry. Public product pages, text-card components, +theme injection and accessibility remain P02d-6; the browser/demo/Lighthouse +harness and phase exit remain P02d-7. ### Explicitly not in this phase @@ -3329,13 +3338,13 @@ reads certificate/key files, resolved against the repository root. ### P02d-5 external-review remediation (2026-10-09) -**Current status — Implementation and reviews complete (2026-10-09).** The +**Current status — Complete and merged (2026-10-09).** The maintainer approved ADR-0054 and all five correction steps on development. Runtime, proof and corpus corrections complete both fresh independent review -rounds and verified fixes. Step 5 records full local validation. [PR #26](https://github.com/HodeTech/LearnStack/pull/26) -owns exact published-head CI results and final maintainer review readiness; -local results never substitute for those six required checks. Original delivery, -proposal and review notes below remain historical. PR #26 is unmerged. +rounds and verified fixes. Step 5 records full local validation. The +[merge closeout](#p02d-5-merge-and-closeout-2026-10-09) records PR #26's final +head, six required checks and merge verification. Original delivery, proposal and +review notes below remain historical; P6/P7 scope remains separate. Review reports are evidence to verify. The triage below compares their claims with `07016405`, installed Next 15.5.18/.NET 10 behavior and owned isolated reproductions. @@ -3810,6 +3819,67 @@ checking the changed contract against source, existing tests and pinned framewor behavior. This approves the revised proposal for maintainer decision review; it does not accept ADR-0054 or establish runtime remediation/CI evidence. +### P02d-5 merge and closeout (2026-10-09) + +The maintainer merged [PR #26](https://github.com/HodeTech/LearnStack/pull/26) at +2026-10-09 15:45:36 UTC. GitHub records final PR head +`f09a4edf3e00e7043f6775642e10e40024ab9393` and merge commit +`863f5c1b0051c318a0326357c2fb65dded75afca`. 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. + +- All six required checks pass on the + [final PR head](https://github.com/HodeTech/LearnStack/actions/runs/37943884658). + Downloaded TRX confirms **2,940 passed, zero failed or skipped**: 1,705 unit, + 198 architecture, four contract, 172 Docker-free integration and 861 Docker + integration. Frontend CI confirms **578 passed** (518 web, 60 SDK), zero + skips/todos, and successful typecheck, lint, production build and SDK drift. +- Native HTTPS/Next verification and all **19 failure controls** pass in CI. + Real API/PostgreSQL HTML/RSC, build containment and parent-pipe cleanup proofs + remain part of the Docker suite. These are transport/runtime proofs, not P6 + product-page or P7 browser/Lighthouse delivery. +- The final review correction isolates fixture Git/hook environments from inherited + `GIT_*` variables and reconciles README/catalogue status. Eleven tooling and + seven ADR workflow cases pass locally under both invalid inherited Git settings + and the normal environment, with typecheck, lint, format and link checks clean. + The original four steps and five remediation steps retain their recorded + independent review rounds and verified fixes. +- Read-only branch-protection verification confirms the same six required Actions + contexts and `strict: true`; no protection setting changes. + +All six required checks also pass in the +[merge-commit run](https://github.com/HodeTech/LearnStack/actions/runs/37954123158). +Independently downloaded merge-run TRX confirms the same 2,940 passing .NET cases +with zero failures/skips. Frontend logs confirm the same 578 guarded cases and +19 ingress controls. Both runs use the delivered Node 22.23.1 pin; the deferred +Lighthouse job remains owned by P02d-7. + +**P02d-5 is closed.** The trusted native ingress, fair known-visitor accounting, +bounded configured caller, live-host/locale entry, uncached rendering policy and +source/runtime controls are delivered under ADR-0053/0054. No database migration, +public API schema change or authentication/Hub flow is added by this packet. + +**Next: P02d-6 — Public renderer.** Its decision pass has not started. Re-verify +G5's unavailable-band behavior, G12's page states, G16's rendering part, G20's +subjects, G38(b,c) and G39–G43 against the delivered API and transport before +implementation. The packet owns catalog, course-detail/outline and lesson pages, +UI messages, ordered text-card rendering, safe theme injection, language/SEO +metadata, page/error/empty/locked states and accessibility checks. Request-local +bootstrap reuse and prefetch policy must also be settled before page consumers. + +P02d-7 owns `make demo`/stop behavior, the two-host browser/full-stack harness, +Lighthouse activation and phase exit. Phase 11 owns production/distributed limits, +load evidence, ingress topology, replay/key lifecycle and tracing participation. +Phase 02d remains in progress; neither remaining packet nor Course Marketplace +commerce is delivered by this closeout. Earlier dated records remain unchanged. + +**Documentation-only closeout validation.** Five local Release corpus cases and +seven ADR lifecycle/workflow controls pass, with zero failures/skips. The eight +changed Markdown files resolve 901 relative links and 393 fragments in the local +audit; prose wrapping and diff checks pass. ADR-0053/0054 changes are insertion-only; +earlier dated delivery records and P02d-1's decision/scope suffix are byte-preserved. +This closeout changes no production code or accepted decision. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved From 64eefe8d41953059866c731e3b691490f3ff6546 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 9 Oct 2026 20:47:07 +0300 Subject: [PATCH 02/58] docs(roadmap): prepare P02d-6 renderer decisions Review the delivered read and transport boundaries before the first product pages. Propose the reserved UI localization ADR and make the Next status/navigation tradeoff explicit for maintainer approval. Record four implementation steps, proof obligations and independent draft reviews; reconcile stale current-state inheritance without changing Accepted ADRs or claiming renderer delivery. ADR: 0027, 0051, 0053, 0054 --- docs/architecture/12-localization.md | 19 +- docs/decisions/0027-frontend-i18n.md | 220 ++++++++++++++ docs/decisions/README.md | 6 +- docs/roadmap/phase-02d-walking-skeleton.md | 281 +++++++++++++++++- docs/roadmap/phase-04-cms-media-pages.md | 31 +- .../phase-05-education-learning-content.md | 23 +- .../roadmap/phase-06-renderer-admin-studio.md | 25 +- .../phase-07-enrollment-learner-portal.md | 19 +- docs/standards/08-localization.md | 6 + docs/standards/README.md | 7 +- 10 files changed, 577 insertions(+), 60 deletions(-) create mode 100644 docs/decisions/0027-frontend-i18n.md diff --git a/docs/architecture/12-localization.md b/docs/architecture/12-localization.md index b0c14c49..91db26b6 100644 --- a/docs/architecture/12-localization.md +++ b/docs/architecture/12-localization.md @@ -14,9 +14,14 @@ Localisation covers: - **System messages** — Problem Details `title`/`detail` for human-facing errors. - **Slugs and routes** — locale-specific URLs (`/tr/kurslar/...`, `/en/courses/...`). -Out of scope for the initial implementation: - -- RTL languages (Arabic, Hebrew). The schema supports them but layout-level RTL is deferred until a tenant requires it. +Initial renderer scope: + +- Document direction and logical layout properties apply from the first public + renderer, including an enabled RTL locale; they are not deferred until a tenant + requests a redesign. P02d-6 G39/G43 selects the runtime mapping and its proofs. +- P02d-6 proposes initial English and Turkish platform UI catalogues in + [ADR-0027](../decisions/0027-frontend-i18n.md). Additional UI translations are + added when authored; their absence must not narrow enabled content locales. - Plural forms beyond ICU MessageFormat defaults (Turkish has different rules than English; ICU handles both). ## Locale Identifiers @@ -307,9 +312,11 @@ apps/web/locales/ ``` Keys are dotted, namespaced by feature, ICU MessageFormat for plural/select. The -frontend uses a lightweight i18n library (e.g. `next-intl` or `react-intl`); the choice -is ADR-0027, reserved and not yet made — see -[the decisions index](../decisions/README.md#open-adr-drafts). +frontend uses a lightweight i18n library (e.g. `next-intl` or `react-intl`); the +choice is now drafted in +[Proposed ADR-0027](../decisions/0027-frontend-i18n.md). No library is installed or +accepted yet. The tree above remains an unimplemented sketch until G39 accepts +one catalogue home; the proposal selects an app-local server-first foundation. > **Open in Phase 02d.** Where the catalogue lives — this tree, > [Localization Standards § Strings in Code](../standards/08-localization.md#strings-in-code) diff --git a/docs/decisions/0027-frontend-i18n.md b/docs/decisions/0027-frontend-i18n.md new file mode 100644 index 00000000..5870a363 --- /dev/null +++ b/docs/decisions/0027-frontend-i18n.md @@ -0,0 +1,220 @@ +# ADR-0027: Frontend UI Localization with next-intl + +## Status + +Proposed — maintainer approval pending; implementation has not started. + +**Date:** 2026-10-09 +**Deciders:** @cemil + +This uses the reserved frontend-i18n number. It proposes closing P02d-6 G39; +it closes no gate until accepted. Phase 04's existing reservation remains binding +until acceptance moves the first-consumer commitment to P02d-6. The +[P02d-6 decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09) +owns the page plan and approval boundary. + +## Decision Drivers + +- P02d-6 introduces platform-authored navigation, empty/error states, attribution + and accessibility labels; English literals cannot serve the bilingual tenant. +- Async Server Components need server-side messages without shipping the entire + catalogue or a provider to every browser. +- Tenant-enabled content locales and the smaller set of translated UI catalogues + have different owners. UI support must not narrow content admission. +- Three catalogue paths are currently documented. The first consumer needs one + location, key grammar and mechanically checked translation contract. +- Host/locale admission, exact content locale and trusted transport are already + accepted in ADR-0052/0053/0054. An i18n library must not replace those boundaries. +- Air-gapped rendering must use bundled messages and runtime locale data, without + translation services, remote downloads or credentials. + +## Considered Options + +1. **next-intl, server-first, without its routing middleware** (recommended). + Direct async App Router APIs fit the first consumers. Routing remains owned by + the verified public entry, with one app-local request configuration. +2. **react-intl / FormatJS**. A viable ICU and React alternative, including its + documented App Router support. Not selected because next-intl directly supplies + the async server message APIs needed here; no broader formatting requirement + currently outweighs that fit. +3. **Lingui**. A viable RSC alternative with extraction and compile-time tooling. + Not selected because the first explicit JSON catalogues do not require its + additional macro/extraction pipeline. +4. **A library-neutral lookup dictionary until Phase 04**. Not selected because + the first plural/select messages, error states and translation checks would + create a temporary parallel runtime at the first real consumer. + +These are alternatives to the proposed decision, not assertions that the other +libraries cannot support Server Components. Reconsider the selection if a pinned +production-build proof fails, its licence changes incompatibly, or actual Studio +requirements demonstrate a material gap in extraction or formatting. + +## Decision + +LearnStack uses `next-intl` for platform-authored frontend UI messages, with +app-local JSON catalogues and server-first formatting. The existing verified +public entry owns host and content-locale admission; the library neither resolves +tenants nor redirects requests. UI catalogue fallback never changes the admitted +content locale. + +This is the proposed decision. Acceptance authorizes the contracts below; +it does not claim implementation or test delivery. + +## Context + +ADR-0008 decides content storage, not the frontend UI runtime. P02d-4 delivers +exact enabled-locale content reads and locale-bearing fallback labels. P02d-5 +delivers membership-first entry through mandatory native ingress. P02d-6 is the +first consumer that needs translated platform copy, earlier than the original +Phase 04 reservation. + +The documentation currently names `packages/i18n`, `apps/web/locales` and +`apps/web/src/i18n//.json`. None exists as an implemented +catalogue. The proposed single home is: + +```text +frontend/apps/web/src/i18n/ + request.ts + messages/ + en/public.json + tr/public.json +``` + +Only the `public` namespace ships in P02d-6. A later feature adds its own namespace +under the same locale directory. Shared-package extraction follows ADR-0009's +measured duplication trigger; an empty `packages/i18n` is not created. + +### Dependency and execution boundary + +Pin `next-intl` **4.14.9** exactly in the web app and commit the lockfile. The npm +metadata checked on 2026-10-09 declares **MIT**, Next 15 and React 19 peer support. +That is compatibility evidence, not a passing build; installation, licence-file +inspection, type checking and a production build are implementation obligations. +No paid service or network runtime is introduced. + +Use the App Router plugin and request configuration without `next-intl` routing +middleware. Keep catalogue loading and server formatting behind `server-only`. +Async Server Components use the server APIs. A Client Component receives only the +translated labels its interaction needs, never the full catalogue by default. +The error boundary's retry control is such a bounded consumer; a provider is not +required merely to render static translated text. + +### Content locale, UI messages and document language + +The verified route locale remains the exact locale sent to the Education API. +Neither `Accept-Language`, a cookie nor a message-catalogue lookup selects it. +The route is admitted only through the live site's enabled-locale membership. + +For platform UI copy, select an authored catalogue by exact canonical tag, then +remove one rightmost subtag at a time, then use the platform `en` catalogue. +P02d-6 supplies complete `en` and `tr` catalogues. A tenant-enabled `ar` or `tr-TR` +route remains admissible even when only fallback UI copy exists. Do not redirect +it or substitute the content language. Missing required keys in a bundled +catalogue are a build failure, not a request-time fallback to raw keys. + +ICU formatting uses the selected catalogue's locale: English fallback copy uses +English plural rules even on a `zh` content route. The API locale and document +language remain `zh`. + +The document's `lang` is the admitted content locale and its direction follows +that locale using runtime internationalization data. If that data cannot describe +an admitted tag, the deterministic direction fallback is `ltr`; it never refuses +the content locale. Platform UI groups whose catalogue language differs carry +their actual `lang` and direction. Pattern B labels use the API's resolved locale; +Pattern A titles, summaries and bodies do not gain cross-locale fallback. + +### Message and test contract + +Platform message identifiers retain the `lockey_` prefix, grouped under the +feature namespace. For example, `en/public.json` contains: + +```json +{ + "lockey_catalog": { + "course_count": "{count, plural, one {# course} other {# courses}}" + } +} +``` + +The request configuration mounts that file under `public`. Its translator +resolves `lockey_catalog.course_count` with `{count}`. JSON nesting supplies the +dot separator; leaves use snake_case. +Values use ICU MessageFormat; dynamic content enters as plain +text parameters. Do not use rich-text translation callbacks, HTML messages, +authored URL attributes or client input as a message identifier. + +The frontend suite checks a nonempty catalogue, equal key sets across supported +UI languages and valid ICU messages, including matching argument names/types. +Typed callsite-to-catalogue checking or a checked callsite census also rejects a +used key missing from every catalogue and a misspelled callsite identifier; +catalogue equality alone cannot prove coverage. Planted per-locale missing-key, +absent-from-all, unknown-callsite, malformed-ICU and mismatched-argument controls +must fail. Override library error/fallback behavior so a formatter failure yields +a bounded translated unavailable state, never a raw identifier, namespace, +parameter value or library diagnostic in the document or logs; exercise that path. +Request tests distinguish route locale, UI fallback and resolved label locale, +including region/script tags, unsupported-but-enabled languages and RTL. The +production fixture proves document/visible-text language and that catalogues and +server configuration do not cross into an unnecessary client payload. + +## Consequences + +### Positive + +- The first public pages have translated copy and accessible labels from day one. +- One catalogue home and runtime replace the competing unimplemented sketches. +- UI translation coverage cannot accidentally become tenant content eligibility. +- Bundled messages preserve the offline deployment boundary. + +### Negative + +- The web app acquires a pinned runtime dependency and an App Router integration. +- Each platform-authored message needs both initial translations and checked ICU + arguments. Falling back to English for an untranslated UI language remains a + visible limitation, labelled with its actual language. +- Server and interactive error-state translation need separate bounded interfaces. + +### Neutral + +- Content satellites, `LocalizedText`, public DTOs, admission and API error codes + retain their accepted contracts. +- Tenant-authored translations/editors remain Phase 04; full Studio/portal UI + consumers and renderer expansion remain Phase 06. +- This chooses no date/number/currency product policy and adds no preference cookie. + +## Implementation Notes + +Acceptance moves ADR-0027 to Active ADRs with P02d-6 as its first-consumer gate. +The same decision commit reconciles Localization Standards, Localization +architecture, the frontend trees, the standards index, Phase 04 and the +`add-i18n-key` / `add-frontend-route` skills. Phase 04 consumes the installed +foundation and still owns its CMS/Studio message coverage. + +P02d-6 implements catalogues, server configuration, bounded interactive labels and +the checks above. Each implementation step follows the packet's commit and two +independent review rounds. This Proposed file changes no Accepted ADR body. + +## Architecture Tests + +The obligations above are proposed proof requirements, not registered or passing +test names. Implementation registers the actual non-skippable rule names in +Standards 21 with planted controls. Existing public source-boundary and guarded +frontend-runner checks remain mandatory; no second skip-refusal rule is created. + +## References + +- [ADR-0008 — Localization Schema](0008-localization-schema.md) +- [ADR-0009 — Single Next.js App First](0009-frontend-single-app-first.md) +- [ADR-0052 — Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md) +- [ADR-0053 — Trusted Public Server Rendering](0053-trusted-public-server-rendering.md) +- [ADR-0054 — Bounded Public Renderer Admission](0054-bounded-public-renderer-admission.md) +- [Localization architecture](../architecture/12-localization.md) +- [Localization Standards](../standards/08-localization.md) +- [Frontend Architecture Standards](../standards/07-frontend-architecture.md) +- [next-intl — App Router setup](https://next-intl.dev/docs/getting-started/app-router) +- [next-intl — Server and Client Components](https://next-intl.dev/docs/environments/server-client-components) +- [next-intl — request configuration](https://next-intl.dev/docs/usage/configuration) +- [next-intl — error files](https://next-intl.dev/docs/environments/error-files) +- [next-intl package metadata](https://www.npmjs.com/package/next-intl/v/4.14.9) +- [FormatJS — React Intl](https://formatjs.github.io/docs/react-intl/) +- [Lingui — React Server Components](https://lingui.dev/tutorials/react-rsc) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 71da6660..1f6f9d38 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -74,6 +74,7 @@ an amendment is not a lifecycle status change. | # | Title | Topic | Target phase / decision point | |---|---|---|---| +| 0027 | [Frontend UI Localization with next-intl](0027-frontend-i18n.md) | Proposed server-first UI runtime and one catalogue home; G39 remains open | Proposed first consumer P02d-6, approval before implementation; existing Phase 04 exit SLA remains until acceptance | | 0049 | [Institution Sites and an Optional Course Marketplace](0049-institution-sites-and-course-marketplace.md) | Direction endorsed 2026-10-02; architecture still Proposed, no gate accepted | P02d-2 preparation hold released; remaining contracts before proposed Phase 09a's first consumers | The maintainer endorsed the marketplace direction and accepted ADR-0050/0051 and @@ -137,7 +138,6 @@ table records the phase commitment so reviewers can flag late drafts. |---|---|---|---| | 0025 | Scoring + completion DSL sandbox engine (CEL vs restricted Lua vs custom; sandbox boundary; allowed function set) | **Phase 05** — `TenantCompletionRule` runtime evaluator lights up here; Phase 08a's assessment scoring depends on it | [ADR-0018](0018-tenant-driven-customization-model.md), [phase-05-education-learning-content.md](../roadmap/phase-05-education-learning-content.md), [phase-08a-assessment-notifications.md](../roadmap/phase-08a-assessment-notifications.md) | | 0026 | Release-tag scheme (`vYYYY.MM.DD.` vs SemVer; SaaS continuous-deploy reconciliation; Self-Hosted release cadence) | **Phase 11** — production hardening checklist owns this | [14-git-workflow.md § Tagging and Releases](../standards/14-git-workflow.md) | -| 0027 | Frontend i18n library pick (`next-intl` vs `react-intl` vs `lingui`) | **Phase 04** — the first CMS / page-builder surface ships locale-aware copy | [12-localization.md](../architecture/12-localization.md), [08-localization.md](../standards/08-localization.md) | | 0046 | Event delivery, the inbox, and the subscriber failure contract (inbox table identity and deduplication key; the dead-letter store; who claims a delivery; per-subscription attempts, terminal state and replay as one state machine) | **Phase 02b** — the consumer packet cannot start without it, and two committed designs currently contradict each other | [phase-02b-events-auth.md](../roadmap/phase-02b-events-auth.md), [15-event-and-outbox.md](../architecture/15-event-and-outbox.md), [ADR-0006](0006-events-and-outbox.md) | | 0047 | Background job runtime (storage under the closed four-role model; the single tenant-context writer; the job frame's transaction owner; the enqueue-site rule; the retry and dead-letter contract; the ADR-0035 classification; the queue grammar; package pins and licence verdict) | **Phase 02b** — Hangfire is attributed to ADR-0002 and ADR-0006, neither of which mentions it, and no compliant first boot exists today | [phase-02b-events-auth.md](../roadmap/phase-02b-events-auth.md), [20-infrastructure-stack.md § Background Jobs and Hangfire](../standards/20-infrastructure-stack.md), [09-tenant-isolation.md](../architecture/09-tenant-isolation.md) | @@ -145,6 +145,10 @@ table records the phase commitment so reviewers can flag late drafts. draft lands, take its reserved number; do not let another ADR claim it. If the decision is dropped, leave the number unused — never recycle. +ADR-0027 is now drafted in Proposed ADRs. Its proposed first consumer is P02d-6; +only maintainer acceptance moves the existing Phase 04 commitment to that packet. +The draft itself neither installs a dependency nor closes G39. + **SLA:** any draft whose target phase is currently in progress without an Accepted ADR is a blocker on the phase exit checklist for that phase. The roadmap's Phase Exit Decision lines reference this list. diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 69ab3354..f10daf1a 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -105,6 +105,13 @@ milestones. P02d-5 is now closed through verification and merge. Phase 02d remains in progress. P02d-6's public-renderer decision pass is next; P6/P7 implementation has not started. +**P02d-6 preparation — 2026-10-09.** The +[decision package](#p02d-6-decision-package-2026-10-09) and +[Proposed ADR-0027](../decisions/0027-frontend-i18n.md) are ready for maintainer +review. All P6 gate parts remain unaccepted. The package explicitly proposes the +404 navigation/status tradeoff exposed by the pinned framework experiment; +implementation waits for approval, not an assumed framework guarantee. + ## Goal Put a working education site in a browser — twice, on two hosts, for two tenants in @@ -339,7 +346,8 @@ Decisions made or referenced in this phase: (lesson-body validation on the command path; no compiled-validator cache) - [ADR-0045 Entitlement and Feature-Flag Socket](../decisions/0045-entitlement-and-feature-flag-socket.md) (`tenancy.white_label_branding`, whose meaning G16 asks) -- The reserved, undrafted **ADR-0027** — the frontend i18n library, target Phase 04 in +- **Proposed [ADR-0027](../decisions/0027-frontend-i18n.md)** — the frontend i18n + library; proposed first consumer P02d-6, existing Phase 04 exit SLA in [decisions/README § Open ADR Drafts](../decisions/README.md#open-adr-drafts); G39 asks whether this phase's renderer Accepts it @@ -3880,6 +3888,264 @@ audit; prose wrapping and diff checks pass. ADR-0053/0054 changes are insertion- earlier dated delivery records and P02d-1's decision/scope suffix are byte-preserved. This closeout changes no production code or accepted decision. +### P02d-6 decision package (2026-10-09) + +**Proposed — approval pending, verified against `848b333`.** P02d-1–5 are +complete and merged. This package proposes the remaining renderer decisions; +it accepts no gate, installs no dependency and claims no product-page delivery. +The new [ADR-0027](../decisions/0027-frontend-i18n.md) requires maintainer approval +before implementation. Earlier delivery records remain historical. + +#### Reviewed inputs and verified premises + +The documentation pass covers the platform vision and MVP boundary; Principles; +the glossary and decisions index; ADR-0008/0009/0018/0035/0036/0040/0042/0043/ +0050/0051/0052/0053/0054; Localization, Frontend Architecture and Tenant +Customization Model; Standards 03/06/07/08/11/13/15/16/21; Phase 02d and its +Phase 04/05/06/07 consumers. The `implement-task`, `add-frontend-route`, +`add-i18n-key`, `write-adr` and commit workflows are included. + +Code verification covers the public SDK DTOs/results, native ingress and verified +entry, configured caller, middleware, root/public layouts, CSS tokens, renderer +registry, frontend source fences, guarded runner and real API/Next fixture. +In particular: + +- Four public GET operations and their eligible alternates already exist. Pages + need no new endpoint, write method, entity, migration, tenant id or Hub call. +- The only implemented lesson presentation is ADR-0051's ordered plain-string + `default-card`. The larger registry does not implement additional renderers. +- Public theme DTO validation currently checks string shape, not color grammar. + Safe HTML injection must validate the complete four-token value again. +- Middleware consumes and discards live site bootstrap. `React.cache` can share + RSC work within a render, not between middleware and RSC execution. +- The production fixture currently injects a disposable transport-probe page. + It proves P5 transport, not the actual P6 product pages. +- Root HTML still has fixed English/platform metadata. No UI catalogue/runtime + is installed. Existing seed inventory does not fill a normal 20-item page. + +The pass corrects current mutable carriers that prematurely claim product pages, +leave the delivered G12 interface open, or require an outbound URL sink in this +plain-text packet. Accepted ADR bodies and shipped delivery records are unchanged. + +#### Proposed gate answers + +| Gate / part | Proposed answer | +|---|---| +| G5, G12 page states | Preserve public DTO eligibility and exact pins. Omit a null level; localize an unavailable level. Unavailable content has a bounded placeholder, never raw JSON or another revision. Distinguish empty catalog, empty public outline, restricted outline, empty ready content and unavailable content. | +| G16(g) | Render the complete safe tenant palette independently of plan. The effective `showPlatformAttribution` value alone controls platform attribution; no plan inference in the renderer. | +| G20 subjects | Census production frontend TS/JS and JSON, including renderer/message values, against the exact decoded seed identity literals from G20(a). Preserve the accepted platform-built-in and development/test exemptions, without a blanket catalogue exemption. P7 implements the existing registered rule across backend and frontend. | +| G38(b,c) | Vitest covers synchronous views and pure mappings; real production HTML/RSC tests cover async pages. Extend the existing required Docker integration fixture to unchanged product pages while preserving the isolated P5 transport probe. P7 still owns shared demo/browser/Lighthouse activation. | +| G39 | Accept ADR-0027 at first use: exact next-intl 4.14.9, server-first configuration, app-local `src/i18n/messages/{en,tr}/public.json`, one checked ICU catalogue contract. No i18n routing middleware or preference cookie. | +| G40 | Three fixed content routes, minimal tenant chrome, both opaque pagination surfaces, exact-locale metadata and explicit page states. The status/navigation choice below requires separate explicit approval with this package. | +| G41 | Render only the API's ordered plain-string fields through app-local synchronous components in `apps/web/src/components/public/`. Unsupported renderer or unresolved presentation renders the bounded fallback. No HTML, Markdown, linkification, authored URL sink or new primitive. | +| G42 | Emit a server-generated style element containing only four fixed `--ls-*` color properties after atomic `#rrggbb` validation. Null or malformed theme retains the entire existing CSS default palette. No per-token merge, style attribute, URL, font or organization override. | +| G43 | Enforce applicable jsx-a11y rules at error severity with actual-config planted controls; assert visible semantics/languages/titles. Catalog → course → lesson is a critical flow requiring keyboard, focus, reflow, contrast and a real screen-reader smoke record. Status promotion occurs with enforcement, not this proposal. | + +UI catalogue fallback is not content fallback. An enabled language without an +authored UI catalogue remains admissible; UI groups and API-resolved labels carry +their actual language. Document language/direction follow the admitted route as +specified by ADR-0027. No tenant identity selects markup, translations or fields. + +#### G40 status and navigation decision + +Pinned Next **15.5.18** was exercised in an owned disposable production fixture, +without modifying this repository. These are planning experiments, not passing +product tests: + +| Strategy | Observed initial document | +|---|---| +| `notFound()` below a loading boundary | Streamed HTTP 200 with not-found digest | +| Await resource outside child loading, then `notFound()` | HTTP 404, but generic error HTML without visible localized fallback or root `lang`/`dir`; fallback text exists only in Flight | +| Remove loading entirely | The same generic HTTP 404 error document | +| Local Suspense / access-fallback boundary | Visible fallback with HTTP 200, not a hard 404 | +| Await lookup, redirect to an explicitly admitted fixed error page; middleware supplies its 404 status | Local HTTP 307 followed by visible localized HTML 404 with root language/direction and no-store; HEAD is bodyless | + +The recommended choice is the last strategy, **subject to explicit approval of +the URL/status tradeoff**. Root/route admission awaits the request-cached resource +before emitting a document shell. A missing/hidden resource redirects to the +same host's fixed `/{locale}/status/not-found` page, which middleware admits only +after live host/locale validation and marks HTTP 404. That page is ordinary +server rendering, not a thrown `notFound()`. It echoes no original slug, query, +cursor or failure detail. Its title/robots are localized and noindex; it has a +catalog recovery link and the same safe theme/chrome. It never queries Education. + +The original request is **307, followed by 404**, and the browser URL changes. +This is not a claim that the original document returns a direct 404. Acceptance +must update G40's completion criterion to name that exact chain and add this +error namespace to the entry matrix and route skill. Middleware's existing masked +unknown-host/provenance refusals remain direct, neutral responses. No new signed +data carrier, shared bootstrap cache or outbound header is introduced. + +Returning a localized HTTP 200 error as though it were a 404 is rejected. A +middleware content preflight followed by another content read is also rejected: +it costs a fourth call on every success and still races the later read. A new +authenticated DTO-transfer protocol is not justified for this packet. Preserving +the original URL with a branded direct 404 would require a separately designed +response-ownership change; it is not silently promised by this proposal. + +Metadata, layout and page use the same request-local resource loader. Layouts do +not serialize child execution, so each page honors the loader result itself. +Required `loading.tsx`/`error.tsx` files remain, but no loading boundary may flush +the document before redirect admission. Loading UI is not promised during that +initial pre-admission wait. Unexpected framework errors retain the framework's +pre-stream 500 / post-stream 200 behavior; `error.tsx` cannot set arbitrary status. + +Known content-call failures use controlled, translated, noindex page states: +invalid cursor → explanation and a relative reset link; 429 → retry-later state; +transport/invalid response/unavailable API → unavailable state. These are ordinary +HTTP 200 representations, not claimed HTTP 400/429/503 responses. Bootstrap +refusals retain middleware's real 404/429/503 and bounded Retry-After. A fresh +bootstrap failure on the error-route request remains that neutral refusal rather +than inventing a tenant theme or configuration. No raw Problem Details is shown. + +#### Routes, pagination, metadata and chrome + +- Keep `/{locale}/courses`, `/{locale}/courses/{slug}` and + `/{locale}/courses/{slug}/lessons/{lessonSlug}`. Fixed section names remain until + Phase 06; the platform placeholder leaves the public product path. Existing + exact `/studio` and `/portal` scaffolds remain without invented authentication. +- Use ordinary same-host relative anchors for product navigation, including + pagination: no automatic prefetch and no reliance on retained client Router + Cache. A new document request re-reads current API state. +- Parse only owned pagination parameters from the verified raw signed target; + observed Next `searchParams` is not their authority. Refuse duplicate, empty, + malformed or oversized owned values. Cursors stay opaque and are never decoded + by the renderer. Catalog uses `cursor`/`limit`; outline uses + `lessonCursor`/`lessonLimit`, with the API's bounds and a default page size 20. +- Show restart/next links as applicable; do not fabricate a previous cursor. + A test-owned inventory above the page bound proves both pagination surfaces; + do not reduce the product page size or rewrite the historical seed inventory. +- A restricted course shows marketing metadata and a translated access notice, + with no lesson count, lesson link, purchase/login/enrollment control or body. + An empty public outline is distinct. Missing optional text is omitted. +- Minimal chrome contains tenant display name, catalog navigation, skip link and + conditional platform attribution. Full menus, logo/media, authored errors, + preview, organization theme overrides and Studio editing remain Phase 06. +- Canonical and Open Graph URLs use only the verified live host, existing HTTPS + listener convention and validated local segments. Course/lesson hreflang uses + the API's actual eligible alternate slugs, never the current slug substituted + into another locale. Catalog uses enabled locales. Paginated views are noindex + with the cursor-free first-page canonical; error states are noindex with no + misleading resource alternates. Metadata shares the same data read as the page. + +#### Rendering, theme and accessibility detail + +Course lists use semantic lists; ordered outlines use ordered lists; field cards +use definition lists with authored order and language-bearing labels. Each page +state has one main, one descriptive h1, sequential headings, a skip target and +visible keyboard focus. Long unbroken strings wrap and logical CSS supports RTL. + +Strings resembling scripts, HTML, `javascript:`, `data:` or HTTP URLs remain +escaped, inert text. Absent optional fields render nothing. Unknown composites +and unavailable content emit only bounded placeholders and at most one diagnostic +per request containing a count/state, not field values, keys or private context. +The original G41 candidate's integer/boolean/enum/taxonomy expansion is not +authorized by ADR-0051; those richer primitives remain Phase 04/05 and Phase 06. + +The theme emitter admits the complete four-color grammar before producing fixed +CSS. Foreground/muted text retains its 4.5:1 background requirement. Primary has +only a 3:1 background guarantee, so normal text links use foreground plus an +underline; do not assume white text on primary is accessible. Primary may supply +non-text focus/border accents where its contrast is sufficient. CSP delivery +remains Phase 11; output must be safe without it. + +Manual evidence names commit, environment, both hosts/locales, keyboard path, +focus, 320 CSS px reflow/zoom, long strings, contrast and the screen reader used. +DOM assertions do not replace that assistive-technology smoke. If it cannot be +performed, the record stays pending and packet completion is not claimed. +Full Playwright/axe remains Phase 06; Lighthouse activation remains P02d-7. + +#### Request accounting and validation + +Normal content document: middleware bootstrap + request-local RSC bootstrap + +one shared content operation = **three API calls**. A missing document followed +to the proposed error page adds two bootstrap calls = **five total**. Repeated +metadata/layout/page consumers must not add calls; the next incoming document +must re-read. No retry or shared validated-DTO cache is added. + +The existing 60-call visitor and 600-call peer windows therefore give theoretical +ceilings of 20/200 successful three-call documents before other calls, not page +throughput promises. No limiter number, deadline, byte cap or partition changes. +Phase 11 owns production/distributed capacity and measured optimization triggers. + +The actual-page fixture must retain learnstack_app, RLS/read-only transactions, +native HTTPS ingress, closed hop headers and existing containment/failure controls. +Keep the synthetic P5 probe in a separate mode so its catch-all cannot shadow +product routes. Assert visible HTML/DOM separately from Flight strings: status, +language/direction, headings, theme, attribution, both hosts/locales, cross-tenant +absence, overlapping concurrent requests across both hosts/locales, freshness in +one process, controlled failures, pagination, exact schema swaps and inert values. +Verify the 307→404 chain and no direct internal bypass. +These HTTP/RSC proofs are not a browser end-to-end claim. + +#### Implementation steps after approval + +0. **Decision commit.** Accept approved ADR/gate parts; reconcile every normative + carrier and the changed G40 status criterion before writing implementation. +1. **Localization and document foundation.** Install/pin i18n, author complete + catalogues and ICU controls; verified request-local loaders, language/direction, + safe theme/chrome and controlled state components. Establish status-route + admission with production regression, preserving all P5 controls. +2. **Catalog and course.** Implement actual list/detail/outline pages, both + pagination surfaces, restricted/empty states and metadata from shared loaders. +3. **Lesson presentation.** Implement ordered default-card fields, unavailable + fallbacks, language attribution, schema-swap and inert-value proofs; complete + semantic/focus/lint controls across all page states. +4. **Product proof and closeout.** Complete the real product fixture, no-leak and + request-count/freshness controls, manual accessibility evidence and all carrier + updates. Validate, then open the implementation PR for maintainer review. + +Every numbered implementation step is committed, then reviewed by fresh agents +in two independent rounds. Verify each finding against current code, commit +confirmed fixes, rerun affected checks and only then advance. Reviewer model and +effort follow criticality; security/runtime and proof integrity have separate +review lines. No branch/worktree change; all commits remain on `development`. + +Validation comprises the guarded frontend suite, typecheck, lint, production +build/native verification, relevant Release backend architecture/contract/unit and +real Docker integration suites, formatting, diff checks and relative-link/anchor +audits. Record actual passing counts only after execution. Browser/assistive +technology observations are reported separately from automated test evidence. + +#### Approval boundary and acceptance carriers + +The maintainer is asked to approve these together: + +1. ADR-0027's library, pin, catalogue home and UI/content-locale separation. +2. G40's explicit local 307→404 error-route behavior and URL change, three-call + normal path/five-call missing path, plain-anchor navigation and controlled + HTTP 200 noindex API-failure states. The original direct-404 wording is not + claimed as implemented by this plan. +3. The remaining gate answers, four implementation steps and mandatory manual + accessibility proof above. + +On approval, update the decisions index; Standards 03/06/07/08/16/21 and index; +Localization/Frontend/Customization architecture; Phase 04/05/06/07 inheritance; +glossary terms where introduced; the two frontend skills; and this register, +renderer scope/completion criteria and decision record. Only implemented tests +may be labelled Implemented. Accepted ADR-0053/0054 remain unchanged: this selects +their delegated page composition, without another authority carrier or budget. +If review finds an Accepted decision must change, draft its replacement and seek +approval before dependent code. P02d-7 and Phase 11 ownership remains unchanged. + +#### Preparation review and validation + +Two independent draft-review rounds complete: GPT-6-astra high and +GPT-6.1-sol high review runtime/corpus first; fresh GPT-6-sol high and +GPT-6.1-sol high review the corrected package second. The separate i18n review +also checks the pinned package evidence. Confirmed fixes add a complete message +key example, ICU formatting in the selected UI language, callsite-to-catalogue +coverage with planted controls, and overlapping host/locale request proofs. +Both final round-two reviews report no actionable issue; maintainer acceptance +is still required. + +Documentation-only validation: five Release corpus cases and seven ADR +lifecycle/workflow cases pass, zero failures/skips. Local relative-file/fragment +audits, added-prose wrapping and `git diff --check` pass across the ten changed +Markdown files. ADR-0027 parses as Proposed. No Accepted ADR file or production +code is changed; no frontend/product, browser or accessibility test is claimed +passing by this preparation record. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved @@ -4376,10 +4642,11 @@ language and direction follow the route's locale per layout's fixed `lang="en"` and platform `` do not survive this phase. What evidence fails a build is **G43**; the language of fallback-resolved fields is **G24**. -Open here, each a register row: the UI strings and the i18n library (**G39**); route -files, page states, catalog pagination and chrome (**G40**); the rendering mode -(**G37**); whether the path sets cookies or loads cross-origin subresources (**G21**); -and the test set (**G38**). +Open here, each a register row: UI strings and the i18n library (**G39**); route +files, page states, pagination and chrome (**G40**); bounded presentation (**G41**), +theme injection (**G42**), accessibility (**G43**) and renderer tests (**G38 b,c**). +P02d-5 already delivers **G37**'s dynamic/no-store rendering and **G21**'s +cookie-free, no-cross-origin-subresource contract; pages must preserve them. ### Host-based tenant resolution, end to end @@ -4619,7 +4886,9 @@ catalogued legs are not implemented (**G28**), and the tenant-branching check is rendering from customization data, with link navigation from catalog to lesson. - 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** chooses; the render-time outbound URL check. + home **G41** chooses; inert-text and active-sink refusal proofs. This packet + introduces no authored URL sink; media/URL handling remains Phase 04/05 and the + broader renderer's Phase 06 contract. - Theming token injection in the `(public)` layout per **G42**, emitting only the `--ls-*` vocabulary. - The UI message layer and catalogue at the location **G39** records. diff --git a/docs/roadmap/phase-04-cms-media-pages.md b/docs/roadmap/phase-04-cms-media-pages.md index a0b73c93..9a4f89c7 100644 --- a/docs/roadmap/phase-04-cms-media-pages.md +++ b/docs/roadmap/phase-04-cms-media-pages.md @@ -6,13 +6,12 @@ Turn LearnStack into an education-aware headless CMS and page-composition platfo merely a course-management system. This phase enables landing pages, blog content, catalog pages, campaign pages, and tenant-defined page blocks. -[Phase 02d](phase-02d-walking-skeleton.md) already renders two tenants' catalog and -lesson pages from customization data. It does so with hard-coded route segments, and -draws lesson bodies through their content type's composite over the subset of primitives -that phase implements. This phase replaces that with an authored, versioned, localized -content system that a tenant admin drives from Admin Studio — and it is the phase where -four long-standing modelling conflicts in the corpus get an answer, because every one of -them becomes load-bearing the moment content is authored rather than seeded. +[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 remain P02d-6; the two-host browser/demo proof remains P02d-7. 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. Decisions consumed in this phase: @@ -31,9 +30,10 @@ Decisions consumed in this phase: needs a code branch per tenant. Decision required **before this phase exits**: -[ADR-0027 — frontend i18n library pick](../decisions/README.md) (`next-intl` vs -`react-intl` vs `lingui`), reserved against this phase in the decisions README's open -drafts table. +[ADR-0027 — frontend UI localization](../decisions/0027-frontend-i18n.md) is Proposed +for P02d-6's first UI strings. If accepted there, this phase consumes its installed +foundation and adds CMS/Studio message coverage. Until acceptance, this phase's +existing decision-and-wiring exit requirement remains in force. ## Scope @@ -66,12 +66,11 @@ The Content module keeps the half that is genuinely its own: through Mechanism #1 — an application contract in `Customization.Application.Contracts` that resolves a `(tenant_id, key, schema_version)` tuple to its JSON Schema and reports whether the revision is still - publishable. [Phase 02d](phase-02d-walking-skeleton.md)'s lesson writer calls this - contract first, so whether it resolves an exact revision or binds the Active one for - a key, whether it is an interface or a query, and which revisions a writer may bind - are G12 in - [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register). - P02d-2's decision pass closes that part of G12 and edits this bullet with its answer. + publishable. P02d-2's lesson writer already consumes the uncached exact-revision + application interface: new bindings require Active; existing-pin writes permit + Active or Deprecated. P02d-3 supplies the separate generation-keyed display + projection. These accepted G12 contracts are owned by the + [Customization module](../modules/customization/README.md). - Referential integrity is therefore enforced in the application, and the failure mode is explicit: deleting a schema revision requires a zero-instance count across the tenant, per [ADR-0013](../decisions/0013-page-block-schema-versioning.md). diff --git a/docs/roadmap/phase-05-education-learning-content.md b/docs/roadmap/phase-05-education-learning-content.md index 770953b9..df05cd1a 100644 --- a/docs/roadmap/phase-05-education-learning-content.md +++ b/docs/roadmap/phase-05-education-learning-content.md @@ -12,10 +12,11 @@ their per-tenant shape. Every model here is **domain-agnostic**. Domain-specific This phase **deepens** the [Phase 02d](phase-02d-walking-skeleton.md) walking skeleton. Its accepted design supplies independent `Course` and `Lesson` roots, each with its own publication state and translation satellites; an ordered lesson -list; and a lesson body drawn through its content type's composite over the primitive -subset that phase implements (G18 in +list; and ADR-0051's bounded ordered plain-text lesson presentation (G18 in [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register)). -Phase 05 adds the structure a real catalog needs: programs, versioning, modules, lesson +P02d-4 delivers the public projection and P02d-5 its trusted transport; actual +product pages remain P02d-6. Phase 05 adds the structure a real catalog needs: +programs, versioning, modules, lesson items, and tenant-defined item types. This phase also carries the decision the customization model has been running without. @@ -36,9 +37,10 @@ Decisions consumed: are platform features. - [ADR-0013 Page Block Schema Versioning](../decisions/0013-page-block-schema-versioning.md) — the same `(key, schemaVersion)` semantics apply to lesson item types. -- [ADR-0048 Publication Before Course Versioning](../decisions/0048-walking-skeleton-publication.md) - — independent publication states, no course-version snapshot, and preservation - obligations for this phase's migration. +- [ADR-0050 Publication and Course Content Access](../decisions/0050-publication-and-course-content-access.md) + — independent publication states and inherited access policy, no course-version + snapshot, and preservation obligations for this phase's migration. ADR-0048 + remains historical context, superseded by ADR-0050. - [ADR-0021 Feature-Based Entitlement](../decisions/0021-feature-based-entitlement.md) — the runtime limits below are `LimitKeys` entries, so a plan raises them up to a platform ceiling rather than the ceiling being hard-coded per tenant. @@ -56,6 +58,11 @@ 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 +backfill and writers; P02d-4 delivered eligible public reads. Preserve those +contracts through this phase's version/module migration. Renderer and browser +proof remain P02d-6/7, not a prerequisite silently claimed as shipped here. + 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 @@ -68,9 +75,9 @@ Each change follows [Database Standards § Migrations](../standards/05-database. | Phase 02d baseline | Phase 05 adds | |---|---| -| `Course` — independent root with `draft` / `published` under [ADR-0048](../decisions/0048-walking-skeleton-publication.md); title, summary and routable slug in `course_translations`; an optional exact taxonomy/band revision pin | Program membership, versioning, categories, tags, SEO and catalog visibility; this phase decides how those compose with existing publication, which currently does not snapshot the lesson set | +| `Course` — independent root with `draft` / `published` and separate content-access policy under [ADR-0050](../decisions/0050-publication-and-course-content-access.md); title, summary and routable slug in `course_translations`; an optional exact taxonomy/band revision pin | Program membership, versioning, categories, tags, SEO and catalog visibility; this phase decides how those compose with existing publication, which currently does not snapshot the lesson set | | `Lesson` — independent root referencing a course, with its own `draft` / `published` state; ordered by `(sort, id)`; its translated body pins one exact content-type revision ([Education spec](../modules/education/README.md)) | Module membership, lesson items, required / optional, duration, prerequisites; preserve identity, ordering and translated content through the migration | -| The anonymous public reads Phase 02d ships — their paths, shapes and count are G25 and G26 in [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register) | The authenticated authoring surface and the versioned read path | +| Four anonymous public reads delivered P02d-4 — G25/G26 and [ADR-0052](../decisions/0052-anonymous-public-read-boundary.md); P02d-5 trusted server transport | The authenticated authoring surface and the versioned read path | | The customization definition read path — content types and taxonomies through the generation-keyed cache ([32-tenant-customization-model.md § 8.2](../architecture/32-tenant-customization-model.md#82-cache-strategy)) | The `TenantLessonItemType` read, the batched reference walk and the measured cost model | | `[TenantOwned]` markers, EF query filters, RLS policies | The same layers on every new table, with no exception | diff --git a/docs/roadmap/phase-06-renderer-admin-studio.md b/docs/roadmap/phase-06-renderer-admin-studio.md index 1ef06f06..1cd07255 100644 --- a/docs/roadmap/phase-06-renderer-admin-studio.md +++ b/docs/roadmap/phase-06-renderer-admin-studio.md @@ -6,26 +6,29 @@ Turn CMS and education catalog data into real product experiences: a complete pu tenant site renderer, a usable Admin Studio, and the portal shell that every learner-facing and instructor-facing screen after this phase is built into. -[Phase 02d](phase-02d-walking-skeleton.md) already put a site in a browser — a catalog -page and a lesson page, on two hosts, for two tenants. That skeleton proved the request -path. It is not a website: it has no navigation, no full SEO treatment, no -tenant-authored error pages, no block registry beyond the primitive subset its lesson -composites draw, and no editing surface. -Phase 06 **deepens** it into something a tenant can publish. +[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; P02d-7 owns the browser/demo harness. Neither remaining packet is +delivered yet. 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, and a non-developer tenant admin can maintain it. ## Scope -### What Phase 02d already shipped +<a id="what-phase-02d-already-shipped"></a> -| Already exists | Phase 06 adds | +### What Phase 02d supplies + +Rows identify their delivery packet; planned P02d-6/7 work is not current behavior. + +| Phase 02d foundation | Phase 06 adds | |---|---| | Host-based tenant + organization resolution, end to end | Per-organization branding override on the resolved context | -| The anonymous `(public)` pages listed in [Phase 02d § Public renderer](phase-02d-walking-skeleton.md#public-renderer), Server Components over the typed SDK | Navigation, SEO metadata beyond what G40 in [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register) settles, tenant-authored error pages, redirect handling, full page composition | -| Lesson bodies drawn through their content type's composite, over the primitive subset [Phase 02d](phase-02d-walking-skeleton.md) implements | The complete two-tier block registry with safe-render placeholders | -| Branding tokens read from `TenantSetting` | The branding configuration surface that writes them | +| Three anonymous `(public)` Server Component pages over the typed SDK, planned P02d-6 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 renderer planned P02d-6 | The complete two-tier block registry with safe-render placeholders | +| Four-color public theme projection delivered P02d-4; safe document injection planned P02d-6 | 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 | ### Public site renderer diff --git a/docs/roadmap/phase-07-enrollment-learner-portal.md b/docs/roadmap/phase-07-enrollment-learner-portal.md index b524ca93..c5e36003 100644 --- a/docs/roadmap/phase-07-enrollment-learner-portal.md +++ b/docs/roadmap/phase-07-enrollment-learner-portal.md @@ -28,15 +28,16 @@ payload/`304` and start protected responses private/no-store before any cache de Worth stating plainly, because a walking skeleton is easy to over-read: -[Phase 02d](phase-02d-walking-skeleton.md) is **anonymous and read-only**. It renders a -catalog page and a lesson page for two tenants through anonymous `GET` endpoints. -There is no user, no enrollment, no course access, no progress row, and no learner-side -write path anywhere behind it. The lesson page it produced has no "mark complete" -control and nothing to store if it had one. - -Manual enrollment, invitation enrollment, lesson progress, resume-learning, and the -learner portal shell are therefore **new construction in this phase** — not shells being -lit up. What Phase 07 inherits from 02d is narrow and read-side only: `Course` and +[Phase 02d](phase-02d-walking-skeleton.md) is **anonymous and read-only**. Its API +and trusted server transport are delivered through P02d-5; three product pages +(catalog, course-detail/outline and lesson) remain P02d-6, with browser/demo proof +in P02d-7. There is no learner, enrollment, access grant, progress row or +learner-side write path. The anonymous lesson has no "mark complete" control. + +Manual enrollment, invitation enrollment, lesson progress and resume-learning are +new construction in this phase. The portal shell belongs to +[Phase 06](phase-06-renderer-admin-studio.md); this phase adds its learner screens. +What Phase 07 inherits from 02d is narrow and read-side only: `Course` and `Lesson` exist (deepened into `CourseVersion`, modules and lesson items by Phase 05), host-based tenant and organization resolution works end to end, and the public content path exists to contrast the entitled one against. diff --git a/docs/standards/08-localization.md b/docs/standards/08-localization.md index 1920d841..ccb66b51 100644 --- a/docs/standards/08-localization.md +++ b/docs/standards/08-localization.md @@ -41,6 +41,12 @@ uses exact enabled query locale; display-only Pattern B values carry authored `{value,locale}`. Neither fallback nor headers authorize a content locale. Internal fallback remains the delivered P02d-3 contract; language attributes are P02d-6. +**Proposed P02d-6 UI foundation — 2026-10-09.** +[ADR-0027](../decisions/0027-frontend-i18n.md) proposes next-intl and one app-local +catalogue home, with UI-language fallback separate from content admission. It is +not yet accepted or implemented; G39 and the existing Phase 04 exit commitment +remain open until approval reconciles their carriers. + ## URL Strategy Public URLs: diff --git a/docs/standards/README.md b/docs/standards/README.md index 088df986..39042ede 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -68,10 +68,11 @@ bookkeeping pass. ### Honest status today The table below describes the implementation as of 2026-10-09: -[Phase 02a](../roadmap/phase-02a-kernel-tenancy.md) and P02d-1–4 are complete and +[Phase 02a](../roadmap/phase-02a-kernel-tenancy.md) and P02d-1–5 are complete and merged. P02d-5's ingress, configured server caller, dynamic entry and frontend -guards are implemented in the open PR; its external-review remediation is recorded -in the [current packet](../roadmap/phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09). +guards are delivered through PR #26; the +[merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09) +owns its final review and CI evidence. P6 product pages and P7's demo/browser/Lighthouse work remain pending. Each packet's delivery record owns its review and validation evidence. From 2d821c4df6e29bca3bdebebd00419ccd19cc537d Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 9 Oct 2026 23:16:25 +0300 Subject: [PATCH 03/58] docs(decisions): reconcile UI localization review findings Separate dotted UI copy from backend message keys and correct the SDK resource-map claim before the P6 localization decision is accepted. Specify trusted request configuration, fallback and RTL proofs, and the exact acceptance carriers while retaining Proposed status. ADR: 0027, 0044, 0053, 0054 --- .claude/skills/add-i18n-key/SKILL.md | 29 ++++--- docs/decisions/0027-frontend-i18n.md | 99 +++++++++++++++++----- docs/glossary.md | 2 +- docs/roadmap/phase-02d-walking-skeleton.md | 40 +++++++-- docs/standards/09-error-handling.md | 33 +++++--- 5 files changed, 153 insertions(+), 50 deletions(-) diff --git a/.claude/skills/add-i18n-key/SKILL.md b/.claude/skills/add-i18n-key/SKILL.md index 19289723..c974be13 100644 --- a/.claude/skills/add-i18n-key/SKILL.md +++ b/.claude/skills/add-i18n-key/SKILL.md @@ -22,11 +22,11 @@ Manage user-facing translations in `apps/web` consistently per > **Open in Phase 02d.** None of the machinery below exists today: no > `frontend/apps/web/src/i18n/` tree, no i18n library, no `pnpm lint:i18n` task, no > `no-literal-strings` rule, no `_deprecated.json`, and no screenshot or `axe-core` -> test. Where the one UI string catalogue lives — this skill, Localization Standards -> and Localization architecture name three different paths — and whether ADR-0027 -> picks the library in Phase 02d are G39 in +> test. The competing catalogue sketches and library choice are G39 in > [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); -> the pass that closes it edits this skill. +> [Proposed ADR-0027](../../../docs/decisions/0027-frontend-i18n.md) selects their +> replacement, pending approval. The pass that accepts it edits this skill's +> catalogue paths and implementation workflow. ## When to use @@ -37,7 +37,9 @@ Manage user-facing translations in `apps/web` consistently per ## When not to use - Backend `LocalizedMessage` keys returned by the API. Those have their own - `lockey_*` namespace (the SDK maps API codes to client-side resources). + `lockey_*` namespace. The SDK normalizes API codes to `AppError`; it has no + translation resource map. A UI feature owns any supported backend-message + resources separately from general UI copy. - Notification template content. Lives in `TenantTemplateLibrary` rows (data, not code). - Debug-only strings or developer error messages. @@ -96,8 +98,11 @@ Rules: - Lowercase, dotted, snake_case for multi-word segments. - Feature-namespaced — `auth.signin.errors.invalid_credentials`, not `errors.invalid_credentials` (no global error namespace). -- API error codes are **not** repeated here — the SDK has its own resource map - for `LocalizedMessage` keys. +- General UI copy does not copy the backend `lockey_*` namespace. The SDK returns + normalized outcomes and message keys as data, not translations. + [Proposed ADR-0027](../../../docs/decisions/0027-frontend-i18n.md#message-and-test-contract) + assigns P02d-6 page-outcome-to-UI-key mapping to the web app; arbitrary backend + message keys never become general UI lookup identifiers. ### Step 2: Add the key in every locale @@ -205,12 +210,14 @@ I18n: enrollment.list.empty_state.title, enrollment.list.empty_state.cta_label to a translation key. - **Per-locale branching.** If you find yourself doing `if (locale === "tr") ...`, encode the behaviour as data via ICU. -- **Missing translation for non-default locale.** The build falls back to the - default locale — and the user sees mixed languages. Tests catch this. +- **Confusing language fallback with a missing key.** Proposed ADR-0027 selects a + whole fallback catalogue only for an unauthored UI language; missing keys in + supported bundled catalogues must fail validation, not mix languages per key. - **Renaming without deprecation window.** Stale references break the build for every consumer. -- **Putting backend `LocalizedMessage` keys here.** Those have their own - `lockey_*` prefix and live with the API contract, not the i18n bundle. +- **Mixing backend keys with general UI copy.** `LocalizedMessage` keys retain + their `lockey_*` API contract. A feature rendering them owns explicit supported + error resources; the SDK does not supply translations. - **Long keys.** A key over ~80 chars is a sign the namespace is wrong. Split. - **`I18n:` trailer missing.** Without it, `git log --grep` for translation changes is broken. diff --git a/docs/decisions/0027-frontend-i18n.md b/docs/decisions/0027-frontend-i18n.md index 5870a363..290dc868 100644 --- a/docs/decisions/0027-frontend-i18n.md +++ b/docs/decisions/0027-frontend-i18n.md @@ -21,8 +21,8 @@ owns the page plan and approval boundary. catalogue or a provider to every browser. - Tenant-enabled content locales and the smaller set of translated UI catalogues have different owners. UI support must not narrow content admission. -- Three catalogue paths are currently documented. The first consumer needs one - location, key grammar and mechanically checked translation contract. +- Competing unimplemented catalogue sketches need one location, key grammar and + mechanically checked translation contract at the first consumer. - Host/locale admission, exact content locale and trusted transport are already accepted in ADR-0052/0053/0054. An i18n library must not replace those boundaries. - Air-gapped rendering must use bundled messages and runtime locale data, without @@ -68,9 +68,10 @@ delivers membership-first entry through mandatory native ingress. P02d-6 is the first consumer that needs translated platform copy, earlier than the original Phase 04 reservation. -The documentation currently names `packages/i18n`, `apps/web/locales` and -`apps/web/src/i18n/<locale>/<namespace>.json`. None exists as an implemented -catalogue. The proposed single home is: +The documentation currently sketches `packages/i18n`, `apps/web/locales` and +`apps/web/src/i18n/<locale>/<namespace>.json`, with differing namespace layouts in +the frontend skills. None exists as an implemented catalogue. The proposed single +home is: ```text frontend/apps/web/src/i18n/ @@ -88,6 +89,7 @@ measured duplication trigger; an empty `packages/i18n` is not created. Pin `next-intl` **4.14.9** exactly in the web app and commit the lockfile. The npm metadata checked on 2026-10-09 declares **MIT**, Next 15 and React 19 peer support. +Those ranges include the repository's exact **Next 15.5.18 / React 19.0.0** pins. That is compatibility evidence, not a passing build; installation, licence-file inspection, type checking and a production build are implementation obligations. No paid service or network runtime is introduced. @@ -99,6 +101,24 @@ translated labels its interaction needs, never the full catalogue by default. The error boundary's retry control is such a bounded consumer; a provider is not required merely to render static translated text. +`src/i18n/request.ts` calls a server-only, request-cached admission loader. That +loader re-verifies the existing ingress envelope, takes the canonical route locale +from its signed target, and checks live site enabled-locale membership. It is the +same loader used by document/layout/page consumers; it neither imports next-intl +nor reads messages, avoiding a configuration cycle or another bootstrap call. +After admission, a pure selector chooses the UI catalogue and the configuration +returns its locale and `{public: messages}`. The callback does not read next-intl's +middleware-derived `requestLocale` or use a callsite locale override as authority. +It adds no locale header, i18n middleware, rewrite, cookie or `Accept-Language` +selection. Route params and observed query data cannot replace the signed source. + +The proposed `/{locale}/status/not-found` namespace uses that same live admission; +it never loads Education content. Admitted `/studio` and `/portal` scaffolds have +no content locale and retain platform English until Phase 06 supplies their UI. +A failed admission cannot select a tenant document through English fallback. +Production tests prove this integration without next-intl routing middleware, +including concurrent hosts/locales and configuration use before page rendering. + ### Content locale, UI messages and document language The verified route locale remains the exact locale sent to the Education API. @@ -109,8 +129,9 @@ For platform UI copy, select an authored catalogue by exact canonical tag, then remove one rightmost subtag at a time, then use the platform `en` catalogue. P02d-6 supplies complete `en` and `tr` catalogues. A tenant-enabled `ar` or `tr-TR` route remains admissible even when only fallback UI copy exists. Do not redirect -it or substitute the content language. Missing required keys in a bundled -catalogue are a build failure, not a request-time fallback to raw keys. +it or substitute the content language. Fallback selects a whole UI catalogue when +that UI language is absent; a missing required key within any supported bundled +catalogue is a build failure, never per-key fallback or a raw-key response. ICU formatting uses the selected catalogue's locale: English fallback copy uses English plural rules even on a `zh` content route. The API locale and document @@ -125,24 +146,34 @@ Pattern A titles, summaries and bodies do not gain cross-locale fallback. ### Message and test contract -Platform message identifiers retain the `lockey_` prefix, grouped under the -feature namespace. For example, `en/public.json` contains: +General platform UI identifiers use lowercase dotted feature namespaces and +snake_case segments, without `lockey_`. For example, `en/public.json` contains: ```json { - "lockey_catalog": { + "catalog": { "course_count": "{count, plural, one {# course} other {# courses}}" } } ``` -The request configuration mounts that file under `public`. Its translator -resolves `lockey_catalog.course_count` with `{count}`. JSON nesting supplies the -dot separator; leaves use snake_case. +The request configuration mounts that file under `public`. The `public` +translator resolves `catalog.course_count` with `{count}`; the full identifier is +`public.catalog.course_count`. JSON nesting supplies the dot separators. Values use ICU MessageFormat; dynamic content enters as plain text parameters. Do not use rich-text translation callbacks, HTML messages, authored URL attributes or client input as a message identifier. +Backend `LocalizedMessage.Key` and Problem Details `messageKey` retain their +`lockey_` wire contract, including backend error-localization resources. They are +distinct from general UI identifiers. The SDK validates those payloads and maps +known machine codes to `AppError`; it owns no translated resources. P02d-6's web +app maps supported page outcomes to explicitly owned feature UI keys. It never +uses arbitrary backend message keys, titles, field errors or parameters as lookup +identifiers or visible copy; unknown outcomes select the bounded unavailable +state. This does not remove the backend-message localization contract for future +form consumers, whose UI owns its supported error resources. + The frontend suite checks a nonempty catalogue, equal key sets across supported UI languages and valid ICU messages, including matching argument names/types. Typed callsite-to-catalogue checking or a checked callsite census also rejects a @@ -153,9 +184,12 @@ must fail. Override library error/fallback behavior so a formatter failure yield a bounded translated unavailable state, never a raw identifier, namespace, parameter value or library diagnostic in the document or logs; exercise that path. Request tests distinguish route locale, UI fallback and resolved label locale, -including region/script tags, unsupported-but-enabled languages and RTL. The -production fixture proves document/visible-text language and that catalogues and -server configuration do not cross into an unnecessary client payload. +including region/script tags, unsupported-but-enabled languages and RTL. A +test-owned enabled `ar` locale and eligible content exercise `lang="ar"` / +`dir="rtl"`, with English fallback UI labelled `lang="en"` / `dir="ltr"`. +Do not rewrite the historical seed or claim an Arabic UI catalogue. The production +fixture proves document/visible-text language and that catalogues and server +configuration do not cross into an unnecessary client payload. ## Consequences @@ -185,10 +219,25 @@ server configuration do not cross into an unnecessary client payload. ## Implementation Notes Acceptance moves ADR-0027 to Active ADRs with P02d-6 as its first-consumer gate. -The same decision commit reconciles Localization Standards, Localization -architecture, the frontend trees, the standards index, Phase 04 and the -`add-i18n-key` / `add-frontend-route` skills. Phase 04 consumes the installed -foundation and still owns its CMS/Studio message coverage. +The same decision commit reconciles these carriers: + +- [Standards 03](../standards/03-frontend-coding.md) for UI keys/checks, + [07](../standards/07-frontend-architecture.md) for the `packages/i18n` sketch and + [08](../standards/08-localization.md#strings-in-code) for its + `packages/i18n/locales/{locale}.json` sketch. +- [Standards 09](../standards/09-error-handling.md#mapping-problem-details--ui) + and the [glossary](../glossary.md#cross-cutting-concerns) for backend wire keys, + SDK normalization and UI-owned message resolution. +- [Localization architecture](../architecture/12-localization.md), + [Frontend architecture](../architecture/14-frontend-architecture.md), + the [standards index](../standards/README.md) and + [Phase 04](../roadmap/phase-04-cms-media-pages.md) for location and ownership. +- The [add-i18n-key](../../.claude/skills/add-i18n-key/SKILL.md) and + [add-frontend-route](../../.claude/skills/add-frontend-route/SKILL.md) skills + for the same catalogue home, namespace grammar and server request integration. + +Phase 04 consumes the installed foundation and still owns its CMS/Studio message +coverage. Proposed status does not make these installation or acceptance claims. P02d-6 implements catalogues, server configuration, bounded interactive labels and the checks above. Each implementation step follows the packet's commit and two @@ -197,9 +246,13 @@ independent review rounds. This Proposed file changes no Accepted ADR body. ## Architecture Tests The obligations above are proposed proof requirements, not registered or passing -test names. Implementation registers the actual non-skippable rule names in -Standards 21 with planted controls. Existing public source-boundary and guarded -frontend-runner checks remain mandatory; no second skip-refusal rule is created. +test names. The acceptance commit registers their non-skippable names and planted +controls in a new **P02d-6 public UI localization controls** section of +[Standards 21](../standards/21-architecture-tests-catalogue.md#how-to-add-an-entry), +before implementation. Status becomes Implemented only with the actual checks. +Existing [public source-boundary controls](../standards/21-architecture-tests-catalogue.md#p02d-5-public-server-rendering-controls) +and [No_Architecture_Test_Is_Skippable](../standards/21-architecture-tests-catalogue.md#no_architecture_test_is_skippable) +remain mandatory; no second skip-refusal rule is created. ## References diff --git a/docs/glossary.md b/docs/glossary.md index 5f2fdce7..7b411f68 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -293,7 +293,7 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: |------|------------| | **`Result<T>`** | The sealed record in `LearnStack.SharedKernel.Results` implementing `IResultBase`: carries `IsSuccess`, `Value`, optional `Error`, and an optional `SuccessMessage` (a `LocalizedMessage`). Primary constructor is `internal`; callers go through `Ok` (throws on null value) / `Fail`. Application + Domain layer methods return `Result<T>` for **expected** outcomes (validation failed, not found, forbidden, business-rule violation). Exceptions are reserved for **unexpected** bugs / infrastructure faults. `Result<None>` is the payload-less success shape. See [09-error-handling.md § Two-Track Model](standards/09-error-handling.md) and [ADR-0032](decisions/0032-exception-handling-logging-and-observability.md). | | **`Error`** | The sealed record `(LocalizedMessage Message, IReadOnlyDictionary<string, IReadOnlyList<LocalizedMessage>>? Details)` that travels inside `Result<T>.Fail(error)`. `Code` is the **unprefixed stable identifier** (e.g. `"validation_failed"`) derived by stripping the `lockey_` prefix from `Message.Key` — so routing logic (`Result.ToActionResult()`, Problem Details writers) and frontend localization read consistent values without manual sync. Field-level `Details` flow as `LocalizedMessage` lists, keeping the prefix invariant uniform across the entire error payload. See [09-error-handling.md § Result Type](standards/09-error-handling.md). | -| **`LocalizedMessage`** | The sealed record `(string Key, IReadOnlyDictionary<string,string>? Params)` in `LearnStack.SharedKernel.Localization`. `Key` MUST begin with `lockey_` — enforced at the constructor — so the frontend's translation catalogues (keyed under the same prefix) resolve every user-facing message without raw English on the wire. Equality is structural (Key + Params); ctor defensively copies Params; empty params normalised to null. `Params` values must be plain text — the frontend resolves messages via React text nodes, never `dangerouslySetInnerHTML`. Per [Phase 02a Packet 2](roadmap/phase-02a-kernel-tenancy.md). | +| **`LocalizedMessage`** | The sealed record `(string Key, IReadOnlyDictionary<string,string>? Params)` in `LearnStack.SharedKernel.Localization`. `Key` MUST begin with `lockey_`, enforced at construction; this is the backend-message wire namespace, distinct from general frontend UI identifiers. Consumers own supported error resources; the SDK normalizes errors and contains no translations. See [Error Handling § Mapping Problem Details → UI](standards/09-error-handling.md#mapping-problem-details--ui). Equality is structural (Key + Params); ctor defensively copies Params; empty params normalised to null. `Params` values must be plain text — resolved messages use React text nodes, never `dangerouslySetInnerHTML`. Per [Phase 02a Packet 2](roadmap/phase-02a-kernel-tenancy.md). | | **`None`** | The `readonly record struct` value used as `Result<None>` when a command/query succeeds without returning data — replaces the `Result<T>` with `IsSuccess = true` and `Value = null` shape Standards 09 § Forbidden bans. | | **`IClock` / `IRandom` / `IGuidFactory`** | The three deterministic-test abstractions in `LearnStack.SharedKernel`. Production code never reads `DateTime.UtcNow`, instantiates `System.Random`, or calls `Guid.NewGuid()` directly — those calls go through the abstractions so tests pin the values via `FixedClock` / `FixedRandom` / `FixedGuidFactory`. Per Standards 02 § Time. | | **`UserId`** | The cross-cutting strongly-typed actor identifier in `LearnStack.SharedKernel.Identifiers` (Vogen `[ValueObject<Guid>]`). Audit columns on `AuditableEntity<TId>` reference users by `UserId` so the "no raw `Guid` on the public surface" rule (Standards 02) holds even though the Identity module lands in Phase 02b. Identity consumes the same type when it ships. | diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index f10daf1a..d5aaac01 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -3901,7 +3901,7 @@ before implementation. Earlier delivery records remain historical. The documentation pass covers the platform vision and MVP boundary; Principles; the glossary and decisions index; ADR-0008/0009/0018/0035/0036/0040/0042/0043/ 0050/0051/0052/0053/0054; Localization, Frontend Architecture and Tenant -Customization Model; Standards 03/06/07/08/11/13/15/16/21; Phase 02d and its +Customization Model; Standards 03/06/07/08/09/11/13/15/16/21; Phase 02d and its Phase 04/05/06/07 consumers. The `implement-task`, `add-frontend-route`, `add-i18n-key`, `write-adr` and commit workflows are included. @@ -3935,7 +3935,7 @@ plain-text packet. Accepted ADR bodies and shipped delivery records are unchange | G16(g) | Render the complete safe tenant palette independently of plan. The effective `showPlatformAttribution` value alone controls platform attribution; no plan inference in the renderer. | | G20 subjects | Census production frontend TS/JS and JSON, including renderer/message values, against the exact decoded seed identity literals from G20(a). Preserve the accepted platform-built-in and development/test exemptions, without a blanket catalogue exemption. P7 implements the existing registered rule across backend and frontend. | | G38(b,c) | Vitest covers synchronous views and pure mappings; real production HTML/RSC tests cover async pages. Extend the existing required Docker integration fixture to unchanged product pages while preserving the isolated P5 transport probe. P7 still owns shared demo/browser/Lighthouse activation. | -| G39 | Accept ADR-0027 at first use: exact next-intl 4.14.9, server-first configuration, app-local `src/i18n/messages/{en,tr}/public.json`, one checked ICU catalogue contract. No i18n routing middleware or preference cookie. | +| G39 | Accept ADR-0027 at first use: exact next-intl 4.14.9, server-first configuration using shared verified request admission, app-local `src/i18n/messages/{en,tr}/public.json`, dotted feature UI keys and one checked ICU catalogue contract. Backend `lockey_*` remains a separate wire namespace; the web app owns closed page-outcome mapping, not the SDK. No i18n routing middleware or preference cookie. | | G40 | Three fixed content routes, minimal tenant chrome, both opaque pagination surfaces, exact-locale metadata and explicit page states. The status/navigation choice below requires separate explicit approval with this package. | | G41 | Render only the API's ordered plain-string fields through app-local synchronous components in `apps/web/src/components/public/`. Unsupported renderer or unresolved presentation renders the bounded fallback. No HTML, Markdown, linkification, authored URL sink or new primitive. | | G42 | Emit a server-generated style element containing only four fixed `--ls-*` color properties after atomic `#rrggbb` validation. Null or malformed theme retains the entire existing CSS default palette. No per-token merge, style attribute, URL, font or organization override. | @@ -3944,7 +3944,10 @@ plain-text packet. Accepted ADR bodies and shipped delivery records are unchange UI catalogue fallback is not content fallback. An enabled language without an authored UI catalogue remains admissible; UI groups and API-resolved labels carry their actual language. Document language/direction follow the admitted route as -specified by ADR-0027. No tenant identity selects markup, translations or fields. +specified by ADR-0027. A test-owned enabled RTL locale and eligible content prove +document direction and English UI fallback language independently, without +rewriting the historical seed or promising an Arabic UI catalogue. No tenant +identity selects markup, translations or fields. #### G40 status and navigation decision @@ -3996,7 +3999,10 @@ transport/invalid response/unavailable API → unavailable state. These are ordi HTTP 200 representations, not claimed HTTP 400/429/503 responses. Bootstrap refusals retain middleware's real 404/429/503 and bounded Retry-After. A fresh bootstrap failure on the error-route request remains that neutral refusal rather -than inventing a tenant theme or configuration. No raw Problem Details is shown. +than inventing a tenant theme or configuration. A closed mapping selects owned UI +keys for supported outcomes; unknown codes/failures use the unavailable state. +No Problem Details message key, title, field error or parameter becomes a lookup +identifier or visible copy. #### Routes, pagination, metadata and chrome @@ -4119,7 +4125,7 @@ The maintainer is asked to approve these together: 3. The remaining gate answers, four implementation steps and mandatory manual accessibility proof above. -On approval, update the decisions index; Standards 03/06/07/08/16/21 and index; +On approval, update the decisions index; Standards 03/06/07/08/09/16/21 and index; Localization/Frontend/Customization architecture; Phase 04/05/06/07 inheritance; glossary terms where introduced; the two frontend skills; and this register, renderer scope/completion criteria and decision record. Only implemented tests @@ -4146,6 +4152,30 @@ Markdown files. ADR-0027 parses as Proposed. No Accepted ADR file or production code is changed; no frontend/product, browser or accessibility test is claimed passing by this preparation record. +#### External i18n draft review reconciliation (2026-10-09) + +The review of `64eefe8` is checked against the SDK, current entry/caller and +mutable documentation. Its six finding groups are addressed: general UI keys +use dotted feature names in the proposal; backend `lockey_*` resources remain +intact; the false SDK translation-map claim is removed. Standards 09, the glossary +and the key skill now distinguish wire-message data from consumer-owned copy. +Acceptance explicitly reconciles Standards 03/07/08/09 and the catalogue sketches. + +ADR-0027 now names the shared verified admission source for request configuration, +whole-catalogue fallback versus missing-key build failure, the future Standards 21 +section, actual repository dependency pins and test-owned RTL inventory. The +review's prefix claim is qualified: `lockey_` is a backend invariant, not a blanket +ban on frontend error resources. No Accepted backend localization contract is +removed. No gate is accepted and no implementation or dependency is added. + +Fresh first-round runtime/corpus reviews use GPT-6-astra high and GPT-6.1-sol +high; an independent second round uses GPT-6-sol high. All report no actionable +finding in the corrected proposal. Maintainer acceptance remains required. + +Five Release corpus cases and seven ADR workflow cases pass with zero failures or +skips. Relative-file/fragment, added-prose wrapping and diff checks pass. These are +documentation checks; no product-runtime or accessibility proof is claimed. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/standards/09-error-handling.md b/docs/standards/09-error-handling.md index d0a41193..4a556058 100644 --- a/docs/standards/09-error-handling.md +++ b/docs/standards/09-error-handling.md @@ -73,13 +73,16 @@ public readonly record struct None { public static None Value { get; } } ``` The `LocalizedMessage`'s `lockey_` prefix is invariant: the constructor -rejects any key that does not start with `lockey_`. Frontend translation -catalogues are keyed by the same prefix; backend code never returns raw -English. `Error.Code` is a **stable, unprefixed** projection of +rejects any key that does not start with `lockey_`. Resources used to resolve +backend messages retain that prefix; it is not a prefix requirement for general +frontend UI copy. Backend code never returns raw English. +`Error.Code` is a **stable, unprefixed** projection of `Message.Key` (the `lockey_` prefix is stripped). Routing logic (`Result.ToActionResult()`, Problem Details writers) reads `Code`; the -frontend reads `Message.Key` for locale resolution — two surfaces in -sync by construction. Per +consumer resolving a backend message reads `Message.Key` — two wire surfaces in +sync by construction. This does not claim a shipped frontend message catalogue. +See [Frontend Error Handling](#mapping-problem-details--ui) for current SDK +normalization and the Proposed public UI mapping. Per [Phase 02a Packet 2](../roadmap/phase-02a-kernel-tenancy.md) and [ADR-0032 § Error Model](../decisions/0032-exception-handling-logging-and-observability.md). @@ -304,15 +307,16 @@ Rules: `internal_error` is reserved for 5xx, which is what the same method returns there. - `messageKey` is the `LocalizedMessage.Key` (always begins with `lockey_`) - the frontend resolves against its i18n catalogue. The legacy + identifying a backend message for a consumer's supported error resources; + it is not an arbitrary UI lookup instruction. The legacy `detail` field is omitted — backend never returns raw English. - `instance` is the request path. - `correlationId` is the full W3C traceparent (`Activity.Current.Id`), which embeds the trace id; falls back to the request id when no trace is active. - `errors` is field-level detail, each entry a `LocalizedMessage` payload - (`key` + optional `params`) so the frontend resolves field-level messages - through the same path as the top-level one. + (`key` + optional `params`), using the same backend-message contract as the + top-level one. The frontend feature owns any supported field-message resources. ## Validation Errors @@ -320,8 +324,8 @@ Rules: - Always include all failures, not just the first one. - Field names match the request shape (`camelCase`). - Messages are localizable because they travel as keys: `messageKey` and each `errors` - entry are `LocalizedMessage` payloads (`key` + optional `params`) the frontend - resolves against its i18n catalogue, as [§ API Surface](#api-surface) states. The API + entry are `LocalizedMessage` payloads (`key` + optional `params`) for a consumer's + supported error resources, as [§ API Surface](#api-surface) states. The API returns no message text today; locale negotiation from `Accept-Language` for any message text the API composes later is [Phase 04](../roadmap/phase-04-cms-media-pages.md)'s. @@ -416,6 +420,15 @@ type AppError = ``` The SDK maps Problem Details payloads to `AppError`; UI code switches on `code`. +It validates/preserves backend message keys as data and owns no translated +resources. A feature that displays backend messages owns its supported error +resources; general UI copy has its own feature identifiers. + +[Proposed ADR-0027](../decisions/0027-frontend-i18n.md#message-and-test-contract) +specifies P02d-6's closed page-outcome mapping to owned UI keys, with unknown +outcomes mapped to the bounded unavailable state. It does not authorize arbitrary +Problem Details keys, titles, field errors or parameters as UI lookup identifiers +or visible copy. This public-page mapping is not implemented or Accepted yet. ### User-Facing Copy From c14ecf2913b6111df9c3eb52c5867d73b923e4f2 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 00:05:50 +0300 Subject: [PATCH 04/58] docs(decisions): accept P02d-6 renderer and UI localization Record maintainer approval before implementation and reconcile the frontend standards, architecture, workflow skills and future consumers. Register falsifiable proof obligations without claiming delivery. ADR: 0027, 0051, 0052, 0053, 0054 --- .claude/skills/add-frontend-route/SKILL.md | 89 ++++++++++--- .claude/skills/add-i18n-key/SKILL.md | 111 +++++++++------- .claude/skills/add-page-block/SKILL.md | 44 +++--- .claude/skills/run-tests-locally/SKILL.md | 21 +-- CLAUDE.md | 8 +- docs/architecture/12-localization.md | 72 ++++++---- docs/architecture/14-frontend-architecture.md | 74 +++++++++-- .../32-tenant-customization-model.md | 20 ++- docs/decisions/0027-frontend-i18n.md | 46 ++++--- docs/decisions/README.md | 8 +- docs/glossary.md | 1 + docs/roadmap/phase-02d-walking-skeleton.md | 125 +++++++++++------- docs/roadmap/phase-04-cms-media-pages.md | 32 +++-- .../phase-05-education-learning-content.md | 11 ++ .../roadmap/phase-06-renderer-admin-studio.md | 23 +++- .../phase-07-enrollment-learner-portal.md | 14 ++ docs/standards/03-frontend-coding.md | 43 ++++-- docs/standards/06-testing.md | 36 ++++- docs/standards/07-frontend-architecture.md | 75 +++++++++-- docs/standards/08-localization.md | 56 ++++++-- docs/standards/09-error-handling.md | 29 +++- docs/standards/16-accessibility.md | 36 +++-- .../21-architecture-tests-catalogue.md | 71 +++++++++- docs/standards/README.md | 13 +- 24 files changed, 772 insertions(+), 286 deletions(-) diff --git a/.claude/skills/add-frontend-route/SKILL.md b/.claude/skills/add-frontend-route/SKILL.md index 9dc4623c..6bcf3f1b 100644 --- a/.claude/skills/add-frontend-route/SKILL.md +++ b/.claude/skills/add-frontend-route/SKILL.md @@ -187,23 +187,36 @@ Hide, don't disable. The hook reads the entitlement projection. See ### Step 7: Localisation +**Accepted P02d-6 G39 — 2026-10-09; implementation pending.** +[ADR-0027](../../../docs/decisions/0027-frontend-i18n.md) selects exact `next-intl` +4.14.9. The target async Server Component pattern is: + ```tsx -import { useTranslations } from "next-intl"; // or react-intl per the i18n ADR +import { getTranslations } from "next-intl/server"; -export default function CoursesPage() { - const t = useTranslations("courses"); +export default async function CoursesPage() { + const t = await getTranslations("public.catalog"); return <h1>{t("title")}</h1>; } ``` -Translation keys live under `frontend/apps/web/src/i18n/<locale>/courses.json`. +Messages live in `frontend/apps/web/src/i18n/messages/<locale>/<namespace>.json`; +P02d-6 supplies complete `en/public.json` and `tr/public.json`. General UI keys +are dotted feature identifiers, distinct from backend `lockey_*` wire keys. The +web app owns closed page-outcome mappings; the SDK supplies no translations. See [add-i18n-key](../add-i18n-key/SKILL.md). -> **Open in Phase 02d.** No i18n library is installed and no catalogue exists. Whether -> ADR-0027 picks the library in Phase 02d, and where the one UI string catalogue -> lives — the corpus names three paths — are G39 in -> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); -> the pass that closes it edits this step and add-i18n-key. +`src/i18n/request.ts` uses the same server-only, request-cached verified admission +loader as document/layout/page consumers. It re-verifies the ingress envelope, +reads the canonical locale from the signed target and checks live enabled-locale +membership. The loader neither imports next-intl nor reads messages. No i18n +routing middleware, preference cookie, locale header or callsite override supplies +authority. Only whole-catalogue fallback is allowed for unauthored UI languages; +missing used keys in supported catalogues fail validation. Preserve exact content +locale, document language and actual resolved-label language. + +Acceptance supplies no installed runtime, catalogue, `lint:i18n` command or +screenshot/axe tooling. Implement and prove the registered contract in P02d-6. ### Step 8: Public-site SSR caching @@ -214,21 +227,54 @@ public rendering and no-store API transport; no positive `revalidate`, ISR, Request-local reuse is isolated to one incoming request. Server Component HMR caching is disabled. A new server/document request rechecks eligibility; client history is not a revocation guarantee. Use the configured server caller; never derive tenancy -from a page header or add hop options to the injected SDK. P6 owns page consumers. +from a page header or add hop options to the injected SDK. + +**Accepted P02d-6 G40 — 2026-10-09; implementation pending.** Use ordinary +same-host relative anchors for public navigation and pagination, without automatic +prefetch or reliance on retained client Router Cache. Request-local metadata, +layout and page share verified admission and resource loaders; each page honors +their result before emitting a shell. No loading boundary may flush before +redirect admission. + +Parse owned pagination values only from the verified raw signed target, never +observed Next `searchParams`. Catalog uses `cursor`/`limit`; outline uses +`lessonCursor`/`lessonLimit`, default 20 and API bounds. Refuse duplicate, empty, +malformed or oversized owned values; never decode opaque cursors or fabricate a +previous cursor. Paginated metadata is noindex with a cursor-free canonical. +Canonical/alternate URLs use the verified live host and eligible API slugs. ### Step 9: Loading + error boundaries -Every route ships its own: - -- `loading.tsx` — skeleton shell, not a blank page. No "loading…" spinners for - expected-fast resources (<250 ms). -- `error.tsx` — graceful boundary; 404 page renders the tenant's brand if a - tenant was resolved. +Each route retains `loading.tsx` and a graceful `error.tsx`; P02d-6's accepted +status composition is distinct from the framework's thrown `notFound()` behavior: + +- A missing/hidden content resource returns local HTTP 307 to the same host's + `/{locale}/status/not-found`. The browser URL changes; the original response is + not a direct 404. +- Middleware admits that fixed status namespace through the same live host and + locale checks, then supplies HTTP 404. Its ordinary server-rendered document has + localized language/direction, safe theme/chrome, noindex metadata and a catalog + recovery link. It never queries Education or echoes the original slug/query. +- Unknown-host/provenance refusals remain direct masked responses. Fresh status + bootstrap failure retains neutral 404/429/503; fallback UI cannot invent tenant + admission. HEAD is bodyless throughout. +- Known content-call failures are controlled translated HTTP 200 noindex states: + invalid cursor with reset link, retry-later for 429, unavailable for transport, + invalid responses or unavailable API. They do not claim HTTP 400/429/503. + Closed outcome mapping never exposes backend keys, titles, field errors or + parameters as lookup identifiers or visible copy. +- Unexpected framework errors keep pre-stream 500 / post-stream 200 behavior; + `error.tsx` cannot set arbitrary status. + +The accepted normal document costs three API calls; following a missing document +through the status route costs five total. Shared request-local loaders add no +metadata/layout/page calls or cross-request cache. See +[the P02d-6 package](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09). ### Step 10: Tests -- Component tests (`frontend/apps/web/src/app/(studio)/dashboard/users/page.test.tsx`) - with Testing Library, per +- Synchronous view/mapping tests use Vitest/Testing Library; async public pages use + the real production HTML/RSC fixture, per [Testing Standards § Frontend Test Types](../../../docs/standards/06-testing.md#frontend-test-types). Automated `axe-core` runs through Playwright, owned by [Phase 06](../../../docs/roadmap/phase-06-renderer-admin-studio.md) per @@ -236,8 +282,11 @@ Every route ships its own: the manual keyboard and contrast checks [Accessibility Standards § Tooling](../../../docs/standards/16-accessibility.md#tooling) and [§ Testing](../../../docs/standards/16-accessibility.md#testing) require are - recorded in the PR description. The phase that ships a route names its test set in - its decision register. + recorded in the PR description. P02d-6 also requires actual manual screen-reader, + keyboard, focus, 320 CSS px reflow/zoom, long-string and contrast evidence on both + 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 diff --git a/.claude/skills/add-i18n-key/SKILL.md b/.claude/skills/add-i18n-key/SKILL.md index c974be13..5ad08593 100644 --- a/.claude/skills/add-i18n-key/SKILL.md +++ b/.claude/skills/add-i18n-key/SKILL.md @@ -1,7 +1,8 @@ --- name: add-i18n-key description: > - Add a translation key under `frontend/apps/web/src/i18n/<locale>/<namespace>.json` + Add a translation key under + `frontend/apps/web/src/i18n/messages/<locale>/<namespace>.json` with the project's dotted-feature namespace + ICU MessageFormat conventions. USE FOR: adding a user-facing string, renaming a key (with deprecation), removing a key. DO NOT USE FOR: backend `LocalizedMessage` keys (those follow the @@ -19,14 +20,13 @@ Manage user-facing translations in `apps/web` consistently per [08-localization.md](../../../docs/standards/08-localization.md) + [ADR-0008 Localization Schema](../../../docs/decisions/0008-localization-schema.md). -> **Open in Phase 02d.** None of the machinery below exists today: no -> `frontend/apps/web/src/i18n/` tree, no i18n library, no `pnpm lint:i18n` task, no -> `no-literal-strings` rule, no `_deprecated.json`, and no screenshot or `axe-core` -> test. The competing catalogue sketches and library choice are G39 in -> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); -> [Proposed ADR-0027](../../../docs/decisions/0027-frontend-i18n.md) selects their -> replacement, pending approval. The pass that accepts it edits this skill's -> catalogue paths and implementation workflow. +> **Accepted G39 — 2026-10-09; implementation pending.** +> [ADR-0027](../../../docs/decisions/0027-frontend-i18n.md) selects exact +> `next-intl` 4.14.9 and the catalogue home below for P02d-6. Acceptance installs +> no runtime, catalogue or tooling. No `pnpm lint:i18n`, `no-literal-strings`, +> `_deprecated.json`, screenshot or `axe-core` task exists today. Use the packet's +> registered proof obligations; do not claim these commands or checks run until +> implementation supplies them. ## When to use @@ -52,31 +52,28 @@ Manage user-facing translations in `apps/web` consistently per |-------|----------|-------------| | Key | Yes | Dotted-feature namespace: `enrollment.list.empty_state.title`. | | Default locale value | Yes | The English (or product-default) translation. | -| Other locales | If translations exist | Each locale's translation. | +| Other locales | Yes for supported UI languages | Complete `en` and `tr` initially. | | Pluralisation / interpolation | If applicable | Use ICU MessageFormat. | ## Workflow ### Step 1: Pick the namespace -Translation files are split by **feature**: +The accepted app-local catalogue layout is: -``` +```text frontend/apps/web/src/i18n/ - en/ - auth.json - catalog.json - classroom.json - common.json - enrollment.json - notifications.json - portal.json - studio.json - tr/ - ... - ... + request.ts + messages/ + en/public.json + tr/public.json ``` +P02d-6 supplies only `public`; later features add a namespace beneath each locale. +Request configuration mounts each file beneath its namespace. JSON nesting +supplies dotted identifiers: `public.catalog.course_count` is +`catalog.course_count` within the `public` translator. + Keys are dotted: `<namespace>.<feature>.<descriptor>`. Examples: ``` @@ -100,17 +97,18 @@ Rules: `errors.invalid_credentials` (no global error namespace). - General UI copy does not copy the backend `lockey_*` namespace. The SDK returns normalized outcomes and message keys as data, not translations. - [Proposed ADR-0027](../../../docs/decisions/0027-frontend-i18n.md#message-and-test-contract) + [ADR-0027](../../../docs/decisions/0027-frontend-i18n.md#message-and-test-contract) assigns P02d-6 page-outcome-to-UI-key mapping to the web app; arbitrary backend message keys never become general UI lookup identifiers. ### Step 2: Add the key in every locale -The convention: a key MUST exist in the default locale (en) before any non-default -locale. Don't ship a key with translations missing from `en`. +Every used key MUST exist in each supported bundled UI catalogue. P02d-6 requires +complete `en` and `tr` `public` messages. These later enrollment examples show the +same layout; they do not introduce an enrollment catalogue in P02d-6. ```jsonc -// frontend/apps/web/src/i18n/en/enrollment.json +// frontend/apps/web/src/i18n/messages/en/enrollment.json { "list": { "empty_state": { @@ -123,7 +121,7 @@ locale. Don't ship a key with translations missing from `en`. ``` ```jsonc -// frontend/apps/web/src/i18n/tr/enrollment.json +// frontend/apps/web/src/i18n/messages/tr/enrollment.json { "list": { "empty_state": { @@ -155,10 +153,23 @@ return <p>{t("count", { count: learners.length })}</p>; ### Step 4: Variable interpolation -ICU placeholders: `{name}`, `{count}`, `{date, date, short}`. The frontend i18n -library (next-intl / react-intl — ADR-0027, reserved in -[the decisions index](../../../docs/decisions/README.md#open-adr-drafts)) handles ICU -natively. +ICU placeholders: `{name}`, `{count}`, `{date, date, short}`. The accepted +`next-intl` 4.14.9 runtime handles ICU using the selected UI catalogue's locale. +Dynamic values enter as plain text parameters, never HTML or rich-text callbacks. + +`src/i18n/request.ts` uses the same server-only request-cached verified admission +loader as document/layout/page consumers. That loader neither imports next-intl +nor reads messages. The canonical locale comes from the signed target and live +site membership, never next-intl middleware `requestLocale`, callsite overrides, +query data, cookies or `Accept-Language`. Do not install i18n routing middleware. + +Select one whole UI catalogue by exact canonical tag, successive rightmost-subtag +removal, then platform `en`. An unauthored UI language does not refuse or redirect +an enabled content locale. API content locale and document language remain the +admitted locale; fallback UI groups carry their actual language and direction. +A missing used key within a supported catalogue fails validation; it never +triggers per-key fallback. See +[ADR-0027's contract](../../../docs/decisions/0027-frontend-i18n.md#message-and-test-contract). ### Step 5: Don't branch on locale @@ -176,15 +187,15 @@ it as data (date formats, currency, plural rules ICU already knows). 1. Add the new key with the same value as the old key. 2. Update all call sites to use the new key. -3. Mark the old key in a tracking file (`frontend/apps/web/src/i18n/_deprecated.json`) - with the planned removal date (≥ 1 release window). +3. Record the planned removal date (≥ 1 release window). The sketched + `_deprecated.json` is not implemented; do not assume a tracking file exists. 4. After the window, remove the old key from every locale file. ### Step 7: Remove a key -1. Confirm zero call sites: `grep -rn "<old.key>" frontend/apps/web/src/`. +1. Confirm zero call sites: `rg "<old.key>" frontend/apps/web/src/`. 2. Remove the key from every locale's JSON file. -3. Remove the tracking entry from `_deprecated.json`. +3. Remove the deprecation tracking entry, if present. ### Step 8: Trailer @@ -197,20 +208,28 @@ I18n: enrollment.list.empty_state.title, enrollment.list.empty_state.cta_label ## Validation -- `pnpm lint:i18n` (custom lint task) flags missing keys per locale. -- `pnpm test` is green; tests that depend on a key surface a clear failure if - it's missing. -- Visual / screenshot tests show the key resolving in every locale, not the - raw key string. -- `axe-core` accessibility test passes (translations don't break ARIA labels). +P02d-6 implements these accepted obligations in the guarded frontend suite; the +acceptance record alone is not passing evidence: + +- Nonempty catalogues with equal key sets and valid ICU messages, including + matching argument names/types across supported languages. +- Typed callsite checking or a checked census rejects absent-from-all keys and + misspelled identifiers. Planted missing-key, unknown-callsite, malformed-ICU + and mismatched-argument controls must fail. +- Formatter failures select the bounded translated unavailable state without raw + keys, parameters or library diagnostics in the document or logs. +- Production HTML/RSC proofs cover locale fallback, language/direction and + concurrent hosts/locales without unnecessary client catalogue payloads. +- Record actual implemented commands and results. Screenshot/axe tooling is not + supplied by acceptance; Phase 06 owns the full Playwright/axe suite. ## Common pitfalls -- **Hardcoded English in JSX.** Lint rule `no-literal-strings` will reject. Move - to a translation key. +- **Hardcoded English in JSX.** Move platform copy to a translation key. No + `no-literal-strings` rule is installed by the acceptance record. - **Per-locale branching.** If you find yourself doing `if (locale === "tr") ...`, encode the behaviour as data via ICU. -- **Confusing language fallback with a missing key.** Proposed ADR-0027 selects a +- **Confusing language fallback with a missing key.** ADR-0027 selects a whole fallback catalogue only for an unauthored UI language; missing keys in supported bundled catalogues must fail validation, not mix languages per key. - **Renaming without deprecation window.** Stale references break the build for diff --git a/.claude/skills/add-page-block/SKILL.md b/.claude/skills/add-page-block/SKILL.md index 836f5ee6..b5f69c38 100644 --- a/.claude/skills/add-page-block/SKILL.md +++ b/.claude/skills/add-page-block/SKILL.md @@ -60,17 +60,13 @@ and [17-page-builder.md](../../../docs/architecture/17-page-builder.md). | **Composite renderer** | C# composite registry + React renderer (`default-card`, `content-list`, `card-grid`). | LearnStack engineering. | Tenants compose this in `TenantPageBlock` rows. | | **Tenant block** | `tenant_page_blocks` row (data only). | Tenant admin via Studio editor. | Tenant-specific shape (`vocabulary-list` for English, `asana-card` for yoga). | -> **Open in Phase 02d.** The code paths in this step and the next two describe intended -> shape. At HEAD the frontend registry is `frontend/apps/web/src/lib/customization/` — -> `PRIMITIVE_KEYS` and `COMPOSITE_KEYS` as closed key lists with `resolveRendererKey`, -> no component and no `registerPrimitive` / `registerComposite` — and the backend's -> closed sets are `PrimitiveRendererKey` and `CompositeRendererKey` in -> `LearnStack.Modules.Customization.Domain`; no `PageBlockRegistry` exists. Where -> primitive and composite components live — `components/blocks/` here, `packages/blocks` -> in Frontend Architecture Standards — is G41 in -> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register), -> and the pass that closes it, which ships the lesson renderer's first components, edits -> this skill with its answer. +> **Accepted P02d-6 G41 — 2026-10-09; implementation pending.** The first +> ordered plain-string `default-card` views live in +> `frontend/apps/web/src/components/public/`, with bounded unavailable fallbacks +> and no active URL sink. The wider registry and component sketches below remain +> Phase 04/05 and Phase 06 targets. The existing key lists in +> `src/lib/customization/` are not renderer implementations. See the +> [accepted package](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-accepted-answers). ### Step 2: Primitive block (path A) @@ -208,20 +204,16 @@ the page. - JSON Schema validation test for the new primitive / tenant schema. - Renderer snapshot test. -- Accessibility test (`axe-core` violations fail). No test runs `axe-core` yet: - [Testing Standards § End-to-End Tests](../../../docs/standards/06-testing.md#end-to-end-tests) - puts automated axe through Playwright in - [Phase 06](../../../docs/roadmap/phase-06-renderer-admin-studio.md). Which - accessibility checks fail a build on Phase 02d's pages, jsdom axe among the options, - is G43 in - [that phase's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register), - whose pass edits this bullet with its answer. -- Budgets in [Performance Standards](../../../docs/standards/15-performance.md) for - representative pages embedding the block — judged by reading while CI's - `lighthouse budget` job remains a placeholder. Whether it activates in Phase 02d - is G44 in - [that phase's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); - the pass that closes it edits this bullet. +- Full automated `axe-core` through Playwright belongs to + [Phase 06](../../../docs/roadmap/phase-06-renderer-admin-studio.md), per + [Testing Standards](../../../docs/standards/06-testing.md#end-to-end-tests). + Accepted P02d-6/G43 requires applicable jsx-a11y error rules with actual-config + planted controls, localized product-page semantics and real manual keyboard, + focus, reflow, contrast and screen-reader evidence. Implementation and evidence + remain pending; acceptance alone supplies no passing accessibility check. +- [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. ## Validation @@ -236,7 +228,7 @@ the page. and UI colours pass, per [Accessibility Standards § Testing](../../../docs/standards/16-accessibility.md#testing), recorded in the PR description. Automated `axe-core` is not a gate yet — Step 7 names - its owner and G43, the Phase 02d gate open on it. + its Phase 06 owner and Accepted G43's earlier P6 obligations. ## Common pitfalls diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index bfeb6d4c..41fe2aeb 100644 --- a/.claude/skills/run-tests-locally/SKILL.md +++ b/.claude/skills/run-tests-locally/SKILL.md @@ -173,7 +173,9 @@ an existing user process. Product-page browser/a11y tests remain P6/P7. > `axe-core` nor `@playwright/test` is a dependency. Both arrive in **Phase 06**, 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. P02d-6/G43 selects the skeleton's accessibility gate. P5's +> remains disabled. Accepted P02d-6/G43 requires failing applicable `jsx-a11y` +> rules, localized page semantics and a real manual screen-reader smoke. These +> controls are implementation obligations, not delivered by acceptance. P5's > production HTML/RSC fixture proves transport, not a browser/a11y audit. ### Step 7: Single-test focus @@ -274,10 +276,11 @@ 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. Whether route tests or `jsx-a11y` - findings fail a build on Phase 02d's pages is P02d-6/G43 in - [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register), - and the pass that closes it edits this line. + The 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 + [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-accepted-answers). ## Common pitfalls @@ -295,9 +298,9 @@ The entry decision does not provide a Lighthouse command or passing audit. - **Assuming an accessibility gate exists.** [16-accessibility.md](../../../docs/standards/16-accessibility.md) makes WCAG 2.2 AA binding, and Phase 06 brings the axe suite that enforces it. Reading the - standard is the gate today; whether a route test or lint rule fails a build - earlier, on Phase 02d's pages, is G43 in - [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register), - and the pass that closes it edits this pitfall. + standard does not prove implementation. Accepted P02d-6/G43 requires earlier + failing lint/semantics controls and actual manual assistive-technology evidence; + DOM assertions cannot substitute for a screen reader. Delivery is recorded in + [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-accepted-answers). - **CI-only failures.** Usually a race or timing assumption. Use `--blame-hang` + `--blame-crash` locally. diff --git a/CLAUDE.md b/CLAUDE.md index 6373fe94..404fa989 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,8 +115,12 @@ and merged** through [PR #26](https://github.com/HodeTech/LearnStack/pull/26) on 2026-10-09. Its [merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09) records final verification. -Work stays on development. P02d-6's decision pass and product pages are next; -P02d-7 owns the browser demo and Lighthouse. Neither packet is delivered by P5. +Work stays on development. **P02d-6's decision pass is Accepted — 2026-10-09**: +[ADR-0027](docs/decisions/0027-frontend-i18n.md) and its +[decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09) +authorize four implementation steps and two independent review rounds per step. +The decision commit delivers no product pages. P02d-7 owns the browser demo and +Lighthouse; 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/docs/architecture/12-localization.md b/docs/architecture/12-localization.md index 91db26b6..2e45e178 100644 --- a/docs/architecture/12-localization.md +++ b/docs/architecture/12-localization.md @@ -18,8 +18,9 @@ Initial renderer scope: - Document direction and logical layout properties apply from the first public renderer, including an enabled RTL locale; they are not deferred until a tenant - requests a redesign. P02d-6 G39/G43 selects the runtime mapping and its proofs. -- P02d-6 proposes initial English and Turkish platform UI catalogues in + requests a redesign. Accepted P02d-6 G39/G43 selects the mapping and proofs; + implementation remains pending. +- P02d-6 accepts initial English and Turkish platform UI catalogues in [ADR-0027](../decisions/0027-frontend-i18n.md). Additional UI translations are added when authored; their absence must not narrow enabled content locales. - Plural forms beyond ICU MessageFormat defaults (Turkish has different rules than English; ICU handles both). @@ -297,34 +298,53 @@ The routing consequences follow directly, and are behaviour rather than defects: ## UI String Catalogue -UI strings (button labels, validation messages, empty-state copy) live in JSON catalogues under the frontend. +**Accepted P02d-6 G39 — 2026-10-09; implementation pending.** +[ADR-0027](../decisions/0027-frontend-i18n.md) selects server-first `next-intl` +4.14.9 and one app-local catalogue home: ```text -apps/web/locales/ - en/ - common.json - portal.json - studio.json - tr/ - common.json - portal.json - studio.json +apps/web/src/i18n/ + request.ts + messages/ + en/public.json + tr/public.json ``` -Keys are dotted, namespaced by feature, ICU MessageFormat for plural/select. The -frontend uses a lightweight i18n library (e.g. `next-intl` or `react-intl`); the -choice is now drafted in -[Proposed ADR-0027](../decisions/0027-frontend-i18n.md). No library is installed or -accepted yet. The tree above remains an unimplemented sketch until G39 accepts -one catalogue home; the proposal selects an app-local server-first foundation. - -> **Open in Phase 02d.** Where the catalogue lives — this tree, -> [Localization Standards § Strings in Code](../standards/08-localization.md#strings-in-code) -> and the `add-i18n-key` skill each name a different path — and whether ADR-0027 is -> Accepted in that phase are G39 in -> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). - -API responses do **not** localise system-level identifiers, only human-facing strings. Error codes are stable English strings; human-readable messages are localised by the consumer when needed, using the locale from the JWT or request. +Only `public` ships first; later feature namespaces use the same locale directory. +Shared-package extraction follows ADR-0009's measured duplication trigger. +General UI keys are lowercase dotted feature names with snake_case segments, such +as `public.catalog.course_count`; JSON nesting supplies the dots. Values use ICU +MessageFormat with plain text parameters. Backend `lockey_*` message resources +remain separate; the SDK validates/normalizes wire errors and owns no translations. +The web app maps closed page outcomes to its own keys, with bounded unavailable +copy for unknown outcomes, never arbitrary backend lookup or visible diagnostics. + +Request configuration uses the same server-only request-cached admission source as +document/layout/page consumers: verified ingress, signed target locale and live +enabled membership. It adds no bootstrap call, i18n routing middleware, locale +header, rewrite, cookie or `Accept-Language` authority. Messages/configuration stay +server-only; interactive consumers receive only needed translated labels. + +UI fallback selects a whole catalogue by exact tag, narrowed rightmost subtags, +then platform `en`. Missing required keys in bundled supported languages fail the +build, not per-key fallback. ICU uses the selected UI language's plural rules. +Checked key/ICU/argument/callsite contracts and planted controls are required by +[Localization Standards](../standards/08-localization.md#strings-in-code). + +The admitted content locale independently selects API reads and document `lang`; +runtime internationalization data supplies its direction, with deterministic `ltr` +when undescribed. UI groups carry their actual selected language/direction, and +Pattern B labels carry their resolved locale. A test-owned enabled `ar` route +proves RTL content with English fallback UI, without altering seed inventory or +claiming an Arabic catalogue. Pattern A content never gains cross-locale fallback. +The dependency, catalogues, runtime and proofs are not installed/delivered by this +acceptance. Phase 04 inherits the foundation for CMS/Studio; Phase 06 owns the full +Studio/portal consumers. + +API error codes remain stable machine identifiers. Problem Details carries +backend `lockey_*` message data, not translated text; supported consumer resources +own its resolution. Public pages instead use the closed UI-outcome mapping above. +See [Error Handling Standards](../standards/09-error-handling.md#mapping-problem-details--ui). ## Notification Template Localisation diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index f8680f39..0465d5a4 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -27,6 +27,12 @@ native method/upgrade admission and pinned URL controls. The remediation record owns execution and independent review evidence; the ongoing rules live in [Frontend Standards](../standards/07-frontend-architecture.md#native-admission-and-url-identity). +**P02d-6 decisions Accepted — 2026-10-09; implementation pending.** +[ADR-0027](../decisions/0027-frontend-i18n.md) selects the UI foundation; the +[decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09) +selects pages, status/navigation, safe theme and product/accessibility proofs. +Acceptance installs no dependency and delivers no product page or passing proof. + > **P02d-4 Accepted design — 2026-10-03.** Step 2 delivers site bootstrap; > Steps 3–4 deliver Education and contract/SDK controls; P02d-5 owns the server consumer. > [ADR-0052](../decisions/0052-anonymous-public-read-boundary.md) selects host-resolved @@ -61,13 +67,18 @@ frontend/ sessions/ api/ # only thin BFF proxies, see "Data Fetching" components/ + public/ # Accepted P6 plain-string views; pending blocks/ # built-in primitive page blocks ui/ # design-system primitives lib/ api/ auth/ tenant/ - i18n/ + i18n/ # Accepted ADR-0027 home; pending + request.ts + messages/ + en/public.json + tr/public.json middleware.ts # ingress verification + locale entry extensions/ # client-side block resolver, see Page Builder packages/ @@ -76,8 +87,9 @@ frontend/ config/ # eslint, tsconfig, tailwind shared bits ``` -> **Phase 02d ownership.** Component placement (G41) and the UI string catalogue -> (G39) remain P02d-6 decisions. P02d-5/G36 delivers Node middleware in +> **Phase 02d ownership.** Accepted G41 places synchronous plain-string views in +> `apps/web/src/components/public/`; G39 selects the app-local i18n home above. +> Both remain implementation-pending. P02d-5/G36 delivers Node middleware in > `apps/web/src/middleware.ts`. The ownership and accepted entry rule are recorded in > [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). > The tree above is the earlier target layout, not a shipped-directory census. @@ -174,14 +186,47 @@ Per segment: Static export is not used; tenants are resolved at request time and the renderer needs per-request context. +### Accepted P02d-6 public composition + +The three locale-prefixed catalog/course/lesson routes use minimal tenant chrome, +ordinary same-host relative anchors and no automatic prefetch. Both opaque +pagination surfaces parse owned parameters only from the signed raw target. A new +document re-reads API state; metadata/layout/page share request-local admission and +content loaders. Normal documents cost three API calls; a followed missing-resource +redirect costs five. No retries or shared validated-DTO cache are added. + +Missing/hidden resources return local **307** to the same host's fixed +`/{locale}/status/not-found`, followed by a branded **404** document; the browser +URL changes. Live host/locale admission applies again, with no Education read or +original slug/query/cursor echo. Required loading/error files cannot flush a shell +before redirect admission. Known content-call failures use translated **HTTP 200 +noindex** states for invalid cursor, retry-later and unavailable responses; +bootstrap retains real neutral 404/429/503. Framework exceptions retain pre-stream +500/post-stream 200 behavior. [Standards 07](../standards/07-frontend-architecture.md#routing) +owns the detailed status and navigation rules. + +Metadata uses verified live host and validated local segments, with actual eligible +alternate slugs. Pagination is noindex with a cursor-free first-page canonical; +errors are noindex without misleading resource alternates. Restricted marketing, +empty catalog/public outline, empty ready content and unavailable content remain +distinct. The lesson view renders only ADR-0051's ordered plain-string +`default-card` fields; unsupported presentation has a bounded fallback. No active +URL sink, HTML, Markdown, linkification or richer primitive is introduced. Full +menus, media, preview, authored error pages and Studio remain Phase 06. + +UI configuration shares verified request-local admission without i18n routing +middleware or another locale authority. UI fallback does not change API content +locale; document/UI/label language and RTL rules live in +[Localization](12-localization.md#ui-string-catalogue). + ## Theming **P02d-4 Step 2 public projection delivered.** Bootstrap returns only the whole typed four-color theme or null; frontend safe CSS defaults remain owned here, without backend duplication. Baseline colors apply independently of plan. Effective WhiteLabelBranding removes LearnStack attribution only. Public -responses expose no setting keys, raw/partial JSON or organization merge. G42 -still owns document injection in P02d-6. +responses expose no setting keys, raw/partial JSON or organization merge. Accepted +G42 selects document injection in P02d-6; implementation remains pending. **P02d-2/3 delivered foundation — 2026-10-03.** The theme writer and typed settings accessor are implemented. Step 2 delivers the anonymous projection; renderer @@ -189,7 +234,7 @@ injection remains P02d-6. The [whole-theme contract](../modules/tenancy/README.md#whole-theme-setting-and-public-boundary) selects only tenant-wide color values and no remote subresource. Organization merges, logo/font URLs and Studio below are Phase 06 targets, not this packet's behavior. -G42 still selects safe HTML injection before P02d-6. +G42 accepts atomic four-color validation and fixed server-generated CSS. A tenant's branding flows from the API as design tokens, and the renderer applies them as CSS custom properties in the SSR'd page. The variable names are the `--ls-*` set @@ -198,10 +243,12 @@ names and the shared Tailwind preset reads; this document keeps no second vocabu The accepted [Tenancy contract](../modules/tenancy/README.md#whole-theme-setting-and-public-boundary) owns P02d-2's admitted tokens and values. P02d-4 accepts their public projection and attribution rule; Step 2 delivers this API projection. -How the tokens reach the document, and how that mechanism stays compatible with the -nonce-based policy that -[Security Standards § HTTP Headers](../standards/11-security.md#http-headers) sets as -the target, is G42 in the same register. +The emitter revalidates the complete `#rrggbb` palette before producing only four +fixed color properties in a style element. A null/malformed value retains the +entire existing CSS default palette; no per-token merge, style attribute, URL or +font is admitted. +[Standards 07](../standards/07-frontend-architecture.md#tenant-branding) owns the exact +tokens. Output is safe without CSP; Phase 11 owns its delivery. In Phase 06's planned organization override, when the request carries an organization id and that organization has a @@ -366,6 +413,13 @@ harness and assertions. The decision is recorded in [Testing Standards § End-to-End Tests](../standards/06-testing.md#end-to-end-tests) names the owning phase. - Keyboard navigation and focus order are reviewed before any block ships. +- Accepted P02d-6/G38(b,c)/G43 requires actual product HTML/DOM proofs and applicable + jsx-a11y error rules with planted controls. Manual keyboard, focus, reflow/zoom, + contrast and real screen-reader evidence for catalog → course → lesson names + commit, environment and both hosts/locales. These remain pending, as does packet + completion until the manual evidence exists. See + [Testing Standards](../standards/06-testing.md#frontend-test-types); HTTP/RSC + proofs do not claim browser E2E or full Playwright/axe delivery. - Color contrast is verified for every branded theme, including the merged tenant and organization token set, per [Accessibility Standards § Color and Contrast](../standards/16-accessibility.md#color-and-contrast). diff --git a/docs/architecture/32-tenant-customization-model.md b/docs/architecture/32-tenant-customization-model.md index 9f84a860..f01f9b14 100644 --- a/docs/architecture/32-tenant-customization-model.md +++ b/docs/architecture/32-tenant-customization-model.md @@ -52,14 +52,22 @@ types opt in; the parser and exact reader validate metadata, and legacy schemas valid. Browser rendering belongs to P02d-6. The wider renderer set below is a target, not implemented Phase 02d coverage. P02d-6 implements only the approved subset. -> **Accepted subset; renderer placement remains open.** ADR-0051 selects plain-string -> text cards with authored order and labels; Phase 02d adds no Markdown or active sink. -> ADR-0018's wider closed set below is unchanged and is not implemented coverage. -> G41 still decides unsupported-content fallbacks and component placement. The folder -> in the sketch does not exist; the key registry in -> `frontend/apps/web/src/lib/customization/` does not settle that gate. See +> **P02d-6 G41 Accepted — 2026-10-09; implementation pending.** ADR-0051 selects +> ordered plain-string `default-card` fields with authored language-bearing labels. +> Synchronous views live in `frontend/apps/web/src/components/public/`. Unsupported +> renderers/unresolved presentation use bounded placeholders, with no raw JSON, +> HTML, Markdown, linkification, active URL sink or richer primitive. ADR-0018's +> wider closed set below remains a target; registry keys are not implementation. +> The sketch's primitive folder does not exist. See > [Phase 02d's register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +P02d-6 omits absent optional fields and renders script/HTML/URL-like values as +escaped inert text. Empty ready content differs from unavailable presentation; +unknown/unavailable content emits at most one count/state diagnostic per request, +without field values, keys or private context. Exact schema swaps and inert-value +proofs use unchanged product routes. Integer/boolean/enum/taxonomy and richer +primitive expansion remain Phase 04/05 and Phase 06, not this accepted subset. + The frontend ships a **fixed, closed set** of primitive renderers: ```typescript diff --git a/docs/decisions/0027-frontend-i18n.md b/docs/decisions/0027-frontend-i18n.md index 290dc868..d45d1ce7 100644 --- a/docs/decisions/0027-frontend-i18n.md +++ b/docs/decisions/0027-frontend-i18n.md @@ -2,14 +2,15 @@ ## Status -Proposed — maintainer approval pending; implementation has not started. +Accepted — 2026-10-09. **Date:** 2026-10-09 **Deciders:** @cemil -This uses the reserved frontend-i18n number. It proposes closing P02d-6 G39; -it closes no gate until accepted. Phase 04's existing reservation remains binding -until acceptance moves the first-consumer commitment to P02d-6. The +The maintainer accepted this reserved frontend-i18n decision and P02d-6 G39 before +implementation. At acceptance, no implementation is delivered; later delivery is +recorded in dated amendments. P02d-6 owns the first consumer, and Phase 04 consumes +that foundation. The [P02d-6 decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09) owns the page plan and approval boundary. @@ -30,7 +31,7 @@ owns the page plan and approval boundary. ## Considered Options -1. **next-intl, server-first, without its routing middleware** (recommended). +1. **next-intl, server-first, without its routing middleware** (chosen). Direct async App Router APIs fit the first consumers. Routing remains owned by the verified public entry, with one app-local request configuration. 2. **react-intl / FormatJS**. A viable ICU and React alternative, including its @@ -44,7 +45,7 @@ owns the page plan and approval boundary. the first plural/select messages, error states and translation checks would create a temporary parallel runtime at the first real consumer. -These are alternatives to the proposed decision, not assertions that the other +These are alternatives to the decision, not assertions that the other libraries cannot support Server Components. Reconsider the selection if a pinned production-build proof fails, its licence changes incompatibly, or actual Studio requirements demonstrate a material gap in extraction or formatting. @@ -57,8 +58,8 @@ public entry owns host and content-locale admission; the library neither resolve tenants nor redirects requests. UI catalogue fallback never changes the admitted content locale. -This is the proposed decision. Acceptance authorizes the contracts below; -it does not claim implementation or test delivery. +Acceptance authorizes the contracts below; it does not claim implementation or +test delivery. ## Context @@ -70,7 +71,7 @@ Phase 04 reservation. The documentation currently sketches `packages/i18n`, `apps/web/locales` and `apps/web/src/i18n/<locale>/<namespace>.json`, with differing namespace layouts in -the frontend skills. None exists as an implemented catalogue. The proposed single +the frontend skills. None exists as an implemented catalogue. The single home is: ```text @@ -112,7 +113,7 @@ middleware-derived `requestLocale` or use a callsite locale override as authorit It adds no locale header, i18n middleware, rewrite, cookie or `Accept-Language` selection. Route params and observed query data cannot replace the signed source. -The proposed `/{locale}/status/not-found` namespace uses that same live admission; +The accepted `/{locale}/status/not-found` namespace uses that same live admission; it never loads Education content. Admitted `/studio` and `/portal` scaffolds have no content locale and retain platform English until Phase 06 supplies their UI. A failed admission cannot select a tenant document through English fallback. @@ -218,8 +219,8 @@ configuration do not cross into an unnecessary client payload. ## Implementation Notes -Acceptance moves ADR-0027 to Active ADRs with P02d-6 as its first-consumer gate. -The same decision commit reconciles these carriers: +The acceptance commit moves ADR-0027 to Active ADRs with P02d-6 as its first-consumer +gate and reconciles these carriers: - [Standards 03](../standards/03-frontend-coding.md) for UI keys/checks, [07](../standards/07-frontend-architecture.md) for the `packages/i18n` sketch and @@ -237,19 +238,26 @@ The same decision commit reconciles these carriers: for the same catalogue home, namespace grammar and server request integration. Phase 04 consumes the installed foundation and still owns its CMS/Studio message -coverage. Proposed status does not make these installation or acceptance claims. +coverage. The decision commit makes no installation or implementation claim. P02d-6 implements catalogues, server configuration, bounded interactive labels and the checks above. Each implementation step follows the packet's commit and two -independent review rounds. This Proposed file changes no Accepted ADR body. +independent review rounds. This acceptance changes no previously Accepted ADR. ## Architecture Tests -The obligations above are proposed proof requirements, not registered or passing -test names. The acceptance commit registers their non-skippable names and planted -controls in a new **P02d-6 public UI localization controls** section of -[Standards 21](../standards/21-architecture-tests-catalogue.md#how-to-add-an-entry), -before implementation. Status becomes Implemented only with the actual checks. +The acceptance commit registers these non-skippable rules with planted controls +in [Standards 21](../standards/21-architecture-tests-catalogue.md#p02d-6-public-ui-localization-controls), +before implementation: + +- [Ui_Catalogues_Cover_Public_Call_Sites](../standards/21-architecture-tests-catalogue.md#ui_catalogues_cover_public_call_sites) +- [Public_Ui_Locale_Does_Not_Change_Content_Admission](../standards/21-architecture-tests-catalogue.md#public_ui_locale_does_not_change_content_admission) +- [Public_Theme_Emits_Only_Validated_Color_Tokens](../standards/21-architecture-tests-catalogue.md#public_theme_emits_only_validated_color_tokens) +- [Public_Pages_Preserve_Approved_Response_States](../standards/21-architecture-tests-catalogue.md#public_pages_preserve_approved_response_states) +- [Public_Pages_Expose_Localized_Accessible_Semantics](../standards/21-architecture-tests-catalogue.md#public_pages_expose_localized_accessible_semantics) + +All five are Registered at acceptance, not passing implementation claims. Status +becomes Implemented only with the actual checks. Existing [public source-boundary controls](../standards/21-architecture-tests-catalogue.md#p02d-5-public-server-rendering-controls) and [No_Architecture_Test_Is_Skippable](../standards/21-architecture-tests-catalogue.md#no_architecture_test_is_skippable) remain mandatory; no second skip-refusal rule is created. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 1f6f9d38..d0ecdf11 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -46,6 +46,7 @@ an amendment is not a lifecycle status change. | 0022 | [Custom Domain & TLS](0022-custom-domain-tls.md) | Hub-owned custom domain admin; DNS-01 + HTTP-01 + Let's Encrypt; APISIX hot-reload | | 0023 | [Strongly-Typed ID Source Generator — Vogen](0023-strongly-typed-id-source-generator.md) | Vogen as the source generator for both IDs and value objects; `[ValueObject<Guid>]` annotation; EF + JSON + ASP.NET + OpenAPI emitters out of the box (Amendment 1: Vogen 7.0.0 pin + architecture-test placement, 2026-05-21; Amendment 2: `UserId` / `TenantId` / `OrganizationId` are cross-cutting and live in `LearnStack.SharedKernel`, 2026-08-10; **Amendment 3: `IStronglyTypedId` gains `IsInitialized()` and is no longer a pure marker, 2026-08-10**; Amendments 4–7, 2026-08-27 to 2026-09-01: `idempotency_keys` mints no id, `gen_uuid_v7()` is not a PostgreSQL function, the retroactive disclosure of the 2026-05-21 edit, and an id's own formatting is not a wire format; **Amendment 8, 2026-09-07: a module-local identifier crosses a cross-module contract as `Guid`**) | | 0024 | [API Versioning Policy](0024-api-versioning-policy.md) | URL-based `/v{N}/`; 6-month deprecation window; RFC 8594 `Sunset` + `Deprecation` headers; OpenAPI `deprecated` + `x-sunset` extensions; 410 Gone with RFC 7807 on sunset | +| 0027 | [Frontend UI Localization with next-intl](0027-frontend-i18n.md) | Accepted 2026-10-09: next-intl 4.14.9, server-first app-local ICU catalogues, dotted UI keys and verified request-locale source; P02d-6 first consumer, implementation pending; Phase 04 consumes the foundation | | 0028 | [`audit_log` Partition Management — Hangfire Recurring Job](0028-audit-log-partition-management.md) | Daily `learnstack:audit:partition-management` Hangfire job; create-ahead 2 months; drop only on platform-max retention horizon; row-level purge separate; no `pg_partman` dependency (Amendment 2026-09-07: Packet 9 creates a **plain, unpartitioned** table and executes none of this ADR's DDL; `Partition_Manager_Job_Is_Registered_AtStartup` moves to Phase 11 with the job it guards) | | 0029 | [Object Storage — SeaweedFS](0029-object-storage-seaweedfs.md) | Self-hosted SeaweedFS behind the existing `IStorageProvider` S3 contract; partially supersedes ADR-0002's MinIO row | | 0030 | [Redis-compatible Store — Valkey](0030-redis-compatible-store-valkey.md) | Valkey (Linux Foundation, BSD-3-Clause) for the cache + Dapr state-store backend; RESP-protocol drop-in; partially supersedes ADR-0002's Redis row | @@ -74,7 +75,6 @@ an amendment is not a lifecycle status change. | # | Title | Topic | Target phase / decision point | |---|---|---|---| -| 0027 | [Frontend UI Localization with next-intl](0027-frontend-i18n.md) | Proposed server-first UI runtime and one catalogue home; G39 remains open | Proposed first consumer P02d-6, approval before implementation; existing Phase 04 exit SLA remains until acceptance | | 0049 | [Institution Sites and an Optional Course Marketplace](0049-institution-sites-and-course-marketplace.md) | Direction endorsed 2026-10-02; architecture still Proposed, no gate accepted | P02d-2 preparation hold released; remaining contracts before proposed Phase 09a's first consumers | The maintainer endorsed the marketplace direction and accepted ADR-0050/0051 and @@ -145,9 +145,9 @@ table records the phase commitment so reviewers can flag late drafts. draft lands, take its reserved number; do not let another ADR claim it. If the decision is dropped, leave the number unused — never recycle. -ADR-0027 is now drafted in Proposed ADRs. Its proposed first consumer is P02d-6; -only maintainer acceptance moves the existing Phase 04 commitment to that packet. -The draft itself neither installs a dependency nor closes G39. +ADR-0027 is Accepted on 2026-10-09 and appears in Active ADRs. P02d-6 now owns +its first consumer and G39 is closed; Phase 04 consumes that foundation. The +acceptance record does not claim dependency installation or implementation. **SLA:** any draft whose target phase is currently in progress without an Accepted ADR is a blocker on the phase exit checklist for that phase. The roadmap's Phase Exit diff --git a/docs/glossary.md b/docs/glossary.md index 7b411f68..bea2f92d 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -294,6 +294,7 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | **`Result<T>`** | The sealed record in `LearnStack.SharedKernel.Results` implementing `IResultBase`: carries `IsSuccess`, `Value`, optional `Error`, and an optional `SuccessMessage` (a `LocalizedMessage`). Primary constructor is `internal`; callers go through `Ok` (throws on null value) / `Fail`. Application + Domain layer methods return `Result<T>` for **expected** outcomes (validation failed, not found, forbidden, business-rule violation). Exceptions are reserved for **unexpected** bugs / infrastructure faults. `Result<None>` is the payload-less success shape. See [09-error-handling.md § Two-Track Model](standards/09-error-handling.md) and [ADR-0032](decisions/0032-exception-handling-logging-and-observability.md). | | **`Error`** | The sealed record `(LocalizedMessage Message, IReadOnlyDictionary<string, IReadOnlyList<LocalizedMessage>>? Details)` that travels inside `Result<T>.Fail(error)`. `Code` is the **unprefixed stable identifier** (e.g. `"validation_failed"`) derived by stripping the `lockey_` prefix from `Message.Key` — so routing logic (`Result.ToActionResult()`, Problem Details writers) and frontend localization read consistent values without manual sync. Field-level `Details` flow as `LocalizedMessage` lists, keeping the prefix invariant uniform across the entire error payload. See [09-error-handling.md § Result Type](standards/09-error-handling.md). | | **`LocalizedMessage`** | The sealed record `(string Key, IReadOnlyDictionary<string,string>? Params)` in `LearnStack.SharedKernel.Localization`. `Key` MUST begin with `lockey_`, enforced at construction; this is the backend-message wire namespace, distinct from general frontend UI identifiers. Consumers own supported error resources; the SDK normalizes errors and contains no translations. See [Error Handling § Mapping Problem Details → UI](standards/09-error-handling.md#mapping-problem-details--ui). Equality is structural (Key + Params); ctor defensively copies Params; empty params normalised to null. `Params` values must be plain text — resolved messages use React text nodes, never `dangerouslySetInnerHTML`. Per [Phase 02a Packet 2](roadmap/phase-02a-kernel-tenancy.md). | +| **UI Catalogue** | App-owned platform messages under `frontend/apps/web/src/i18n/messages/<locale>/<namespace>.json`. Accepted [ADR-0027](decisions/0027-frontend-i18n.md) selects exact next-intl 4.14.9 and complete `en`/`tr` `public` catalogues for P02d-6; implementation remains pending. General UI identifiers are dotted feature keys, distinct from backend `lockey_*`. Whole-catalogue language fallback never changes admitted content locale; missing used keys in supported catalogues fail validation. The SDK contains no translations. | | **`None`** | The `readonly record struct` value used as `Result<None>` when a command/query succeeds without returning data — replaces the `Result<T>` with `IsSuccess = true` and `Value = null` shape Standards 09 § Forbidden bans. | | **`IClock` / `IRandom` / `IGuidFactory`** | The three deterministic-test abstractions in `LearnStack.SharedKernel`. Production code never reads `DateTime.UtcNow`, instantiates `System.Random`, or calls `Guid.NewGuid()` directly — those calls go through the abstractions so tests pin the values via `FixedClock` / `FixedRandom` / `FixedGuidFactory`. Per Standards 02 § Time. | | **`UserId`** | The cross-cutting strongly-typed actor identifier in `LearnStack.SharedKernel.Identifiers` (Vogen `[ValueObject<Guid>]`). Audit columns on `AuditableEntity<TId>` reference users by `UserId` so the "no raw `Guid` on the public surface" rule (Standards 02) holds even though the Identity module lands in Phase 02b. Identity consumes the same type when it ships. | diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index d5aaac01..86215e71 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -14,7 +14,7 @@ > | 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 | not started | +> | P02d-6 | Public renderer | in progress — decision pass Accepted 2026-10-09; [decision package](#p02d-6-decision-package-2026-10-09); implementation pending | > | P02d-7 | Demo, full-stack CI and exit | not started | **Acceptance update — 2026-10-02.** P02d-1 remains merged. The maintainer accepted @@ -112,6 +112,13 @@ review. All P6 gate parts remain unaccepted. The package explicitly proposes the 404 navigation/status tradeoff exposed by the pinned framework experiment; implementation waits for approval, not an assumed framework guarantee. +**P02d-6 acceptance — 2026-10-09.** The maintainer approved revised ADR-0027 and +the complete [decision package](#p02d-6-decision-package-2026-10-09), including +the local 307→404 URL change, HTTP 200/noindex content-failure states and four +implementation steps. The acceptance commit closes its named gate parts and +reconciles their carriers before code. Every step uses commits and two fresh +independent review rounds. Development remains the working branch. + ## Goal Put a working education site in a browser — twice, on two hosts, for two tenants in @@ -346,10 +353,9 @@ Decisions made or referenced in this phase: (lesson-body validation on the command path; no compiled-validator cache) - [ADR-0045 Entitlement and Feature-Flag Socket](../decisions/0045-entitlement-and-feature-flag-socket.md) (`tenancy.white_label_branding`, whose meaning G16 asks) -- **Proposed [ADR-0027](../decisions/0027-frontend-i18n.md)** — the frontend i18n - library; proposed first consumer P02d-6, existing Phase 04 exit SLA in - [decisions/README § Open ADR Drafts](../decisions/README.md#open-adr-drafts); G39 asks - whether this phase's renderer Accepts it +- **Accepted [ADR-0027](../decisions/0027-frontend-i18n.md)** — the frontend i18n + library and catalogue contract; P02d-6 first consumer, accepted with G39 on + 2026-10-09. Phase 04 consumes the foundation; implementation is separate. ## Scope @@ -398,22 +404,22 @@ premise a row cites is re-verified at that pass rather than trusted. | G2 | Which aggregate does `Lesson` belong to, and what is its parent: an entity inside `Course`, its own root referencing `Course`, or a minimal `CourseVersion` and default `Module` now? With it: how the satellites are mapped (base type, markers, `deleted_at`), the `sort` invariant and tie-breaker, and what the shape obliges Phase 05 to preserve — course and lesson ids, published slugs, order, organization scope, the inline body | The reviews split. One brings the version spine forward so Phase 05 enriches rather than re-parents; two keep this phase thin and record the preservation obligations, with Phase 05 designing the move. Between the thin shapes: inside `Course` means `ON DELETE CASCADE` and one audit row, but a lesson edit mutates `Course` structurally, which [Domain Model § Education Catalog](../architecture/02-domain-model.md#education-catalog) says a published course never is; its own root means `RESTRICT`, its own `row_version` and its own audit subject | Contract: a dated phase-doc statement; no Accepted ADR holds the `Course` / `CourseVersion` hierarchy, so a new ADR only if the answer needs a cross-root write ([ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md)). Detail: the Education spec's data model; an interim note in [Domain Model § Learning Content](../architecture/02-domain-model.md#learning-content) where the answer departs from it; the class count in [Database Standards § Foreign keys between tenant-owned tables](../standards/05-database.md#foreign-keys-between-tenant-owned-tables) if `Lesson` cascades | P02d-1 (the lessons foreign-key target, `ON DELETE`, `row_version`, the root mapping and satellite `deleted_at`) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G2 | | G3 | Which of `courses` and `lessons` carry a publication state, with which values and transitions, and what does "published" mean to an anonymous reader — publicly readable, or only listed? Which command sets it, which states does the seed write, and may a course with no lessons, or untranslated in an enabled locale, be published? And for any transition or deletion this phase does not ship (unpublishing a course or lesson, deleting either), which phase owns it? | `courses` `draft` / `published`, meaning publicly readable (Phase 05 adds catalog visibility as its own concept); lessons carry a state and show only when both are published; draft → published only; an empty course may be published, since publish validation is Phase 05's. One review leaned "listed in the catalog" | Contract: a new ADR, or a dated phase-doc statement recording why a two-value, one-transition column is not the state machine Decision Timing reserves for a decision record; no Accepted ADR decides publication ([ADR-0018](../decisions/0018-tenant-driven-customization-model.md) reserves the lifecycle to LearnStack). Detail: the `CHECK` ([Database Standards § Constraints](../standards/05-database.md#constraints)), the Education spec's state diagram, the publish row [Audit Coverage Standards](../standards/18-audit-coverage.md) makes MUST | P02d-1 (column presence and value set), P02d-2 (publishing commands and seeded states; transition contract closed in P02d-1) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): commands and seed states; independent lifecycle retained. Anonymous access superseded by [ADR-0050 / dated G3 record](#g3-supersession-2026-10-02); original P02d-1 answer retained as history | | G4 | Where does a lesson body's binding to the content-type key and `schema_version` it was validated against live — on `lessons` or on each translation row — and where does the body live: its column, type, per-locale placement, and how non-translatable field values are carried? May a constraint cross into the Customization chain? What becomes of Localization Standards' `isLocalized` marker, which nothing implements? | A value pin `(content_type_key, schema_version)` on `lessons`, as Phase 04 plans for `ContentEntry`, with no foreign key; the field document per locale in `lesson_translations`, every locale validated against the one pin, duplicated non-translatable values accepted until Phase 05's lesson items retire them; the marker removed or given its introducing phase | Detail: this document's § Localization schema, the Education spec, [Localization Standards § Pattern A](../standards/08-localization.md#pattern-a--side-translation-table-default-for-content-shaped-entities) in the same diff. Contract: a dated ADR-0043 amendment if a localization keyword enters the schema profile; its own ADR or amendment if a cross-chain foreign key is chosen, as ADR-0044 § 9 did, with Phase 04 and [Database Standards § Migrations](../standards/05-database.md#migrations) in the same diff | P02d-1 (the first `lessons` and `lesson_translations` DDL; a pin added later needs a backfill that guesses between two Active content types) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G4 | -| G5 | Before Phase 05's `Level` exists, how does a course or lesson carry the level band criterion 1 shows? Does the reference pin a taxonomy revision, how is a band validated on write, and what renders when the resolved revision no longer declares the stored band? | The reviews split: (a) a nullable, non-translatable `(taxonomy_key, band_key)` on `courses`, resolved against the live revision, because the criterion names the catalog; (b) a revision-pinned triple; (c) no column, the band shown through a lesson-page `x-taxonomy` field, with the criterion reworded. No shipped path validates a band value under any of them | Detail: a phase-doc statement, the Education spec, and a Phase 05 inherited row if a reference ships. Contract: a dated ADR-0010 amendment or a new ADR if an Education table takes a foreign key into Customization | P02d-1 (whether and where a column exists), P02d-2 (validation, seeded references), P02d-4 and P02d-6 (the unresolved-band state) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): column; [Accepted — 2026-10-02](#p02d-2-accepted-answers): validation and seeded references; [Accepted — 2026-10-03](#p02d-4-accepted-answers): unresolved public-band response. Renderer behavior remains P02d-6 | +| G5 | Before Phase 05's `Level` exists, how does a course or lesson carry the level band criterion 1 shows? Does the reference pin a taxonomy revision, how is a band validated on write, and what renders when the resolved revision no longer declares the stored band? | The reviews split: (a) a nullable, non-translatable `(taxonomy_key, band_key)` on `courses`, resolved against the live revision, because the criterion names the catalog; (b) a revision-pinned triple; (c) no column, the band shown through a lesson-page `x-taxonomy` field, with the criterion reworded. No shipped path validates a band value under any of them | Detail: a phase-doc statement, the Education spec, and a Phase 05 inherited row if a reference ships. Contract: a dated ADR-0010 amendment or a new ADR if an Education table takes a foreign key into Customization | P02d-1 (whether and where a column exists), P02d-2 (validation, seeded references), P02d-4 and P02d-6 (the unresolved-band state) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): column; [Accepted — 2026-10-02](#p02d-2-accepted-answers): validation and seeded references; [Accepted — 2026-10-03](#p02d-4-accepted-answers): unresolved public-band response. [Accepted — 2026-10-09](#p02d-6-accepted-answers): renderer state | | G6 | Locale identity on the content path. (a) What spelling and column type do the satellites' `locale` columns store, and which rule replaces Localization Standards' "Lowercase", which the shipped `LocaleTag` does not follow? (b) Is the `locale` parameter canonicalized before lookup, the membership check and every cache or cursor key, or is a non-canonical spelling refused? (c) What does a non-canonical `/{locale}/` segment get? | (a) `LocaleTag`'s canonical case (`tr-TR`, `zh-Hans`) in `varchar(35)`, as `tenant_locales` stores it — [ADR-0018](../decisions/0018-tenant-driven-customization-model.md)'s 2026-09-04 amendment already makes case variants one locale; (b) well-formedness, then canonicalization, then lookup; (c) a redirect to the canonical segment, decided with G36 | Detail: [Localization Standards § Locale Codes](../standards/08-localization.md#locale-codes) and the Database Standards satellite fence in the same diff. No ADR: ADR-0008 states no casing rule | P02d-1 (a: the first stored rows), P02d-4 (b: validators, cursor binding), P02d-5 (c, with G36) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): (a); [Accepted — 2026-10-03](#p02d-4-accepted-answers): (b); URL-segment redirects (c) remain P02d-5; [Accepted — 2026-10-08](#p02d-5-accepted-answers): (c) URL redirects; prior (a,b) answers retained | | G7 | Organization write scope. (1) Does a lesson carry its course's organization scope? (2) What forces a satellite's — and a lesson's — mirrored `organization_id` to equal its parent's at insert: writer derivation alone, or that plus a database backstop, and which? (3) May an organization-scoped session `INSERT` a tenant-wide row through the `organization_id IS NULL` arm of `WITH CHECK`, which [ADR-0003](../decisions/0003-tenant-isolation-defense-in-depth.md)'s Amendment 5 and Database Standards say it cannot and which it can at `HEAD`? | (1) Identical scope for a course, its lessons and every translation. (2) Writers derive the child's organization from the authorised parent; the reviews split on the backstop — a stored generated scope column with an organization-inclusive composite key, which structural sweeps can see, or a `BEFORE INSERT` trigger reading the parent under the caller's policies — and one review requires database enforcement. A nullable three-column key is already excluded, because `MATCH SIMPLE` skips the check. (3) Tighten, after the pass confirms no audit writer composes a null-organization row under an announced organization | Contract: one dated ADR-0003 amendment for (2) and (3), with an ADR-0041 erratum beside any sentence the pass finds false when it entered the record; the template replaced in place in [Database Standards](../standards/05-database.md) with its disclosure; forward migrations for `tenant_settings` and `audit_log` if (3) tightens. Detail: [Database Standards § Translation satellite tables](../standards/05-database.md#translation-satellite-tables); a catalogue row with a planted offender if a database mechanism is chosen | P02d-1 (policy SQL, the generated column or trigger, aggregate factories), P02d-2 (child derivation in the commands) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): database controls and factory derivation; [Accepted — 2026-10-02](#p02d-2-accepted-answers): command derivation | | G8 | Which structural guards does the Education chain register, so its tables cannot regress with the suite green: every foreign key between two tables carrying `tenant_id` includes it; every table carrying `organization_id` has the immutability trigger (and how `audit_log`'s append-only guard counts); the Pattern A rule, which would make [ADR-0008](../decisions/0008-localization-schema.md)'s "the migration linter rejects ad-hoc per-locale columns" true? And how does `fn_organization_id_immutable` — which reads `OLD.id` and is declared only in the Tenancy chain — serve satellites that have no `id`? | Three rows, each with a planted-offender companion; the function replaced by a Tenancy-chain migration that reports `OLD.organization_id` or reads the row key through `to_jsonb(OLD)`, which (as in the audit append-only guard's row comparison) never names a column the table may lack, with the cross-chain dependency recorded under Database Standards § Migrations | Detail: Standards 21 rows Registered and Implemented in the packet; the Database Standards immutability fence and § Migrations; `MigrationRollbackTests`. Contract, only if ADR-0008's sentence is left untrue: an ADR-0041 erratum if it was false when entered, otherwise a dated amendment | P02d-1 (a guard shipped with its first new subject is the only point its companion is written against real tables) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G8 | | G9 | Education schema detail: the content slug's character shape, normalization, width and database backstop — including whether a GUID-shaped slug is refused, which G26's shared-slot path needs; whether an Education table holds a foreign key into `tenants`, `organizations` or `tenant_locales`; and each runtime role's privileges on the four tables | `UrlSlug`'s shape with its own width constant and a `ck_<satellite>_slug_format` backstop, since restrictive now is the reversible choice (ASCII-only slugs exclude native-script URLs, a product choice); no foreign key into Tenancy; `learnstack_app` `SELECT, INSERT` plus exactly what G11's commands need, `learnstack_platform` `SELECT` | Detail: Localization Standards § Pattern A for the shape; the Database Standards satellite fence and [§ GRANT matrix](../standards/05-database.md#grant-matrix); § Migrations only if a cross-chain key is chosen | P02d-1 (the creating migration writes the `CHECK` and the grants; the grants couple with G11) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G9 | | G10 | What is the catalog's default order and tie-breaker, and what is the cursor it mints: its payload and version; what it binds (tenant, organization, locale, sort, filters, endpoint); its integrity (none, a MAC with a key version, or server-side state); its direction; what happens when a row changes between pages; which list parameters the endpoint binds; where it is decoded; whether the codec is this endpoint's or the kernel's; and which cursor classes answer `400`? | The reviews split between a keyless versioned payload with a binding fingerprint, decoded at binding so a garbage cursor opens no transaction, and an HMAC-authenticated cursor with key rotation. Both keep tenant and organization out of the cursor, and bind `CursorPaginationRequest` rather than `ListRequest`, whose `q` is Phase 04's search | Contract: a phase-doc statement if the codec is endpoint-local and keyless; a new ADR if it becomes a kernel rule later lists follow, or a MAC adds a secret and a rotation posture. Detail: [API Standards § Pagination](../standards/04-api-design.md#pagination), which drops "Nothing validates its *shape* yet"; Standards 21 rows | P02d-1 (the order part: an ordering column, publication timestamp or collation), P02d-4 (the codec part) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): order; [Accepted — 2026-10-03](#p02d-4-accepted-answers): resource-local cursor codec | | G11 | The write surface the seed needs. Which Education commands write courses, lessons and their translations; is a translation written separately from create; is publishing its own command; which command reports a slug collision as `business_rule_violation` rather than a raw unique violation, and does Localization Standards' "from the publish command" still hold? What shape do the Tenancy commands raising `tenancy.locale.write` and `tenancy.setting.write` take? How are the non-baseline writes classified, and how does a re-run converge? | Create course, write course translation, add lesson, write lesson translation, publish course (MUST); one locale command over `Tenant.AddLocale` and `SetDefaultLocale`; a create-or-update setting command keyed on context scope and key; ordering taxonomy → content type → course → lessons; idempotent by conflict, with an ownership check per act and a second-run test. None has a route | Contract: a phase-doc statement plus the Education spec (README write sequence, `audit.md`, `permissions.md` as a forward declaration on [the Tenancy precedent](../modules/tenancy/permissions.md)). Detail: catalogue sources, the Tenancy `audit.md` and `permissions.md`, Localization Standards § Pattern A if the collision sentence changes. An ADR only if a handler must write two roots | P02d-2 (commands, handlers, catalogue sources, seeder acts) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): commands, failures, audit classifications and convergence | -| G12 | Through which `Customization.Application.Contracts` surface does an Education write obtain the schema a body is validated against — exact `(key, schema_version)` including Deprecated revisions, or a key that binds the Active one — and is it an interface or a MediatR query, classified how? Which revisions may a writer bind, and what refusal answers an absent, cross-tenant or ineligible one? On the read side: what the cache keys on, whether the lesson response carries the binding or resolved field descriptors, and what the API and the page show when a binding cannot be resolved | One exact-revision query, Deprecated included, never falling back to Active; only Active revisions bindable for new writes, since a Draft's body can still change; absent and cross-tenant refused indistinguishably as `validation_failed` naming the binding; resolved descriptors in the response; an unresolvable binding shows a bounded placeholder with a warning log, never a `500` and never another revision's fields ([ADR-0013](../decisions/0013-page-block-schema-versioning.md)'s placeholder rule) | Detail: the Customization spec's contract and § Primary read flow, the Education spec's invariants, a phase-doc statement. No ADR: ADR-0010 settles the mechanism. A dated ADR-0013 amendment only if the unresolvable outcome departs from the placeholder rule | P02d-2 (the contract and write eligibility: the lesson writer is its first caller), P02d-3 (the cache key), P02d-4 (descriptors, the unresolvable outcome), P02d-6 (the page state) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): contract and write eligibility. [Accepted — 2026-10-02](#p02d-3-decision-package-2026-10-02): cache key; [Accepted — 2026-10-03](#p02d-4-accepted-answers): public response; page behavior remains P02d-6 | +| G12 | Through which `Customization.Application.Contracts` surface does an Education write obtain the schema a body is validated against — exact `(key, schema_version)` including Deprecated revisions, or a key that binds the Active one — and is it an interface or a MediatR query, classified how? Which revisions may a writer bind, and what refusal answers an absent, cross-tenant or ineligible one? On the read side: what the cache keys on, whether the lesson response carries the binding or resolved field descriptors, and what the API and the page show when a binding cannot be resolved | One exact-revision query, Deprecated included, never falling back to Active; only Active revisions bindable for new writes, since a Draft's body can still change; absent and cross-tenant refused indistinguishably as `validation_failed` naming the binding; resolved descriptors in the response; an unresolvable binding shows a bounded placeholder with a warning log, never a `500` and never another revision's fields ([ADR-0013](../decisions/0013-page-block-schema-versioning.md)'s placeholder rule) | Detail: the Customization spec's contract and § Primary read flow, the Education spec's invariants, a phase-doc statement. No ADR: ADR-0010 settles the mechanism. A dated ADR-0013 amendment only if the unresolvable outcome departs from the placeholder rule | P02d-2 (the contract and write eligibility: the lesson writer is its first caller), P02d-3 (the cache key), P02d-4 (descriptors, the unresolvable outcome), P02d-6 (the page state) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): contract and write eligibility. [Accepted — 2026-10-02](#p02d-3-decision-package-2026-10-02): cache key; [Accepted — 2026-10-03](#p02d-4-accepted-answers): public response; [Accepted — 2026-10-09](#p02d-6-accepted-answers): page state | | G13 | May an Education translation be written for a locale absent from, or disabled in, `tenant_locales`, and how is membership checked across the module boundary? Does a read resolve under a disabled locale? What does a tenant with no locale rows serve — [Localization § Tenant Locale Configuration](../architecture/12-localization.md#tenant-locale-configuration) promises platform `en`, and nothing implements it? Does a platform registry bound the enabled set, as Localization Standards names one in a namespace that does not exist? What happens to translations when `RemoveLocale` runs? | A Tenancy application contract checks membership on write; a read resolves only an enabled locale, checked once per request; no cross-chain foreign key; no platform registry in this phase; a tenant with no locale rows serves nothing until it has one | Contract: a phase-doc statement over ADR-0010's application-contract mechanism. Detail: the Tenancy and Education specs; Localization architecture and Localization Standards § Locale Model reconciled in the same diff; Database Standards § Migrations only if a key is chosen | P02d-2 (the translation command's check and the locale command the seed uses; the read half is written to the same answer in P02d-4) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): write eligibility, no platform registry and no implicit no-row locale; public reader implementation remains P02d-4 | | G14 | Seed inventory. At what scope is each seeded row class written — courses, lessons, translations, branding settings — and from what seeder context, given that `SeedTenantContext` requires an organization? Where do the rows the criteria need live — a sibling-organization course, an organization-scoped course on the tenant host, a `(locale, slug)` held in both tenants, draft and wrong-course rows, more courses than one catalog page, a disabled locale holding translations — `make seed` or test-owned data? Which key the yoga taxonomy uses, which tenant is bilingual, what state do the built-in `card` / `plain` keep, which record holds it all, and how do the Packet 7 fixture's raw settings rows coexist with seeded ones? | English content tenant-wide; the yoga studio gets a tenant-wide, a Studio One and a Studio Two course; a seed context that announces no organization; branding tenant-wide; rows in the seed with `SeedData` as the record; built-ins stay Active and are never selected implicitly; expectations recomputed as enumerated sets. An English organization-scoped row is still needed for the tenant-host criterion, seeded or test-owned — the demo database's contents are the owner's preference | Detail: a phase-doc statement, the `SeedData` remarks, the `seed-tenant` skill, the writers delivery record. No ADR: [Security Standards § Forbidden](../standards/11-security.md#forbidden) already makes scope come from context | P02d-2 (seeder steps, the seed-context constructor, `SeedData`, `SeederTests`; moving placement later rewrites the seed and every request-level case) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): inventory, ownership and test-owned controls | | G15 | `SeedRunner` calls `IUnitOfWork.SetTenantContextAsync` on its own transaction, and neither [ADR-0040](../decisions/0040-ambient-unit-of-work.md)'s closed setter set nor [Security Standards § The out-of-band setters](../standards/11-security.md#the-out-of-band-setters) lists it. Is that method's caller set mechanically closed, and is the seeder's call reconciled by routing its ownership check through `ISender`, or by admitting the seeder? | Route the ownership check through `ISender`, and add a source scan that admits `TransactionBehavior` (and Phase 02b's transport) with a planted offender | Contract: a dated ADR-0040 amendment plus a setters-table row only if the seeder is admitted. Detail: a Standards 21 source-scan row with its companion | P02d-2 (the Education seed acts reach the ownership check's refusal arm today) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): contextual verification and Registered caller fence | -| G16 | The branding token contract. (a) Where does the settings key registry live, what does a descriptor carry, and does `tenancy.setting.write` refuse keys outside it? (b) Which branding keys exist — per-token keys or one theme document — and is a layout option among them? (c) What value does each accept, fonts and logos included, and what happens to a stored value that fails it? (d) Does a failed contrast check refuse the write or record a warning — [Accessibility Standards § Color and Contrast](../standards/16-accessibility.md#color-and-contrast) says a Studio warning? (e) What does an organization-scoped branding row do here — refused, ignored or applied? (f) Which tokens may leave an anonymous response? (g) Does `tenancy.white_label_branding` — which reads true under `NullEntitlementProvider`, whose projection grants every registered feature, falls back to its catalog default `false` from a projection that omits it, and which the Hub's Starter plan sets false — govern applying theme tokens or only removing LearnStack attribution? | (a) a registry beside `FeatureKeys` and `LimitKeys`, as `Tenant.SetFeatureFlag` already refuses unregistered keys; (b) per-token keys, at most one enumerated layout option or none; (c) `#rrggbb` colours, one font key from a closed self-hosted set, no remote logo; (d) refuse; (e) tenant-wide only, keeping Phase 06's override and ADR-0017's `OrganizationBranding` true; (f) a closed projection of publicly readable keys; (g) not gated — tokens are baseline presentation, and the key's meaning is agreed with the Hub. That token values are tenant settings is settled by [Frontend Architecture Standards § Tenant Branding](../standards/07-frontend-architecture.md#tenant-branding) | Contract: a phase-doc statement plus Frontend Architecture Standards § Tenant Branding; a new ADR if the registry becomes an admission rule for every `tenant_settings` key; a dated ADR-0017 amendment if (e) applies overrides; Accessibility Standards if (d) replaces the warning. Detail: the Tenancy spec and permission matrix, [Frontend Architecture § Theming](../architecture/14-frontend-architecture.md#theming), the `FeatureKeys` descriptor with a matching note in the Hub repository for (g) | P02d-2 (a–e: validation and the seeded keys, which Phase 06's editor later edits), P02d-4 (f, g: the anonymous projection the OpenAPI baseline freezes), P02d-6 (g: whether rendering consults the flag) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): (a–e); [Accepted — 2026-10-03](#p02d-4-accepted-answers): public projection and attribution (f/g); injection remains G42/P02d-6 | +| G16 | The branding token contract. (a) Where does the settings key registry live, what does a descriptor carry, and does `tenancy.setting.write` refuse keys outside it? (b) Which branding keys exist — per-token keys or one theme document — and is a layout option among them? (c) What value does each accept, fonts and logos included, and what happens to a stored value that fails it? (d) Does a failed contrast check refuse the write or record a warning — [Accessibility Standards § Color and Contrast](../standards/16-accessibility.md#color-and-contrast) says a Studio warning? (e) What does an organization-scoped branding row do here — refused, ignored or applied? (f) Which tokens may leave an anonymous response? (g) Does `tenancy.white_label_branding` — which reads true under `NullEntitlementProvider`, whose projection grants every registered feature, falls back to its catalog default `false` from a projection that omits it, and which the Hub's Starter plan sets false — govern applying theme tokens or only removing LearnStack attribution? | (a) a registry beside `FeatureKeys` and `LimitKeys`, as `Tenant.SetFeatureFlag` already refuses unregistered keys; (b) per-token keys, at most one enumerated layout option or none; (c) `#rrggbb` colours, one font key from a closed self-hosted set, no remote logo; (d) refuse; (e) tenant-wide only, keeping Phase 06's override and ADR-0017's `OrganizationBranding` true; (f) a closed projection of publicly readable keys; (g) not gated — tokens are baseline presentation, and the key's meaning is agreed with the Hub. That token values are tenant settings is settled by [Frontend Architecture Standards § Tenant Branding](../standards/07-frontend-architecture.md#tenant-branding) | Contract: a phase-doc statement plus Frontend Architecture Standards § Tenant Branding; a new ADR if the registry becomes an admission rule for every `tenant_settings` key; a dated ADR-0017 amendment if (e) applies overrides; Accessibility Standards if (d) replaces the warning. Detail: the Tenancy spec and permission matrix, [Frontend Architecture § Theming](../architecture/14-frontend-architecture.md#theming), the `FeatureKeys` descriptor with a matching note in the Hub repository for (g) | P02d-2 (a–e: validation and the seeded keys, which Phase 06's editor later edits), P02d-4 (f, g: the anonymous projection the OpenAPI baseline freezes), P02d-6 (g: whether rendering consults the flag) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): (a–e); [Accepted — 2026-10-03](#p02d-4-accepted-answers): public projection and attribution (f/g); [Accepted — 2026-10-09](#p02d-6-accepted-answers): injection and attribution (g) | | 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. Subjects, exemptions and behavioral proof remain open for P02d-5/6/7; [Accepted — 2026-10-08](#p02d-5-accepted-answers): (c) exemptions; (b,d) stay P6/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 | | 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 | @@ -431,12 +437,12 @@ premise a row cites is re-verified at that pass rather than trusted. | 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; (b,c) stay P6 | -| G39 | Is [ADR-0027](../decisions/README.md#open-adr-drafts), 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) | Open | -| 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 | Open | -| 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 | Open | -| 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) | Open | -| 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) | Open | +| 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 | +| 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 | @@ -2894,6 +2900,14 @@ bootstrap and grants no API or authentication authority. Platform/unknown hosts still receive `404`. Future BFF/auth routes require their own Phase 02b contract; P6/G40 decides the product behavior of the remaining scaffold screens. +**P02d-6 entry extension — Accepted 2026-10-09.** In addition to the delivered +P5 matrix above, exact `/{enabledCanonicalLocale}/status/not-found` continues +through live admission with middleware HTTP `404`. Noncanonical enabled prefixes +canonicalize under the same rule; sibling/extra status segments remain masked +`404`. This status route carries no Education authorization. P6's decision package +owns the original content request's 307→404 navigation chain. Implementation is +not claimed by this acceptance note. + #### P02d-5 implementation plan The first commit accepts this package and ADR-0053, records the bounded ADR-0036 @@ -3890,11 +3904,11 @@ This closeout changes no production code or accepted decision. ### P02d-6 decision package (2026-10-09) -**Proposed — approval pending, verified against `848b333`.** P02d-1–5 are -complete and merged. This package proposes the remaining renderer decisions; -it accepts no gate, installs no dependency and claims no product-page delivery. -The new [ADR-0027](../decisions/0027-frontend-i18n.md) requires maintainer approval -before implementation. Earlier delivery records remain historical. +**Accepted — 2026-10-09, re-verified against `2d821c4`.** P02d-1–5 are complete +and merged. The maintainer explicitly approved revised +[ADR-0027](../decisions/0027-frontend-i18n.md), all gate answers and the status/URL +tradeoff below. The decision commit precedes implementation and claims no product +page or dependency delivery. Earlier preparation/review records remain historical. #### Reviewed inputs and verified premises @@ -3927,19 +3941,19 @@ The pass corrects current mutable carriers that prematurely claim product pages, leave the delivered G12 interface open, or require an outbound URL sink in this plain-text packet. Accepted ADR bodies and shipped delivery records are unchanged. -#### Proposed gate answers +#### P02d-6 accepted answers -| Gate / part | Proposed answer | +| Gate / part | Accepted answer | |---|---| | G5, G12 page states | Preserve public DTO eligibility and exact pins. Omit a null level; localize an unavailable level. Unavailable content has a bounded placeholder, never raw JSON or another revision. Distinguish empty catalog, empty public outline, restricted outline, empty ready content and unavailable content. | | G16(g) | Render the complete safe tenant palette independently of plan. The effective `showPlatformAttribution` value alone controls platform attribution; no plan inference in the renderer. | | G20 subjects | Census production frontend TS/JS and JSON, including renderer/message values, against the exact decoded seed identity literals from G20(a). Preserve the accepted platform-built-in and development/test exemptions, without a blanket catalogue exemption. P7 implements the existing registered rule across backend and frontend. | | G38(b,c) | Vitest covers synchronous views and pure mappings; real production HTML/RSC tests cover async pages. Extend the existing required Docker integration fixture to unchanged product pages while preserving the isolated P5 transport probe. P7 still owns shared demo/browser/Lighthouse activation. | -| G39 | Accept ADR-0027 at first use: exact next-intl 4.14.9, server-first configuration using shared verified request admission, app-local `src/i18n/messages/{en,tr}/public.json`, dotted feature UI keys and one checked ICU catalogue contract. Backend `lockey_*` remains a separate wire namespace; the web app owns closed page-outcome mapping, not the SDK. No i18n routing middleware or preference cookie. | -| G40 | Three fixed content routes, minimal tenant chrome, both opaque pagination surfaces, exact-locale metadata and explicit page states. The status/navigation choice below requires separate explicit approval with this package. | +| G39 | ADR-0027 is Accepted at first use: exact next-intl 4.14.9, server-first configuration using shared verified request admission, app-local `src/i18n/messages/{en,tr}/public.json`, dotted feature UI keys and one checked ICU catalogue contract. Backend `lockey_*` remains a separate wire namespace; the web app owns closed page-outcome mapping, not the SDK. No i18n routing middleware or preference cookie. | +| G40 | Three fixed content routes, minimal tenant chrome, both opaque pagination surfaces, exact-locale metadata and explicit page states. The maintainer explicitly approved the status/navigation choice below. | | G41 | Render only the API's ordered plain-string fields through app-local synchronous components in `apps/web/src/components/public/`. Unsupported renderer or unresolved presentation renders the bounded fallback. No HTML, Markdown, linkification, authored URL sink or new primitive. | | G42 | Emit a server-generated style element containing only four fixed `--ls-*` color properties after atomic `#rrggbb` validation. Null or malformed theme retains the entire existing CSS default palette. No per-token merge, style attribute, URL, font or organization override. | -| G43 | Enforce applicable jsx-a11y rules at error severity with actual-config planted controls; assert visible semantics/languages/titles. Catalog → course → lesson is a critical flow requiring keyboard, focus, reflow, contrast and a real screen-reader smoke record. Status promotion occurs with enforcement, not this proposal. | +| G43 | Enforce applicable jsx-a11y rules at error severity with actual-config planted controls; assert visible semantics/languages/titles. Catalog → course → lesson is a critical flow requiring keyboard, focus, reflow, contrast and a real screen-reader smoke record. Status promotion occurs with enforcement, not acceptance. | UI catalogue fallback is not content fallback. An enabled language without an authored UI catalogue remains admissible; UI groups and API-resolved labels carry @@ -3963,8 +3977,8 @@ product tests: | Local Suspense / access-fallback boundary | Visible fallback with HTTP 200, not a hard 404 | | Await lookup, redirect to an explicitly admitted fixed error page; middleware supplies its 404 status | Local HTTP 307 followed by visible localized HTML 404 with root language/direction and no-store; HEAD is bodyless | -The recommended choice is the last strategy, **subject to explicit approval of -the URL/status tradeoff**. Root/route admission awaits the request-cached resource +The maintainer accepted the last strategy, **including its URL/status tradeoff**. +Root/route admission awaits the request-cached resource before emitting a document shell. A missing/hidden resource redirects to the same host's fixed `/{locale}/status/not-found` page, which middleware admits only after live host/locale validation and marks HTTP 404. That page is ordinary @@ -3973,9 +3987,10 @@ cursor or failure detail. Its title/robots are localized and noindex; it has a catalog recovery link and the same safe theme/chrome. It never queries Education. The original request is **307, followed by 404**, and the browser URL changes. -This is not a claim that the original document returns a direct 404. Acceptance -must update G40's completion criterion to name that exact chain and add this -error namespace to the entry matrix and route skill. Middleware's existing masked +This is not a claim that the original document returns a direct 404. The +acceptance commit updates G40's completion criterion to name that exact chain and +adds this error namespace to the entry matrix and route skill. Middleware's +existing masked unknown-host/provenance refusals remain direct, neutral responses. No new signed data carrier, shared bootstrap cache or outbound header is introduced. @@ -3984,7 +3999,7 @@ middleware content preflight followed by another content read is also rejected: it costs a fourth call on every success and still races the later read. A new authenticated DTO-transfer protocol is not justified for this packet. Preserving the original URL with a branded direct 404 would require a separately designed -response-ownership change; it is not silently promised by this proposal. +response-ownership change; it is not silently promised by this decision. Metadata, layout and page use the same request-local resource loader. Layouts do not serialize child execution, so each page honors the loader result itself. @@ -4065,7 +4080,7 @@ Full Playwright/axe remains Phase 06; Lighthouse activation remains P02d-7. Normal content document: middleware bootstrap + request-local RSC bootstrap + one shared content operation = **three API calls**. A missing document followed -to the proposed error page adds two bootstrap calls = **five total**. Repeated +to the accepted error page adds two bootstrap calls = **five total**. Repeated metadata/layout/page consumers must not add calls; the next incoming document must re-read. No retry or shared validated-DTO cache is added. @@ -4084,7 +4099,7 @@ one process, controlled failures, pagination, exact schema swaps and inert value Verify the 307→404 chain and no direct internal bypass. These HTTP/RSC proofs are not a browser end-to-end claim. -#### Implementation steps after approval +#### Accepted implementation steps 0. **Decision commit.** Accept approved ADR/gate parts; reconcile every normative carrier and the changed G40 status criterion before writing implementation. @@ -4115,7 +4130,7 @@ technology observations are reported separately from automated test evidence. #### Approval boundary and acceptance carriers -The maintainer is asked to approve these together: +The maintainer approved these together on 2026-10-09: 1. ADR-0027's library, pin, catalogue home and UI/content-locale separation. 2. G40's explicit local 307→404 error-route behavior and URL change, three-call @@ -4125,15 +4140,26 @@ The maintainer is asked to approve these together: 3. The remaining gate answers, four implementation steps and mandatory manual accessibility proof above. -On approval, update the decisions index; Standards 03/06/07/08/09/16/21 and index; +The acceptance commit updates the decisions index; Standards 03/06/07/08/09/16/21 +and index; Localization/Frontend/Customization architecture; Phase 04/05/06/07 inheritance; -glossary terms where introduced; the two frontend skills; and this register, +glossary terms where introduced; the frontend route/i18n/block and test skills; +and this register, renderer scope/completion criteria and decision record. Only implemented tests may be labelled Implemented. Accepted ADR-0053/0054 remain unchanged: this selects their delegated page composition, without another authority carrier or budget. If review finds an Accepted decision must change, draft its replacement and seek approval before dependent code. P02d-7 and Phase 11 ownership remains unchanged. +#### Acceptance validation (2026-10-10) + +The acceptance-only reconciliation passes five Release corpus-consistency cases +and seven ADR-status workflow cases, with zero failures/skips, plus added-prose +wrapping, relative-link/fragment and diff checks. A fresh independent acceptance +review confirms Registered proof statuses, unchanged previously Accepted ADR bodies +and the pending implementation boundary. Its confirmed stale block-skill carrier +is reconciled before the decision commit. No dependency or runtime is delivered. + #### Preparation review and validation Two independent draft-review rounds complete: GPT-6-astra high and @@ -4651,7 +4677,8 @@ page only through a public read. The accessor is internal: role a `TenantSetting` read, so no `[PublicSurface]` request returns settings by key, and the accepted anonymous projection returns the whole four-color theme or null. Baseline colors are independent of plan; effective WhiteLabelBranding removes -attribution only. G42 still selects document injection in P02d-6; organization +attribution only. Accepted G42 selects atomic style-element injection in P02d-6; +organization overrides and token merges remain Phase 06 scope. P02d-6's text-card path uses escaped React text under ADR-0051, with no HTML, @@ -4672,9 +4699,10 @@ language and direction follow the route's locale per layout's fixed `lang="en"` and platform `<title>` do not survive this phase. What evidence fails a build is **G43**; the language of fallback-resolved fields is **G24**. -Open here, each a register row: UI strings and the i18n library (**G39**); route -files, page states, pagination and chrome (**G40**); bounded presentation (**G41**), +The [Accepted P02d-6 package](#p02d-6-accepted-answers) closes UI strings/library +(**G39**), routes/states/pagination/chrome (**G40**), bounded presentation (**G41**), theme injection (**G42**), accessibility (**G43**) and renderer tests (**G38 b,c**). +Implementation and its passing proof remain separate from acceptance. P02d-5 already delivers **G37**'s dynamic/no-store rendering and **G21**'s cookie-free, no-cross-origin-subresource contract; pages must preserve them. @@ -5322,17 +5350,20 @@ otherwise page reflows at 320 CSS px, and the text and UI-component colours of both seeded token sets meet [Accessibility Standards § Color and Contrast](../standards/16-accessibility.md#color-and-contrast). - **Manual**, in the renderer pull request's accessibility record. + **Manual**, including a real screen-reader smoke on that critical flow, in the + renderer pull request's accessibility record. DOM tests do not replace it. - 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, Localization § UI String Catalogue and the `add-i18n-key` and - `add-frontend-route` skills name one catalogue path, and it exists. At exit, either - ADR-0027 is Accepted with its decisions-index row targeting 02d, or the standards - index still records the i18n runtime carve-out and Phase 04 owns ADR-0027. -- Tenant B's course URL requested on host A returns an HTTP `404` page in the browser, - not a `200` with error text and not a `500`; a tampered `?cursor=`, an API `429` and - an unavailable API each yield the state **G40** records; an empty catalog renders a - state distinct from the error state. + `add-frontend-route` skills name ADR-0027's one catalogue path, and it exists. + ADR-0027 is Accepted with P02d-6 as first consumer; Phase 04 consumes it. +- 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` HTML; + the browser URL changes, no original slug/query is echoed, and HEAD is bodyless. + Direct status-page entry requires live host/locale admission and makes no + Education call. A tampered `?cursor=`, content API `429` and unavailable content + API yield controlled translated HTTP `200`/noindex states; bootstrap refusals + retain their real neutral `404`/`429`/`503`. An empty catalog is distinct. - A page whose course or lesson carries a band the resolved revision does not declare renders the state **G5** records, without throwing and without another tenant's name. - Field values containing `<script>`, an `<img onerror>` fragment and a `javascript:` diff --git a/docs/roadmap/phase-04-cms-media-pages.md b/docs/roadmap/phase-04-cms-media-pages.md index 9a4f89c7..86bbe289 100644 --- a/docs/roadmap/phase-04-cms-media-pages.md +++ b/docs/roadmap/phase-04-cms-media-pages.md @@ -29,11 +29,15 @@ Decisions consumed in this phase: directly here: presentation and content shape are inside it, so nothing in this phase needs a code branch per tenant. -Decision required **before this phase exits**: -[ADR-0027 — frontend UI localization](../decisions/0027-frontend-i18n.md) is Proposed -for P02d-6's first UI strings. If accepted there, this phase consumes its installed -foundation and adds CMS/Studio message coverage. Until acceptance, this phase's -existing decision-and-wiring exit requirement remains in force. +**Accepted foundation — 2026-10-09; implementation pending in P02d-6.** +[ADR-0027](../decisions/0027-frontend-i18n.md) closes G39 at the first public UI +consumer: exact `next-intl` 4.14.9, server-first request configuration and app-local +`src/i18n/messages/<locale>/<namespace>.json` catalogues. This phase consumes that +foundation and adds CMS/Studio message coverage; it does not reselect the library +or own its initial installation. Tenant-authored content localization remains +this phase's responsibility. The +[P02d-6 package](phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09) +records accepted page/status behavior and distinguishes it from delivery. ## Scope @@ -208,7 +212,10 @@ Also in scope: locale fallback chain per tenant, the `/{locale}/{slug}` routing per-locale publish readiness, and locale negotiation from `Accept-Language` for API-returned messages ([Error Handling Standards § Validation Errors](../standards/09-error-handling.md#validation-errors)). -The frontend i18n library is chosen in ADR-0027 (see the Phase Exit Decision). +Frontend UI messages consume Accepted ADR-0027's separate whole-catalogue fallback +contract; that fallback never changes the admitted content locale. Public route +admission remains signed-source and live-membership based; `Accept-Language` is +not route authority. ### Page Blocks — Two-Tier Registry @@ -391,7 +398,8 @@ describes. - Admin Studio CMS screens per the list above. - Public read APIs for the renderer, versioned per [ADR-0024](../decisions/0024-api-versioning-policy.md). -- ADR-0027 Accepted, and the chosen i18n library wired into `frontend/apps/web`. +- CMS/Studio UI messages extend the ADR-0027 foundation owned by P02d-6, with + complete supported catalogues and checked ICU/callsite coverage. ## Completion Criteria @@ -477,9 +485,9 @@ describes. the education catalog as disconnected systems. Both are corpus-level regressions; the catalog in [Phase 05](phase-05-education-learning-content.md) composes the same blocks and the same content types. -- **ADR-0027 slipping past the exit gate.** An i18n library chosen after the Studio and - the public renderer already have strings is a mechanical but wide refactor. The gate - is there to make the cost visible while it is still small. +- **Bypassing the accepted localization foundation.** A second catalogue home or + unchecked CMS/Studio literals recreate the drift G39 closes. Extend ADR-0027's + app-local namespaces and checks rather than introducing another UI runtime. ## Phase Exit Decision @@ -502,6 +510,8 @@ hold: `IVideoTranscoder` has exactly one registered implementation with the managed alternative recorded against [Phase 11](phase-11-production-hardening.md) and its trigger. -- ADR-0027 is **Accepted** and the chosen library is wired, not merely selected. +- CMS/Studio UI coverage uses the P02d-6 ADR-0027 foundation, with complete + supported catalogues and passing ICU/callsite checks. Its installation belongs + to P02d-6; acceptance alone is not evidence of delivery. - Public rendering and Admin Studio work can proceed against a stable content contract — which is what [Phase 06](phase-06-renderer-admin-studio.md) assumes. diff --git a/docs/roadmap/phase-05-education-learning-content.md b/docs/roadmap/phase-05-education-learning-content.md index df05cd1a..4106871b 100644 --- a/docs/roadmap/phase-05-education-learning-content.md +++ b/docs/roadmap/phase-05-education-learning-content.md @@ -81,6 +81,17 @@ Each change follows [Database Standards § Migrations](../standards/05-database. | The customization definition read path — content types and taxonomies through the generation-keyed cache ([32-tenant-customization-model.md § 8.2](../architecture/32-tenant-customization-model.md#82-cache-strategy)) | The `TenantLessonItemType` read, the batched reference walk and the measured cost model | | `[TenantOwned]` markers, EF query filters, RLS policies | The same layers on every new table, with no exception | +**Accepted renderer inheritance — 2026-10-09; implementation pending.** +The [P02d-6 package](phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09) +fixes exact-locale content, both opaque pagination surfaces, inert plain-text +presentation and distinct restricted/empty/unavailable states. Its local 307→404 +status-route chain changes the browser URL; known content-call failures use +translated HTTP 200 noindex states. Preserve these boundaries when expanding +catalog data. [ADR-0027](../decisions/0027-frontend-i18n.md) supplies UI messages +through shared verified request admission; UI fallback never substitutes content +language or revises stored schema/taxonomy pins. Richer item renderers and the +full registry remain this phase and Phase 06 work, not P02d-6 delivery. + ### ADR-0025 — the scoring and completion DSL engine The one decision this phase cannot ship without. The ADR settles: diff --git a/docs/roadmap/phase-06-renderer-admin-studio.md b/docs/roadmap/phase-06-renderer-admin-studio.md index 1cd07255..8b2f942b 100644 --- a/docs/roadmap/phase-06-renderer-admin-studio.md +++ b/docs/roadmap/phase-06-renderer-admin-studio.md @@ -31,6 +31,22 @@ Rows identify their delivery packet; planned P02d-6/7 work is not current behavi | Four-color public theme projection delivered P02d-4; safe document injection planned P02d-6 | 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 | +**Accepted P02d-6 inheritance — 2026-10-09; implementation pending.** +[ADR-0027](../decisions/0027-frontend-i18n.md) selects exact `next-intl` 4.14.9, +app-local `src/i18n/messages/<locale>/<namespace>.json` and complete `en`/`tr` +`public` messages. This phase extends its namespaces for full Studio/portal and +renderer UI. It preserves shared verified request admission, exact content locale, +whole-catalogue UI fallback and missing-key failure; the SDK owns no translations. + +The [P02d-6 package](phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09) +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. + ### Public site renderer The Next.js `apps/web` app supports: @@ -212,9 +228,10 @@ live class. **Phase 06 owns it.** ### End-to-end test stack — a carried dependency -Phase 06 is the first phase to run browser tests, so it is the first phase that depends -on the ephemeral end-to-end stack actually working. `infra/compose/e2e.yml` overlays the -development stack with `volumes: !reset []`, which discards more than the named volumes: +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. +`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**. A browser suite launched against that stack meets a database without the `learnstack_app` / `learnstack_migration` roles the init script creates and an object diff --git a/docs/roadmap/phase-07-enrollment-learner-portal.md b/docs/roadmap/phase-07-enrollment-learner-portal.md index c5e36003..837c668d 100644 --- a/docs/roadmap/phase-07-enrollment-learner-portal.md +++ b/docs/roadmap/phase-07-enrollment-learner-portal.md @@ -42,6 +42,20 @@ What Phase 07 inherits from 02d is narrow and read-side only: `Course` and host-based tenant and organization resolution works end to end, and the public content path exists to contrast the entitled one against. +**Accepted UI inheritance — 2026-10-09; implementation pending in P02d-6.** +[ADR-0027](../decisions/0027-frontend-i18n.md) selects exact `next-intl` 4.14.9 +and app-local `src/i18n/messages/<locale>/<namespace>.json`. Learner screens add +feature UI namespaces through the Phase 06 portal shell, preserving complete +supported catalogues and checked ICU/callsite coverage. General UI identifiers +remain distinct from backend `lockey_*`; the SDK supplies no translations. + +The [P02d-6 package](phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09) +selects anonymous page/status composition and shared verified public admission. +Its UI fallback changes no content locale or access policy. Its local 307→404 URL +change and HTTP 200 noindex content-failure states do not decide authenticated +portal response behavior. This phase still owns learner access, enrollment and +progress; public page acceptance supplies none of those implementations. + ## Scope ### Enrollment diff --git a/docs/standards/03-frontend-coding.md b/docs/standards/03-frontend-coding.md index 1d78d9c8..d077591e 100644 --- a/docs/standards/03-frontend-coding.md +++ b/docs/standards/03-frontend-coding.md @@ -2,6 +2,7 @@ **Status:** Active **Derives from:** [ADR 0009 — Frontend Single App First](../decisions/0009-frontend-single-app-first.md). +Public UI localization derives from [ADR-0027](../decisions/0027-frontend-i18n.md). TypeScript, React, and Next.js coding conventions. Frontend *architecture* (App Router layout, tenant resolution, SDK shape) is in [07-frontend-architecture.md](07-frontend-architecture.md). @@ -128,7 +129,8 @@ export function CourseCard({ course, onEnroll }: CourseCardProps) { P02d-2 accepts and implements G16's theme value grammar; P02d-4 delivers its public projection. [Tenant Branding](07-frontend-architecture.md#tenant-branding) owns that -contract. P02d-6 still owns G42's server-rendered token injection and its proof. +contract. P02d-6's Accepted G42 selects safe server-rendered token injection; +implementation and proof remain pending. ## Server Actions @@ -175,7 +177,27 @@ Accepted G35 assigns the browser logger, Sentry and web-vitals to Phase 11; P02d-5 provides the bounded server caller and trace propagation, with no browser observability hook. See [Frontend Observability](10-observability.md#frontend-observability). -P02d-6 still owns G40's public error/loading/not-found placement and localized states. +P02d-6's Accepted G40 selects public error/loading placement and localized states; +implementation remains pending. [Standards 07](07-frontend-architecture.md#routing) +owns the approved status-route behavior. + +## UI Messages + +**Accepted P02d-6 G39 — 2026-10-09; implementation pending.** +[ADR-0027](../decisions/0027-frontend-i18n.md) selects server-first `next-intl` +4.14.9 and `apps/web/src/i18n/messages/{en,tr}/public.json`. General UI identifiers +use lowercase dotted feature namespaces with snake_case segments, for example +`public.catalog.course_count`. Backend `lockey_*` keys retain their separate wire +contract; the SDK owns no translated resources. + +Use ICU MessageFormat and plain text parameters. Do not use rich-text callbacks, +HTML messages, authored URL attributes or client input as a lookup identifier. +Require nonempty catalogues, equal key sets, valid ICU and matching argument +names/types, plus checked callsite coverage. Missing required keys fail the build; +formatter failures select a bounded translated unavailable state. Planted controls +must prove these checks can fail. See +[Localization Standards](08-localization.md#strings-in-code) for request integration +and UI/content-language separation. ## Forbidden @@ -208,7 +230,6 @@ packages/ ui/ # design system primitives sdk/ # generated API client config/ # shared configs - i18n/ # locale messages + helpers ``` Feature folder layout: @@ -223,16 +244,12 @@ features/<feature>/ types.ts ``` -> **Open in P02d-6.** Two questions these trees answer are open for the public -> renderer. Where the UI string catalogue lives is G39: `packages/i18n/` is one of -> several homes the corpus names, and `frontend/packages` holds no such package. -> G31 is Accepted: `packages/sdk` exports generated types and four injected public -> GET wrappers, governed by [Standards 07](07-frontend-architecture.md#sdk). -> Where the lesson page's composite and primitive field components live — -> `packages/ui/`, a route group's or a feature's -> `components/`, or elsewhere — is G41. Each is a row in -> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), -> and the pass that closes each open question edits this section with its answer. +**Accepted P02d-6 placement — 2026-10-09; implementation pending.** G39 uses +`apps/web/src/i18n/request.ts` and the app-local message home above; no +`packages/i18n` is created. G41 uses synchronous public views under +`apps/web/src/components/public/` for ordered plain-string `default-card` fields +only. Richer primitives remain Phase 04/05 and Phase 06. G31's delivered SDK +contract remains governed by [Standards 07](07-frontend-architecture.md#sdk). ## Comments diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index 569219c6..f4fa0f38 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). +UI localization controls derive from [ADR-0027](../decisions/0027-frontend-i18n.md) +and the [Accepted P02d-6 package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09). Test pyramid, conventions, and what every change must cover. @@ -189,12 +191,34 @@ Rules: - Playwright covers the same golden flows the backend E2E covers, from the user's perspective. - Visual regression covers the public renderer and page-builder block output. -> **Open in P02d-6.** G38(b/c)'s product-page cases and G43's accessibility checks -> remain in -> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). -> P02d-5 already delivers G38(a)'s transport cases and G38(d)'s guarded Vitest runner; -> neither is an open page-testing decision. The P6 pass records whether axe under -> jsdom or another page check supplements the later Playwright suite. +**Accepted P02d-6 G38(b,c)/G43 — 2026-10-09; implementation pending.** Vitest +covers synchronous views and pure mappings. Async pages require production +HTML/RSC proof against unchanged product routes in the existing required real +API/Next Docker fixture; keep P5's synthetic transport probe in a separate mode +so its catch-all cannot shadow product routes. Preserve `learnstack_app`, RLS, +read-only transactions, native HTTPS, closed hop headers and fixture containment. + +Assert visible HTML/DOM separately from Flight: status, `lang`/`dir`, headings, +theme, attribution, both hosts/locales, cross-tenant absence, overlapping concurrent +host/locale requests, freshness in one process, controlled failures, both opaque +pagination surfaces, exact schema swaps and inert authored values. Test-owned +inventory exceeds the page bound; test-owned enabled RTL content proves direction +and labelled UI fallback without rewriting historical seed inventory. Verify the +approved local 307→404 chain, changed status URL, no direct internal bypass and +HTTP 200 noindex known content-failure states. Shared metadata/layout/page loaders +must preserve three API calls per normal document and five for a followed missing +document; the next document re-reads. These proofs are not browser E2E evidence. + +Applicable jsx-a11y rules run at error severity with actual-config planted +controls. Catalogue checks cover nonempty/equal key sets, valid ICU and argument +agreement, checked callsite coverage and bounded formatter failure, with planted +offenders. Canonical names/statuses live in the +[architecture-test catalogue](21-architecture-tests-catalogue.md); acceptance does +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. ## Tenant Isolation Tests diff --git a/docs/standards/07-frontend-architecture.md b/docs/standards/07-frontend-architecture.md index d260b7ad..e205aec0 100644 --- a/docs/standards/07-frontend-architecture.md +++ b/docs/standards/07-frontend-architecture.md @@ -11,6 +11,9 @@ Public-read additions derive from configured caller and dynamic rendering derive from [ADR-0053](../decisions/0053-trusted-public-server-rendering.md); native admission and redirect/query rules derive from [ADR-0054](../decisions/0054-bounded-public-renderer-admission.md). +Public UI localization derives from [ADR-0027](../decisions/0027-frontend-i18n.md); +page composition follows the +[Accepted P02d-6 package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09). Next.js App Router layout, tenant resolution, SDK shape, and runtime concerns for the tenant-facing `apps/web` application in *this* repository. See @@ -41,13 +44,18 @@ frontend/ layout.tsx # root layout middleware.ts # ingress verification and live locale/path admission components/ + public/ # Accepted P6 synchronous views; implementation pending + i18n/ # Accepted ADR-0027 home; implementation pending + request.ts + messages/ + en/public.json + tr/public.json lib/ packages/ ui/ # design system primitives (extracted only when duplication is real) sdk/ # generated API client + types config/ # eslint, tsconfig, tailwind shared configs - i18n/ # locale messages + helpers auth/ # OIDC client config + BFF helpers ``` @@ -138,6 +146,16 @@ public-site URL canonicalization and header transport remain P02d-5. An unavailable/no-locale site supplies no synthesized `en` default. - Client-side locale switching navigates to the new locale path. +**Accepted P02d-6 G39 — 2026-10-09; implementation pending.** `next-intl` +request configuration uses the same server-only request-cached admission loader +as document/layout/page consumers. That loader re-verifies the ingress envelope, +takes the canonical locale from its signed target and checks live enabled +membership before selecting messages. It imports neither next-intl nor messages; +configuration adds no bootstrap call or cycle. No i18n routing middleware, +`requestLocale`, callsite locale override, cookie or `Accept-Language` replaces +this authority. [Standards 08](08-localization.md#strings-in-code) owns catalogue +fallback and language attributes. + ## SDK The SDK is the frontend API boundary. **P02d-4 Step 4 delivers** generated @@ -183,12 +201,28 @@ owns the source, pin, diff policy, bootstrap exception and required-check rollou - File-based App Router. - Route groups: `(public)`, `(studio)`, `(portal)`. - Dynamic segments use `[slug]`, catch-all `[...slug]`. -- `params` and `searchParams` server-side; thread through carefully. +- Public route and owned pagination identity come from the verified signed raw + target; observed `params`/`searchParams` do not replace it. - Each route group has its own `layout.tsx`, `loading.tsx`, `error.tsx`. -> **Open in Phase 02d.** Which of these files the `(public)` group ships in Phase 02d, -> and any dated carve-out that needs, is G40 in -> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +**Accepted P02d-6 G40 — 2026-10-09; implementation pending.** Keep the three +`/{locale}/courses` list/course/lesson routes and minimal tenant chrome. Use plain +same-host relative anchors, including opaque catalog and outline pagination; +disable automatic prefetch. A new document request re-reads API state. + +Await request-local resource admission before a loading boundary can flush the +document. A missing/hidden resource returns local **307**, followed by the same +host's fixed `/{locale}/status/not-found` page with **404**; the browser URL changes. +Live host/locale admission applies again. The status page uses safe theme/chrome, +localized title and noindex, has a catalog recovery link, echoes no original +slug/query/cursor and never queries Education. This does not promise a direct +branded 404 at the original URL. Unknown-host/provenance refusals remain neutral. + +Keep `loading.tsx`/`error.tsx`; initial pre-admission loading UI is not promised. +Known content-call failures render translated **HTTP 200 noindex** states for +invalid cursors, rate limiting and unavailable/invalid API responses. Bootstrap +refusals retain real 404/429/503. Unexpected framework errors retain pre-stream +500/post-stream 200 semantics; `error.tsx` cannot set arbitrary status. ## Public Site Renderer @@ -198,7 +232,8 @@ routes render dynamically with no-store API transport. No ISR, positive `revalidate`, `generateStaticParams`, `unstable_cache` or shared bootstrap/data/route cache is permitted. Request-local reuse is isolated to one incoming request. Freshness is the next new server/document request, not client Router Cache history. -Disable local Server Component HMR caching. P02d-6/G41 still owns components. +Disable local Server Component HMR caching. Accepted P02d-6/G41 selects components; +implementation remains pending. P5 delivers the dynamic layout, transport and source/runtime proofs. Test-owned production routes exercise the real API; P6 public pages are not delivered by them. @@ -207,6 +242,16 @@ production routes exercise the real API; P6 public pages are not delivered by th - Block rendering pulls from a block registry (`packages/blocks`); blocks register a React component plus a JSON schema. - Preview tokens enable draft rendering for editors. +P02d-6 narrows this broader renderer target to API-ordered plain-string +`default-card` fields, through app-local synchronous views. No HTML, Markdown, +linkification, authored URL sink or new primitive is admitted. Unknown renderers +and unavailable presentation use bounded fallbacks. Metadata shares the page's +request-local content read: verified live host/local segments only, actual eligible +alternate slugs, noindex pagination with a first-page canonical and no misleading +resource alternates on errors. Full menus, media, preview and Studio remain +Phase 06. Normal documents use three API calls; following a missing document's +status redirect uses five total. No retry or shared DTO cache is added. + ### Public source fence scope P02d-5 remediation Step 4 follows header constructors and converted records through @@ -240,17 +285,23 @@ names the controls; the remediation record owns execution evidence. **G16(a–e)/G21 writer contract delivered in P02d-2 — 2026-10-02.** The [Tenancy contract](../modules/tenancy/README.md#whole-theme-setting-and-public-boundary) selects one whole-theme color document, contrast refusal, no organization override -and no font/logo/URL/layout value. Document injection remains G42. +and no font/logo/URL/layout value. Accepted G42 selects document injection below; +implementation remains pending. P02d-4 accepts complete typed theme or null and attribution-only entitlement; Step 2 delivers that public projection. P02d-4's public theme is exactly four validated colors or null; frontend safe CSS defaults remain the fallback, with no backend palette copy. Baseline colors are independent of plan. Effective WhiteLabelBranding removes LearnStack -attribution only. Document injection remains G42/P02d-6. +attribution only. Accepted G42/P02d-6 requires atomic validation of all four +`#rrggbb` values before emitting a server-generated style element with only +`--ls-primary`, `--ls-bg`, `--ls-fg` and `--ls-muted`. Null or any malformed value +retains the entire existing CSS default palette; no per-token merge or style +attribute. No URL, font or organization override is admitted. Output is safe +without CSP, whose delivery remains Phase 11. - Tenant theme tokens loaded at the layout level via RSC. -- Tokens map to CSS variables; Tailwind reads them via `--ls-primary`, `--ls-bg`, etc. +- Tailwind reads the four fixed color variables above; the font token stays local. - Theme JSON shape part of tenant settings; tenant-admin editor surfaces it. ## Live Classroom UI @@ -273,7 +324,8 @@ attribution only. Document injection remains G42/P02d-6. - Route-level `error.tsx` shows a graceful boundary. - Inline form errors at field level. - Toasts for transient feedback; modals for action-required errors. -- 404 page renders the tenant's brand if a tenant is resolved. +- P02d-6's accepted branded missing-resource path is the admitted 307→404 chain + in [Routing](#routing); a fresh bootstrap refusal stays neutral. ## Loading UI @@ -281,6 +333,9 @@ attribution only. Document injection remains G42/P02d-6. - Suspense boundaries scope streaming to meaningful units. - No "loading…" spinners for resources expected to take < 250 ms. +P02d-6 admission must complete before the initial shell flushes, per +[Routing](#routing); the general streaming rule does not bypass redirect admission. + ## Performance - Image: `next/image` with `priority` for above-the-fold heroes. diff --git a/docs/standards/08-localization.md b/docs/standards/08-localization.md index ccb66b51..53dff92d 100644 --- a/docs/standards/08-localization.md +++ b/docs/standards/08-localization.md @@ -4,6 +4,7 @@ **Derives from:** [ADR 0008 — Localization Schema](../decisions/0008-localization-schema.md). Public-read additions derive from [ADR-0052](../decisions/0052-anonymous-public-read-boundary.md). +Frontend UI additions derive from [ADR-0027](../decisions/0027-frontend-i18n.md). i18n is a platform-level concern. It affects translatable content, slugs, URLs, SEO, dates, numbers, currencies, formats, and the Admin Studio UI itself. See [docs/architecture/12-localization.md](../architecture/12-localization.md) for the strategy. @@ -41,11 +42,10 @@ uses exact enabled query locale; display-only Pattern B values carry authored `{value,locale}`. Neither fallback nor headers authorize a content locale. Internal fallback remains the delivered P02d-3 contract; language attributes are P02d-6. -**Proposed P02d-6 UI foundation — 2026-10-09.** -[ADR-0027](../decisions/0027-frontend-i18n.md) proposes next-intl and one app-local -catalogue home, with UI-language fallback separate from content admission. It is -not yet accepted or implemented; G39 and the existing Phase 04 exit commitment -remain open until approval reconciles their carriers. +**Accepted P02d-6 UI foundation — 2026-10-09; implementation pending.** +[ADR-0027](../decisions/0027-frontend-i18n.md) selects next-intl and one app-local +catalogue home, with UI-language fallback separate from content admission. P6 is +the first consumer; Phase 04 inherits this foundation for CMS/Studio coverage. ## URL Strategy @@ -219,19 +219,50 @@ applying the fallback chain. ## Strings in Code -- Frontend: `next-intl` (or equivalent) loaded from `packages/i18n/locales/{locale}.json`. +- Frontend: server-first `next-intl` **4.14.9**, with app-local + `apps/web/src/i18n/messages/{en,tr}/public.json` and `src/i18n/request.ts`. + This is Accepted in P02d-6; dependency installation, catalogues and checks remain + pending. No shared i18n package is created without ADR-0009's duplication trigger. - Backend: localized strings live in resource files under each module. - Strings are referenced by key, never duplicated: ```tsx -const t = useTranslations("CourseCard"); -return <button>{t("enroll")}</button>; +const t = await getTranslations("public"); +return <p>{t("catalog.course_count", {count})}</p>; ``` ```csharp -var msg = _stringLocalizer["course.publish.success"]; +var msg = _stringLocalizer["lockey_course_publish_success"]; ``` +General UI identifiers use lowercase dotted feature namespaces and snake_case +segments (`public.catalog.course_count`); JSON nesting supplies the dots. Backend +`LocalizedMessage.Key` and Problem Details `messageKey` retain `lockey_*`, including +consumer-owned backend error resources. The SDK normalizes errors and owns no +translated resources. P6 maps closed page outcomes to explicitly owned UI keys; +arbitrary wire keys/titles/errors/parameters never become lookup identifiers or copy. + +Request configuration shares the server-only request-cached admission loader used +by document/layout/page consumers. It re-verifies the ingress envelope and live +enabled membership using the signed target's canonical content locale. No i18n +routing middleware, middleware-derived `requestLocale`, callsite override, cookie +or `Accept-Language` is authority, and message loading adds no bootstrap call. + +Select a whole UI catalogue by exact tag, then remove rightmost subtags, then use +platform `en`. Missing required keys in supported bundled catalogues fail the +build; no per-key fallback or raw-key output. ICU formatting uses the selected UI +catalogue's locale. Plain text parameters only; no HTML/rich callbacks or authored +URL attributes. Formatter failure yields a bounded translated unavailable state. +Nonempty/equal key sets, ICU argument names/types and callsite coverage require +planted failure controls, per ADR-0027 and the canonical +[test catalogue](21-architecture-tests-catalogue.md). + +The exact admitted content locale still selects API reads and document `lang`. +UI groups carry their selected catalogue's actual `lang`/direction when different; +Pattern B labels carry the API-resolved locale. Neither UI fallback nor label +fallback substitutes Pattern A titles, summaries or bodies. Studio/portal scaffolds +retain platform English until Phase 06 supplies their UI. + ## Locale Codes - IETF BCP 47 identity uses the shipped `LocaleTag` validator and canonicalizer: @@ -270,6 +301,13 @@ default-enabled CHECK; request-language negotiation is not part of those writers - Layout uses logical CSS properties (`padding-inline-start`, not `padding-left`). - Components flip via `dir="rtl"` on the document root. +Accepted P02d-6 derives document direction from the admitted content locale using +runtime internationalization data; an undescribed admitted tag falls back to +`ltr`, never refusal. UI fallback groups retain their own direction. Test-owned +enabled `ar` content proves `lang="ar"`/`dir="rtl"` and labelled English fallback +UI independently; it neither rewrites the seed nor claims an Arabic UI catalogue. +These renderer controls remain implementation obligations. + ## Admin Studio UI Admin Studio separates: diff --git a/docs/standards/09-error-handling.md b/docs/standards/09-error-handling.md index 4a556058..5453bc6c 100644 --- a/docs/standards/09-error-handling.md +++ b/docs/standards/09-error-handling.md @@ -4,6 +4,8 @@ **Derives from:** [ADR 0002 — Initial Architecture](../decisions/0002-initial-architecture.md) (Problem Details + Result\<T\> baseline), [ADR 0032 — Exception Handling, Logging, and Observability Architecture](../decisions/0032-exception-handling-logging-and-observability.md) (implementation patterns), [04-api-design.md](04-api-design.md) § Error Responses. Public-read additions derive from [ADR-0052](../decisions/0052-anonymous-public-read-boundary.md). +Public UI message mapping derives from [ADR-0027](../decisions/0027-frontend-i18n.md) +and the [Accepted P02d-6 package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09). How LearnStack represents, propagates, surfaces, and recovers from failures. @@ -82,7 +84,7 @@ frontend UI copy. Backend code never returns raw English. consumer resolving a backend message reads `Message.Key` — two wire surfaces in sync by construction. This does not claim a shipped frontend message catalogue. See [Frontend Error Handling](#mapping-problem-details--ui) for current SDK -normalization and the Proposed public UI mapping. Per +normalization and the Accepted, implementation-pending public UI mapping. Per [Phase 02a Packet 2](../roadmap/phase-02a-kernel-tenancy.md) and [ADR-0032 § Error Model](../decisions/0032-exception-handling-logging-and-observability.md). @@ -424,11 +426,32 @@ It validates/preserves backend message keys as data and owns no translated resources. A feature that displays backend messages owns its supported error resources; general UI copy has its own feature identifiers. -[Proposed ADR-0027](../decisions/0027-frontend-i18n.md#message-and-test-contract) +[ADR-0027](../decisions/0027-frontend-i18n.md#message-and-test-contract) specifies P02d-6's closed page-outcome mapping to owned UI keys, with unknown outcomes mapped to the bounded unavailable state. It does not authorize arbitrary Problem Details keys, titles, field errors or parameters as UI lookup identifiers -or visible copy. This public-page mapping is not implemented or Accepted yet. +or visible copy. This public-page mapping is Accepted — 2026-10-09; +implementation remains pending. + +### Public Page Status and Recovery + +**Accepted P02d-6 G40 — 2026-10-09; implementation pending.** A missing/hidden +content resource redirects locally with **307** to the same host's fixed +`/{locale}/status/not-found`, whose admitted branded document returns **404**. +The browser URL changes. The page rechecks live host/locale admission, never calls +Education, echoes no original target/query/cursor and supplies localized noindex +metadata and a catalog recovery anchor. This is not a direct branded 404 at the +original URL. A fresh bootstrap refusal remains neutral. + +Known content-call failures are translated **HTTP 200 noindex** page states: +invalid cursor has a relative reset link, rate limiting has a retry-later state, +and transport/invalid-response/unavailable or unknown outcomes have the bounded +unavailable state. They do not claim HTTP 400/429/503. Bootstrap retains its real +404/429/503 and bounded Retry-After. Unexpected framework failures retain +pre-stream 500/post-stream 200 behavior; `error.tsx` cannot choose arbitrary status. +Plain same-host relative anchors trigger fresh document requests. Admission +completes before loading boundaries flush; [Standards 07](07-frontend-architecture.md#routing) +owns placement and the shared request-local loader. ### User-Facing Copy diff --git a/docs/standards/16-accessibility.md b/docs/standards/16-accessibility.md index de92345e..601eb52b 100644 --- a/docs/standards/16-accessibility.md +++ b/docs/standards/16-accessibility.md @@ -2,6 +2,8 @@ **Status:** Adopted **Derives from:** WCAG 2.2 AA (external authoritative standard), [00-principles.md](00-principles.md) § 6 (Foundation First). +Public-page obligations also follow [ADR-0027](../decisions/0027-frontend-i18n.md) +and the [Accepted P02d-6 package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09). LearnStack targets **WCAG 2.2 AA** across all user-facing surfaces. @@ -55,6 +57,9 @@ LearnStack is an education platform; learners with disabilities are a first-clas [complete palette contract](../modules/tenancy/README.md#whole-theme-setting-and-public-boundary) defines supported usage and atomic replacement. The future Studio can explain that refusal; a warning does not authorize saving an invalid palette. +- P02d-6 uses foreground plus underline for normal text links. Primary has only + a 3:1 background guarantee; do not assume white text on primary meets body-text + contrast. Primary may supply sufficient-contrast non-text focus/border accents. ### Images and Media @@ -91,18 +96,29 @@ LearnStack is an education platform; learners with disabilities are a first-clas ## Tooling - `eslint-plugin-jsx-a11y` in the frontend lint config. -- `axe-core` integrated with Playwright; runs on every E2E test for the public renderer and portal critical flows. -- Lighthouse accessibility audit runs in CI for public routes. +- 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. - Manual keyboard walkthroughs for new screens in the PR review. -> **Open in Phase 02d.** Its public pages are the first routes this section binds. -> Whether the Lighthouse job activates there, and whether its accessibility audit is -> asserted, is G44; which checks fail a build on those pages — route tests, `jsx-a11y` -> at error severity or component axe — is G43. Both are in -> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), -> and the passes that close them edit this section. The Playwright axe run is -> [Phase 06](../roadmap/phase-06-renderer-admin-studio.md)'s, per -> [Testing Standards § End-to-End Tests](06-testing.md#end-to-end-tests). +**Accepted P02d-6 G43 — 2026-10-09; implementation pending.** Applicable +jsx-a11y rules run at error severity with planted controls through the actual app +configuration. Actual product-page HTML/DOM proves titles, languages, one main, +one descriptive h1, sequential headings, semantic lists/definition lists, skip +target and visible focus, including all controlled states. Logical CSS and wrapping +must support RTL and long unbroken strings. Acceptance does not promote this +standard: it stays **Adopted** until its enforcement lands. + +Catalog → course → lesson is a critical flow requiring manual keyboard, focus, +320 CSS px reflow/zoom, long-string, contrast and real screen-reader smoke evidence. +The record names commit, environment, both hosts/locales and screen reader used. +DOM assertions cannot replace assistive-technology observation; pending evidence +keeps packet completion pending. Full Playwright/axe remains +[Phase 06](../roadmap/phase-06-renderer-admin-studio.md)'s, per +[Testing Standards](06-testing.md#end-to-end-tests). Accepted G44 leaves Lighthouse +activation and its assertions with P02d-7 after the P6 pages; no audit is passing +by acceptance alone. ## Testing diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index be240b5a..3e0d3a18 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -4,7 +4,9 @@ **Derives from:** [ADR-0032 Exception Handling, Logging, and Observability Architecture](../decisions/0032-exception-handling-logging-and-observability.md) (ships the first batch of catalogue entries), [ADR-0054 Bounded Public Renderer Admission](../decisions/0054-bounded-public-renderer-admission.md) -(accepted replacement proofs implemented). The catalogue grows as +(accepted replacement proofs implemented), +[ADR-0027 Frontend UI Localization](../decisions/0027-frontend-i18n.md) +(P02d-6 rules Registered; implementation pending). The catalogue grows as subsequent ADRs and phases land their tests; per-test ownership stays with the originating ADR / standard. @@ -4219,6 +4221,72 @@ verification pass; the delivery record owns execution evidence. disposable routes, not P6 product pages or P7 browser delivery. - **Phase:** 02d (P02d-5). +## P02d-6 public UI localization controls + +The maintainer accepted ADR-0027 and the P6 decision package on 2026-10-09. The +rules below are commitments before the first implementation; none is a passing +test claim at acceptance. Existing P5 source/runtime controls remain mandatory, +and P6 product proofs supplement the isolated synthetic transport fixture. + +#### `Ui_Catalogues_Cover_Public_Call_Sites` + +- **Asserts:** nonempty supported UI catalogues have equal key sets, valid ICU + syntax and matching argument names/types; every used key exists. General UI + feature keys are separate from backend `lockey_*` wire keys. Planted per-locale + and absent-from-all keys, unknown callsites, bad ICU and mismatched arguments + fail. Public graphs include configuration and JSON catalogue dependencies. +- **Source:** ADR-0027; Accepted P02d-6 G39. +- **Type:** frontend catalogue/callsite + production build. **Kind:** structural. +- **Status:** **Registered** — P02d-6 Step 1. +- **Phase:** 02d (P02d-6). + +#### `Public_Ui_Locale_Does_Not_Change_Content_Admission` + +- **Asserts:** request configuration uses the existing verified signed target and + live membership. Whole-catalogue UI fallback changes neither exact API locale + nor admission. Document and UI groups retain their actual languages/directions; + test-owned enabled RTL content and overlapping host/locale requests exercise + the real configuration without another authority carrier or bootstrap call. +- **Source:** ADR-0027; Accepted P02d-6 G39 and G38(b,c). +- **Type:** frontend mapping + actual production rendering. **Kind:** behavioural. +- **Status:** **Registered** — P02d-6 Steps 1 and 4. +- **Phase:** 02d (P02d-6). + +#### `Public_Theme_Emits_Only_Validated_Color_Tokens` + +- **Asserts:** the complete four-color value is admitted atomically before fixed + `--ls-*` CSS emission. Malformed/null input preserves the whole default palette; + no raw style attributes, extra tokens, URLs or organization merges appear. + Effective attribution alone controls the platform label, independently of theme. +- **Source:** Accepted P02d-6 G16(g)/G42; Standards 07 § Tenant Branding. +- **Type:** frontend pure mapping + rendered document. **Kind:** behavioural. +- **Status:** **Registered** — P02d-6 Step 1. +- **Phase:** 02d (P02d-6). + +#### `Public_Pages_Preserve_Approved_Response_States` + +- **Asserts:** real product routes preserve shared request-local reads, opaque + pagination and no-store isolation. Missing/hidden content follows the approved + same-host 307→fixed localized 404 chain; known content-call errors use bounded + HTTP 200/noindex states. The status page makes no Education call. Production + tests observe actual status, visible HTML, call counts, freshness and no leaks. +- **Source:** Accepted P02d-6 G40/G38(b,c); ADR-0053/0054. +- **Type:** actual production Next/API/PostgreSQL. **Kind:** behavioural. +- **Status:** **Registered** — P02d-6 Steps 1–4. +- **Phase:** 02d (P02d-6). + +#### `Public_Pages_Expose_Localized_Accessible_Semantics` + +- **Asserts:** public states expose document language/direction, one main, + descriptive headings/title, skip navigation and language-bearing fallback UI. + Applicable jsx-a11y errors fail through the real lint config with planted + controls. Automated semantics supplement mandatory manual keyboard, focus, + 320 CSS px reflow, contrast and screen-reader evidence; they do not replace it. +- **Source:** Accepted P02d-6 G43; Standards 16 § Testing. +- **Type:** frontend lint/DOM + actual production HTML. **Kind:** structural. +- **Status:** **Registered** — P02d-6 Steps 1–4; manual evidence remains separate. +- **Phase:** 02d (P02d-6). + ## References - [ADR-0003 Tenant Isolation Defense in Depth](../decisions/0003-tenant-isolation-defense-in-depth.md) (Amendment 3) @@ -4226,6 +4294,7 @@ verification pass; the delivery record owns execution evidence. - [ADR-0032 Exception Handling, Logging, and Observability Architecture](../decisions/0032-exception-handling-logging-and-observability.md) - [ADR-0033 Audit Durability Model](../decisions/0033-audit-durability-model.md) - [ADR-0024 API Versioning Policy](../decisions/0024-api-versioning-policy.md) +- [ADR-0027 Frontend UI Localization](../decisions/0027-frontend-i18n.md) - [ADR-0034 Hub Contract Surface Invariant](../decisions/0034-hub-contract-surface-invariant.md) - [ADR-0035 Demand-Gated Infrastructure](../decisions/0035-demand-gated-infrastructure.md) - [ADR-0036 Trusted Inputs for Tenant and Organization Resolution](../decisions/0036-tenant-resolution-trusted-inputs.md) diff --git a/docs/standards/README.md b/docs/standards/README.md index 39042ede..04ce7b5f 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -73,8 +73,11 @@ 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) owns its final review and CI evidence. -P6 product pages and P7's demo/browser/Lighthouse work remain pending. Each packet's -delivery record owns its review and validation evidence. +P6's [decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09) +and [ADR-0027](../decisions/0027-frontend-i18n.md) are Accepted — 2026-10-09; +dependency installation, product pages and their proofs remain pending. P7 owns +demo/browser/Lighthouse. Each packet's delivery record owns actual review and +validation evidence; acceptance alone promotes no standard or test 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 @@ -88,12 +91,12 @@ included. | 00 | [Principles](00-principles.md) | **Active** | Governs every PR and every ADR; principles 1, 16 and 17 are already deciding live scope questions. | | 01 | [Architecture Standards](01-architecture-standards.md) | **Active** | Module layout shipped; `ModuleDomain_DoesNotDependOn_*` and the planted-violation meta-test are green. | | 02 | [Backend Coding](02-backend-coding.md) | **Active** | MediatR pipeline, `Result<T>`, `IClock`, the `LS0001` analyzer and the pipeline-order test all ship. Packet 6 brought the first `DbContext`, the first aggregates and the ambient unit of work, so its EF Core and domain-modelling clauses became live. Tenancy, Customization, Audit and Education now implement domain and persistence code. | -| 03 | [Frontend Coding](03-frontend-coding.md) | **Active** | ESLint and TypeScript strict mode are configured, the Vitest harness runs, and Packet 10 added the two rules this document's § Forbidden names — the direct-`fetch` ban and `dangerouslySetInnerHTML` outside the sanitised-HTML primitive — with a test that lints fixtures through the app's own configuration, because a configured rule and a missing one look identical from a green `pnpm lint`. `apps/web` is otherwise still a scaffold; its first real components land in [Phase 02d](../roadmap/phase-02d-walking-skeleton.md). | +| 03 | [Frontend Coding](03-frontend-coding.md) | **Active** | ESLint and TypeScript strict mode are configured, the Vitest harness runs, and Packet 10 added the two rules this document's § Forbidden names — the direct-`fetch` ban and `dangerouslySetInnerHTML` outside the sanitised-HTML primitive — with a test that lints fixtures through the app's own configuration, because a configured rule and a missing one look identical from a green `pnpm lint`. P02d-5 delivers trusted entry/caller controls; Accepted P02d-6 adds UI/ICU and synchronous public-view obligations, whose implementation remains pending. | | 04 | [API Design](04-api-design.md) | **Active** | Packet 4 shipped the versioned route convention and its startup guards, one Problem Details shape on every error including the framework-minted ones, cursor pagination, the sort grammar, idempotency keys, ETag concurrency, correlation ids, the request-body limit and the tenancy edge — each with tests in the required `backend` check. P02d-4 adds four institution public GET/HEAD pairs, served/snapshot contract controls and compatibility CI; application writers remain unrouted. | | 05 | [Database](05-database.md) | **Active** | Packet 6 applied it — two migration chains, ten tables — and Packets 8 and 9 took it to four chains and seventeen data tables. P02d-1 Step 2 adds Education: five chains and twenty-one data tables (twenty-six including five migration-history tables), with parent-mirror and Pattern A proofs. The four-role model and the canonical RLS template this document owns — `ENABLE` **and** `FORCE`, one `AND`-ed policy per table, an explicit `WITH CHECK` — are asserted against a real PostgreSQL as `learnstack_app`. Its § Concurrency, § Table classes, § Indexes and § GRANT matrix each have a test that fails without them. Partitioning and the retention job are still ahead. | | 06 | [Testing](06-testing.md) | **Active** | Unit, architecture and contract suites, and the Docker-free integration tests, run in the required `backend` job — Packet 4 removed the filter that used to exclude the integration assembly, which by then held the only tests that could catch an unversioned route. The Docker-bound `backend-integration` job activated in Packet 6 with the four-role provisioning suite; the split is by `[Trait("Requires","Docker")]` and the two jobs' filters are exact complements. The `backend-integration` job is required as of P02d-1, 2026-09-14 ([CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main)). | | 07 | [Frontend Architecture](07-frontend-architecture.md) | **Active** | The one-app rule is mechanical — `Frontend_Has_Only_The_Web_App` fails a second application in this repository — and the route groups it prescribes exist as layouts. The rest, the server/client split and the tenant context an SDK call carries, is exercised first in [Phase 02d](../roadmap/phase-02d-walking-skeleton.md): Active for what ships, and the phase that adds components is the one that tests them. | -| 08 | [Localization](08-localization.md) | **Active** | Packet 6 shipped `tenant_locales` and the slug schema. The partial unique default index enforces at most one default, not existence of a default for every enabled set; P02d-2 ships supported-write validation and the default-enabled CHECK constraint; raw deletes can still leave an enabled locale set without a default. `LocalizedText` and `LocalizedMessage` ship with their own cases, and API errors are keyed. P02d-1 shipped Education Pattern A satellites, canonical locales and flat localized-slug uniqueness; P02d-4 Step 3 delivers exact enabled public locale admission and response labels. The i18n runtime belongs to [Phase 04](../roadmap/phase-04-cms-media-pages.md); P02d-5 Step 3 delivers membership-first redirect G36; UI messages/library G39 remain open for P6. | +| 08 | [Localization](08-localization.md) | **Active** | Packet 6 shipped `tenant_locales` and the slug schema. The partial unique default index enforces at most one default, not existence of a default for every enabled set; P02d-2 ships supported-write validation and the default-enabled CHECK constraint; raw deletes can still leave an enabled locale set without a default. `LocalizedText` and `LocalizedMessage` ship with their own cases, and API errors are keyed. P02d-1 shipped Education Pattern A satellites, canonical locales and flat localized-slug uniqueness; P02d-4 Step 3 delivers exact enabled public locale admission and response labels. P02d-5 Step 3 delivers membership-first redirect G36. Accepted ADR-0027/G39 selects next-intl 4.14.9 and app-local `src/i18n/messages/{en,tr}/public.json` at P6; installation/runtime/checks remain pending. Phase 04 inherits the foundation for CMS/Studio. | | 09 | [Error Handling](09-error-handling.md) | **Active** | L1 `IExceptionHandler`, the exception hierarchy, `ProblemDetailsFactory` and `HttpStatusMap` shipped in Packet 3. | | 10 | [Observability](10-observability.md) | **Active** | Serilog → OTLP, OpenTelemetry SDK, `TenantContextSpanProcessor` and the redaction enrichers shipped in Packet 3. | | 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. | @@ -101,7 +104,7 @@ included. | 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) | **Adopted** | No user interface to audit. First surfaces render in [Phase 02d](../roadmap/phase-02d-walking-skeleton.md); automated axe checks in [Phase 06](../roadmap/phase-06-renderer-admin-studio.md). Which accessibility checks fail a build on Phase 02d's pages — route tests, `jsx-a11y` at error severity, axe under jsdom or Lighthouse's accessibility audit — and what this row says afterwards are G43 and G44 in [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register); this note is updated in the pull request that lands those pages, and any status change with its enforcer. | +| 16 | [Accessibility](16-accessibility.md) | **Adopted** | P6 product pages are not delivered. Accepted G43 requires applicable jsx-a11y error rules with actual-config planted controls, actual-page semantic/language proofs and manual keyboard/focus/reflow/contrast/screen-reader evidence. Implementation and evidence remain pending; promotion occurs with enforcement. Full Playwright/axe remains [Phase 06](../roadmap/phase-06-renderer-admin-studio.md); Accepted G44 assigns Lighthouse to P02d-7 after P6. | | 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). | From 568ecc2a1257a69706dd28660d4266700316ef6d Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 00:30:59 +0300 Subject: [PATCH 05/58] feat(public): add localized document and status foundation Keep tenant UI and content admission separate while sharing one resource read across metadata and document consumers. Prove the branded missing flow through the real API without replacing the P5 transport controls. ADR: 0027, 0051, 0052, 0053 I18n: public.chrome.*, public.catalog.*, public.course.*, public.lesson.*, public.page.* --- .claude/skills/code-review/SKILL.md | 4 +- CLAUDE.md | 9 +- .../Database/PublicServerRenderingTests.cs | 65 +- docs/architecture/12-localization.md | 14 +- docs/architecture/14-frontend-architecture.md | 32 +- docs/decisions/0027-frontend-i18n.md | 24 + docs/decisions/README.md | 2 +- docs/glossary.md | 4 +- docs/roadmap/phase-02d-walking-skeleton.md | 63 ++ docs/roadmap/phase-04-cms-media-pages.md | 7 +- docs/standards/07-frontend-architecture.md | 32 +- docs/standards/08-localization.md | 18 +- .../21-architecture-tests-catalogue.md | 15 +- docs/standards/README.md | 17 +- frontend/apps/web/next.config.ts | 3 +- frontend/apps/web/package.json | 2 + frontend/apps/web/scripts/fixture-support.mjs | 6 +- frontend/apps/web/scripts/verify-ingress.mjs | 12 +- .../web/scripts/verify-public-rendering.mjs | 643 +++++++++++++----- .../[locale]/status/not-found/layout.tsx | 22 + .../[locale]/status/not-found/page.tsx | 20 + frontend/apps/web/src/app/(public)/error.tsx | 27 + frontend/apps/web/src/app/(public)/layout.tsx | 35 +- .../apps/web/src/app/(public)/loading.tsx | 15 + frontend/apps/web/src/app/globals.css | 87 ++- frontend/apps/web/src/app/layout.tsx | 15 +- .../apps/web/src/components/public/chrome.tsx | 46 ++ .../src/components/public/error-labels.tsx | 30 + .../src/components/public/foundation.test.tsx | 153 +++++ .../apps/web/src/components/public/state.tsx | 48 ++ .../apps/web/src/components/public/theme.tsx | 17 + frontend/apps/web/src/i18n/catalogues.test.ts | 288 ++++++++ frontend/apps/web/src/i18n/catalogues.ts | 55 ++ frontend/apps/web/src/i18n/locale.test.ts | 39 ++ frontend/apps/web/src/i18n/locale.ts | 30 + .../apps/web/src/i18n/messages/en/public.json | 57 ++ .../apps/web/src/i18n/messages/tr/public.json | 57 ++ frontend/apps/web/src/i18n/request.ts | 21 + frontend/apps/web/src/middleware.test.ts | 14 + frontend/apps/web/src/middleware.ts | 7 +- .../server/configured-public-client.test.ts | 33 +- .../apps/web/src/server/public-entry.test.ts | 8 + frontend/apps/web/src/server/public-entry.ts | 9 +- .../web/src/server/public-request.test.ts | 233 +++++++ .../apps/web/src/server/public-request.ts | 50 ++ .../web/src/server/public-resource.test.ts | 278 ++++++++ .../apps/web/src/server/public-resource.ts | 95 +++ .../apps/web/src/server/public-route.test.ts | 116 ++++ frontend/apps/web/src/server/public-route.ts | 55 ++ frontend/apps/web/src/server/public-ui.ts | 14 + .../apps/web/src/server/request-memo.test.ts | 101 +++ frontend/apps/web/src/server/request-memo.ts | 25 + .../apps/web/src/test/fixture-support.test.ts | 37 +- .../web/src/test/public-boundaries.test.ts | 196 +++++- .../web/src/test/public-boundary-analysis.ts | 86 ++- frontend/pnpm-lock.yaml | 441 +++++++++++- 56 files changed, 3531 insertions(+), 301 deletions(-) create mode 100644 frontend/apps/web/src/app/(public)/[locale]/status/not-found/layout.tsx create mode 100644 frontend/apps/web/src/app/(public)/[locale]/status/not-found/page.tsx create mode 100644 frontend/apps/web/src/app/(public)/error.tsx create mode 100644 frontend/apps/web/src/app/(public)/loading.tsx create mode 100644 frontend/apps/web/src/components/public/chrome.tsx create mode 100644 frontend/apps/web/src/components/public/error-labels.tsx create mode 100644 frontend/apps/web/src/components/public/foundation.test.tsx create mode 100644 frontend/apps/web/src/components/public/state.tsx create mode 100644 frontend/apps/web/src/components/public/theme.tsx create mode 100644 frontend/apps/web/src/i18n/catalogues.test.ts create mode 100644 frontend/apps/web/src/i18n/catalogues.ts create mode 100644 frontend/apps/web/src/i18n/locale.test.ts create mode 100644 frontend/apps/web/src/i18n/locale.ts create mode 100644 frontend/apps/web/src/i18n/messages/en/public.json create mode 100644 frontend/apps/web/src/i18n/messages/tr/public.json create mode 100644 frontend/apps/web/src/i18n/request.ts create mode 100644 frontend/apps/web/src/server/public-request.test.ts create mode 100644 frontend/apps/web/src/server/public-request.ts create mode 100644 frontend/apps/web/src/server/public-resource.test.ts create mode 100644 frontend/apps/web/src/server/public-resource.ts create mode 100644 frontend/apps/web/src/server/public-route.test.ts create mode 100644 frontend/apps/web/src/server/public-route.ts create mode 100644 frontend/apps/web/src/server/public-ui.ts create mode 100644 frontend/apps/web/src/server/request-memo.test.ts create mode 100644 frontend/apps/web/src/server/request-memo.ts diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index 5340890d..9aad9d03 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -83,7 +83,7 @@ Walk every change against the project's | **Authn / authz** | Every new endpoint has `[Authorize(Policy=…)]` or `[AllowAnonymous]` with a comment. Policy keys exist in the permission registry. Resource-scope handlers (`instructor` edits own course only) present where needed. | | **Tenant id from the resolved context only** | Resolved per [ADR-0036](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md): from the host lookup of the effective host, or on a platform host from validated claims confirmed by live membership; off HTTP, from the job parameters or the integration-event envelope. Never from a request body, query string, cookie or tenant header — `X-Tenant-Id` and `X-Organization-Id` are assertions that can only reject, on every hop. Check command and DTO surfaces. | | **SQL injection** | EF Core LINQ or `FromSqlInterpolated`; never raw string concatenation. Raw SQL has parameters. | -| **XSS** | No `dangerouslySetInnerHTML` outside a sanitisation wrapper; CSP nonces in place; markdown via allowlist. Nothing sets a CSP today: the [standards index](../../../docs/standards/README.md#honest-status-today) row for Security Standards carves § HTTP Headers out to [Phase 11](../../../docs/roadmap/phase-11-production-hardening.md#security), so a missing nonce is not a Blocker until that phase sets one. How Phase 02d's theme tokens reach the page without constraining that nonce-based target is G42 in [its decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register), and the pass that closes it edits this row. | +| **XSS** | No `dangerouslySetInnerHTML` outside a sanitisation wrapper; CSP nonces in place; markdown via allowlist. Nothing sets a CSP today: the [standards index](../../../docs/standards/README.md#honest-status-today) row for Security Standards carves § HTTP Headers out to [Phase 11](../../../docs/roadmap/phase-11-production-hardening.md#security), so a missing nonce is not a Blocker until that phase sets one. Accepted P02d-6 G42 requires atomic four-color validation and a fixed style element, with whole-palette fallback; output must be safe without CSP. [The accepted package](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-accepted-answers) owns its proof obligations; acceptance alone is not delivery. | | **CSRF** | Server Actions / Auth.js session check; non-Action mutating fetches carry CSRF tokens. | | **Secrets** | No secret in source / appsettings / env-file-committed; reads through `ISecretProvider`. No log of token / password / national id. | | **PII redaction** | `[PiiSensitive]` fields carry `SensitiveTokenCatalog.RedactedValue` in audit snapshots — the property stays, so the diff still records *that* it changed; logs don't carry raw PII. | @@ -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. P02d-6/G43 selects the skeleton's accessibility gate; the broader axe/Playwright suite belongs to Phase 06. | +| 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. | A change without tests is incomplete; flag as Blocker unless the user explicitly deferred the test. diff --git a/CLAUDE.md b/CLAUDE.md index 404fa989..cd0b1b33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,8 +119,13 @@ Work stays on development. **P02d-6's decision pass is Accepted — 2026-10-09** [ADR-0027](docs/decisions/0027-frontend-i18n.md) and its [decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09) authorize four implementation steps and two independent review rounds per step. -The decision commit delivers no product pages. P02d-7 owns the browser demo and -Lighthouse; Phase 06 retains the full renderer/Studio expansion. +Step 1 implements the pinned i18n and document foundation, verified request-local +admission/content loaders, atomic color theming, chrome and controlled status/error/ +loading components. Its +[delivery record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) +owns current validation and review evidence. Catalog/course and lesson pages +remain Steps 2 and 3. P02d-7 owns the browser demo and Lighthouse; 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/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs index 37c4ac6c..e84843d7 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs @@ -23,7 +23,8 @@ namespace LearnStack.Tests.Integration.Database; /// <summary> /// Production Next/native TLS/SDK/API/PostgreSQL proof. The disposable renderer -/// owns its route; no diagnostic page is added to the shipped application. +/// separates the P5 transport probe from the actual P6 document/status foundation. +/// Its content consumer is test-owned until product routes land; no diagnostic route ships. /// </summary> [Collection(PublicReadTestGroup.Name)] [Trait(RequiresDocker.Key, RequiresDocker.Value)] @@ -32,8 +33,10 @@ public sealed class PublicServerRenderingTests(PublicReadFixture fixture) private const string SuppliedTrace = "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01"; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - [Fact] - public async Task Production_rendering_is_host_isolated_fresh_and_private_through_the_real_public_API() + [Theory] + [InlineData("transport")] + [InlineData("foundation")] + public async Task Production_rendering_is_host_isolated_fresh_and_private_through_the_real_public_API(string mode) { var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); var observed = new RenderingObservation(secret); @@ -75,6 +78,8 @@ public async Task Production_rendering_is_host_isolated_fresh_and_private_throug var started = false; var draft = false; var verified = false; + var foundationPosition = 0; + var foundationLocales = false; var tracePosition = 0; var beforeProtocol = -1; var beforeStock = -1; @@ -82,11 +87,20 @@ public async Task Production_rendering_is_host_isolated_fresh_and_private_throug var checkpoints = new List<string>(); try { + if (mode == "foundation") + { + // Test-owned membership extends only this scenario. Product reads + // still execute as learnstack_app through the real read-only API. + await fixture.ExecuteAsync(SeedData.English, + "INSERT INTO tenant_locales(tenant_id,locale,is_default,is_enabled,sort) VALUES(@tenant,'ar',false,true,8),(@tenant,'tr-TR',false,true,9),(@tenant,'tr',false,true,10)"); + foundationLocales = true; + } started = process.Start(); started.Should().BeTrue(); var errors = BoundedErrorsAsync(process.StandardError, deadline.Token); await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new { + mode, apiOrigin = origin, secret, locale, @@ -142,6 +156,35 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new await SetStatusAsync(firstCourse, firstCourse.Status); draft = false; break; + case "foundation-normal": + case "foundation-repeat": + case "foundation-missing": + var foundationRequests = observed.Requests.Skip(foundationPosition).ToArray(); + foundationRequests.Should().HaveCount(checkpoint == "foundation-missing" ? 5 : 3, + "metadata, document/layout and page share one request-local admission and content operation"); + foundationRequests.Count(request => request.Path == "/api/v1/public/site").Should() + .Be(checkpoint == "foundation-missing" ? 4 : 2); + foundationRequests.Count(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)).Should().Be(1); + foundationRequests.Single(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)) + .Locale.Should().Be(locale, "UI configuration cannot change the exact content API locale"); + foundationPosition = observed.Requests.Length; + break; + case "foundation-status": + case "foundation-head": + case "foundation-refused": + case "foundation-canonical": + var statusRequests = observed.Requests.Skip(foundationPosition).ToArray(); + statusRequests.Should().HaveCount(checkpoint switch + { + "foundation-status" => 8, + "foundation-head" => 2, + "foundation-canonical" => 3, + _ => 2 + }); + statusRequests.Should().OnlyContain(request => request.Path == "/api/v1/public/site", + "status pages and admission refusals must never query Education"); + foundationPosition = observed.Requests.Length; + break; case "verified": verified = true; break; @@ -155,7 +198,9 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new var errorOutput = await errors; process.ExitCode.Should().Be(0, errorOutput); verified.Should().BeTrue("the production fixture must execute every scenario"); - checkpoints.Should().Equal("build-complete", "trace-supplied", "trace-missing", "trace-malformed", "protocol-before", "protocol-after", "stock-before", "stock-after", "make-draft", "restore-published", "verified"); + if (mode == "transport") checkpoints.Should().Equal("build-complete", "trace-supplied", "trace-missing", "trace-malformed", "protocol-before", "protocol-after", "stock-before", "stock-after", "make-draft", "restore-published", "verified"); + else checkpoints.Should().Equal("build-complete", "foundation-normal", "foundation-repeat", "foundation-missing", + "foundation-status", "foundation-head", "foundation-refused", "foundation-canonical", "stock-before", "stock-after", "verified"); observed.Requests.Length.Should().BeInRange(1, 59, "the fixture stays within one real anonymous visitor budget"); observed.Requests.Should().OnlyContain(request => request.ValidHop, "the real caller uses the closed authenticated hop"); observed.Logs.Should().BeGreaterThan(0, "API log containment needs a nonempty real logging subject"); @@ -175,7 +220,13 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new catch (OperationCanceledException) { process.Kill(entireProcessTree: true); await process.WaitForExitAsync(CancellationToken.None); } } } - finally { if (draft) await SetStatusAsync(firstCourse, firstCourse.Status); } + finally + { + if (draft) await SetStatusAsync(firstCourse, firstCourse.Status); + if (foundationLocales) + await fixture.ExecuteAsync(SeedData.English, + "DELETE FROM tenant_locales WHERE tenant_id=@tenant AND locale IN ('ar','tr-TR','tr')"); + } } } @@ -339,7 +390,7 @@ private static async Task<string> BoundedErrorsAsync(StreamReader stream, Cancel ? result.ToString() : "Production rendering fixture exited unexpectedly."; } - private sealed record ObservedRequest(string Path, string TraceId, bool ValidHop); + private sealed record ObservedRequest(string Path, string Locale, string TraceId, bool ValidHop); /// <summary>Observes the real Kestrel request without changing API resolution or transactions.</summary> private sealed class RenderingObservation(string secret) : IStartupFilter, ILogEventSink @@ -386,7 +437,7 @@ public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) = && !headers.ContainsKey("Authorization") && !headers.ContainsKey("Cookie") && !headers.ContainsKey("X-Tenant-Id") && !headers.ContainsKey("X-Organization-Id") && !headers.ContainsKey("X-Locale") && !headers.ContainsKey("X-Forwarded-For"); - _requests.Enqueue(new(context.Request.Path.Value!, Activity.Current?.TraceId.ToHexString() ?? "", validHop)); + _requests.Enqueue(new(context.Request.Path.Value!, context.Request.Query["locale"].ToString(), Activity.Current?.TraceId.ToHexString() ?? "", validHop)); } await continuation(); }); diff --git a/docs/architecture/12-localization.md b/docs/architecture/12-localization.md index 2e45e178..834056e6 100644 --- a/docs/architecture/12-localization.md +++ b/docs/architecture/12-localization.md @@ -19,7 +19,8 @@ Initial renderer scope: - Document direction and logical layout properties apply from the first public renderer, including an enabled RTL locale; they are not deferred until a tenant requests a redesign. Accepted P02d-6 G39/G43 selects the mapping and proofs; - implementation remains pending. + Step 1 implements direction selection and fallback chrome; actual content-page + language proofs remain later P02d-6 steps. - P02d-6 accepts initial English and Turkish platform UI catalogues in [ADR-0027](../decisions/0027-frontend-i18n.md). Additional UI translations are added when authored; their absence must not narrow enabled content locales. @@ -298,7 +299,7 @@ The routing consequences follow directly, and are behaviour rather than defects: ## UI String Catalogue -**Accepted P02d-6 G39 — 2026-10-09; implementation pending.** +**P02d-6 Step 1 G39 foundation implemented — 2026-10-10.** [ADR-0027](../decisions/0027-frontend-i18n.md) selects server-first `next-intl` 4.14.9 and one app-local catalogue home: @@ -337,9 +338,12 @@ when undescribed. UI groups carry their actual selected language/direction, and Pattern B labels carry their resolved locale. A test-owned enabled `ar` route proves RTL content with English fallback UI, without altering seed inventory or claiming an Arabic catalogue. Pattern A content never gains cross-locale fallback. -The dependency, catalogues, runtime and proofs are not installed/delivered by this -acceptance. Phase 04 inherits the foundation for CMS/Studio; Phase 06 owns the full -Studio/portal consumers. +Step 1 implements the pinned dependency/plugin, catalogues, request runtime and +key/ICU/callsite controls. The +[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) +owns current verification and review evidence. Actual content-page proofs +remain later P02d-6 steps. Phase 04 inherits the foundation for CMS/Studio; Phase 06 +owns the full Studio/portal consumers. API error codes remain stable machine identifiers. Problem Details carries backend `lockey_*` message data, not translated text; supported consumer resources diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index 0465d5a4..d734b60f 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -27,11 +27,16 @@ native method/upgrade admission and pinned URL controls. The remediation record owns execution and independent review evidence; the ongoing rules live in [Frontend Standards](../standards/07-frontend-architecture.md#native-admission-and-url-identity). -**P02d-6 decisions Accepted — 2026-10-09; implementation pending.** +**P02d-6 decisions Accepted — 2026-10-09; Step 1 foundation implemented.** [ADR-0027](../decisions/0027-frontend-i18n.md) selects the UI foundation; the [decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09) selects pages, status/navigation, safe theme and product/accessibility proofs. -Acceptance installs no dependency and delivers no product page or passing proof. +Step 1 installs the pinned runtime and supplies shared request-local loaders, +document language/direction, atomic theme injection, chrome and controlled +status/error/loading components. Actual catalog/course/lesson pages remain Steps 2 +and 3. The +[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) +owns current validation and review evidence. > **P02d-4 Accepted design — 2026-10-03.** Step 2 delivers site bootstrap; > Steps 3–4 deliver Education and contract/SDK controls; P02d-5 owns the server consumer. @@ -67,14 +72,14 @@ frontend/ sessions/ api/ # only thin BFF proxies, see "Data Fetching" components/ - public/ # Accepted P6 plain-string views; pending + public/ # P6 chrome/states; content views pending blocks/ # built-in primitive page blocks ui/ # design-system primitives lib/ api/ auth/ tenant/ - i18n/ # Accepted ADR-0027 home; pending + i18n/ # ADR-0027 foundation implemented in P6 Step 1 request.ts messages/ en/public.json @@ -89,7 +94,8 @@ frontend/ > **Phase 02d ownership.** Accepted G41 places synchronous plain-string views in > `apps/web/src/components/public/`; G39 selects the app-local i18n home above. -> Both remain implementation-pending. P02d-5/G36 delivers Node middleware in +> Step 1 implements i18n and chrome/state views; content views remain Steps 2/3. +> P02d-5/G36 delivers Node middleware in > `apps/web/src/middleware.ts`. The ownership and accepted entry rule are recorded in > [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). > The tree above is the earlier target layout, not a shipped-directory census. @@ -215,7 +221,11 @@ URL sink, HTML, Markdown, linkification or richer primitive is introduced. Full menus, media, preview, authored error pages and Studio remain Phase 06. UI configuration shares verified request-local admission without i18n routing -middleware or another locale authority. UI fallback does not change API content +middleware or another locale authority. Admission/content loaders use a private +weak memo keyed by Next's exact request-store headers object, including error +rendering; header values never key shared representations. `getPublicUi` uses +React cache only for selected messages/translators inside an RSC render. UI fallback +does not change API content locale; document/UI/label language and RTL rules live in [Localization](12-localization.md#ui-string-catalogue). @@ -226,11 +236,11 @@ only the whole typed four-color theme or null; frontend safe CSS defaults remain owned here, without backend duplication. Baseline colors apply independently of plan. Effective WhiteLabelBranding removes LearnStack attribution only. Public responses expose no setting keys, raw/partial JSON or organization merge. Accepted -G42 selects document injection in P02d-6; implementation remains pending. +G42 document injection is implemented in P02d-6 Step 1. **P02d-2/3 delivered foundation — 2026-10-03.** The theme writer and typed settings -accessor are implemented. Step 2 delivers the anonymous projection; renderer -injection remains P02d-6. The +accessor are implemented. P02d-4 Step 2 delivers the anonymous projection; +P02d-6 Step 1 implements renderer injection. The [whole-theme contract](../modules/tenancy/README.md#whole-theme-setting-and-public-boundary) selects only tenant-wide color values and no remote subresource. Organization merges, logo/font URLs and Studio below are Phase 06 targets, not this packet's behavior. @@ -255,8 +265,8 @@ and that organization has a `BrandingOverride`, the override merges on top of the tenant defaults before injection — the merged token set is the source of truth for the SSR'd page. -P02d-6's target is a themed first paint through safe SSR injection; no renderer -injection is implemented yet. +P02d-6 Step 1 implements safe SSR injection for themed first paint; the delivery +record owns production verification and review evidence. Logo/font assets and uploads are Phase 06 targets, requiring safe media and subresource contracts before their writers or consumers. P02d-2's accepted color-only diff --git a/docs/decisions/0027-frontend-i18n.md b/docs/decisions/0027-frontend-i18n.md index d45d1ce7..93f2c82e 100644 --- a/docs/decisions/0027-frontend-i18n.md +++ b/docs/decisions/0027-frontend-i18n.md @@ -262,6 +262,30 @@ Existing [public source-boundary controls](../standards/21-architecture-tests-ca and [No_Architecture_Test_Is_Skippable](../standards/21-architecture-tests-catalogue.md#no_architecture_test_is_skippable) remain mandatory; no second skip-refusal rule is created. +## Amendments + +### Amendment 1 — P02d-6 document foundation delivery (2026-10-10) + +P02d-6 Step 1 implements the exact next-intl 4.14.9 dependency/lockfile and App +Router plugin, complete app-local English/Turkish `public` catalogues, server-only +request configuration and key/ICU/argument/callsite controls with planted failures. +UI fallback preserves content locale; formatter failures select bounded copy. + +Admission/content loaders use a private weak identity memo keyed only by Next's +exact request-store headers object to retain same-request work across framework +error rendering. Header values, host, locale and the ingress envelope are not +cache keys; no representation crosses incoming requests or gains a new authority +carrier. `getPublicUi` uses React cache for selected messages/translators within +an RSC render, separately from admission/content-read deduplication. + +Step 1 also supplies document language/direction, atomic four-color style-element +injection, tenant chrome, bounded interactive error labels, loading/state views +and the live-admitted fixed status page, which performs no Education read. +Catalog/course pages remain Step 2; lesson presentation remains Step 3. The +[Step 1 delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) +owns current validation, review and product/accessibility proof status. The +Decision, Status, Date and Deciders remain unchanged. + ## References - [ADR-0008 — Localization Schema](0008-localization-schema.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index d0ecdf11..544f7359 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -46,7 +46,7 @@ an amendment is not a lifecycle status change. | 0022 | [Custom Domain & TLS](0022-custom-domain-tls.md) | Hub-owned custom domain admin; DNS-01 + HTTP-01 + Let's Encrypt; APISIX hot-reload | | 0023 | [Strongly-Typed ID Source Generator — Vogen](0023-strongly-typed-id-source-generator.md) | Vogen as the source generator for both IDs and value objects; `[ValueObject<Guid>]` annotation; EF + JSON + ASP.NET + OpenAPI emitters out of the box (Amendment 1: Vogen 7.0.0 pin + architecture-test placement, 2026-05-21; Amendment 2: `UserId` / `TenantId` / `OrganizationId` are cross-cutting and live in `LearnStack.SharedKernel`, 2026-08-10; **Amendment 3: `IStronglyTypedId` gains `IsInitialized()` and is no longer a pure marker, 2026-08-10**; Amendments 4–7, 2026-08-27 to 2026-09-01: `idempotency_keys` mints no id, `gen_uuid_v7()` is not a PostgreSQL function, the retroactive disclosure of the 2026-05-21 edit, and an id's own formatting is not a wire format; **Amendment 8, 2026-09-07: a module-local identifier crosses a cross-module contract as `Guid`**) | | 0024 | [API Versioning Policy](0024-api-versioning-policy.md) | URL-based `/v{N}/`; 6-month deprecation window; RFC 8594 `Sunset` + `Deprecation` headers; OpenAPI `deprecated` + `x-sunset` extensions; 410 Gone with RFC 7807 on sunset | -| 0027 | [Frontend UI Localization with next-intl](0027-frontend-i18n.md) | Accepted 2026-10-09: next-intl 4.14.9, server-first app-local ICU catalogues, dotted UI keys and verified request-locale source; P02d-6 first consumer, implementation pending; Phase 04 consumes the foundation | +| 0027 | [Frontend UI Localization with next-intl](0027-frontend-i18n.md) | Accepted 2026-10-09: next-intl 4.14.9, server-first app-local ICU catalogues, dotted UI keys and verified request-locale source; P02d-6 Step 1 implements the foundation; actual content pages remain Steps 2 and 3; Phase 04 consumes the foundation | | 0028 | [`audit_log` Partition Management — Hangfire Recurring Job](0028-audit-log-partition-management.md) | Daily `learnstack:audit:partition-management` Hangfire job; create-ahead 2 months; drop only on platform-max retention horizon; row-level purge separate; no `pg_partman` dependency (Amendment 2026-09-07: Packet 9 creates a **plain, unpartitioned** table and executes none of this ADR's DDL; `Partition_Manager_Job_Is_Registered_AtStartup` moves to Phase 11 with the job it guards) | | 0029 | [Object Storage — SeaweedFS](0029-object-storage-seaweedfs.md) | Self-hosted SeaweedFS behind the existing `IStorageProvider` S3 contract; partially supersedes ADR-0002's MinIO row | | 0030 | [Redis-compatible Store — Valkey](0030-redis-compatible-store-valkey.md) | Valkey (Linux Foundation, BSD-3-Clause) for the cache + Dapr state-store backend; RESP-protocol drop-in; partially supersedes ADR-0002's Redis row | diff --git a/docs/glossary.md b/docs/glossary.md index bea2f92d..acd9fc05 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -200,7 +200,7 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | Term | Definition | |------|------------| | **TenantBranding** | The tenant's presentation tokens. Not an aggregate of its own: the values are tenant settings held in `tenant_settings` ([Frontend Architecture Standards § Tenant Branding](standards/07-frontend-architecture.md#tenant-branding)). G16(a–e) is Accepted and delivered in P02d-2; the [Tenancy contract](modules/tenancy/README.md#p02d-2-accepted-locale-and-branding-contract) owns the keys and value grammar. Anonymous projection and attribution are delivered G16(f/g) in P02d-4 Step 2, and server-rendered injection remains G42, in [Phase 02d's decision register](roadmap/phase-02d-walking-skeleton.md#the-decision-register). | -| **`branding.theme`** | P02d-2 delivers the writer for a single tenant-wide TenantSetting document with four validated color fields; one root/version protects contrast during concurrent replacement. The [Tenancy spec](modules/tenancy/README.md#whole-theme-setting-and-public-boundary) owns the command-local registry; other generic setting keys remain legal. Anonymous projection is delivered by P02d-4 Step 2; rendering remains P02d-6. | +| **`branding.theme`** | P02d-2 delivers the writer for a single tenant-wide TenantSetting document with four validated color fields; one root/version protects contrast during concurrent replacement. The [Tenancy spec](modules/tenancy/README.md#whole-theme-setting-and-public-boundary) owns the command-local registry; other generic setting keys remain legal. Anonymous projection is delivered by P02d-4 Step 2; P02d-6 Step 1 implements atomic document color injection. | | **OrganizationBranding** | A planned per-organization design-token override owned by [Phase 06](roadmap/phase-06-renderer-admin-studio.md). Its partial token merge is not implemented by P02d-2/3; the generic settings scope model does not authorize an organization override of `branding.theme`. | ## Module-Loading Contracts @@ -294,7 +294,7 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | **`Result<T>`** | The sealed record in `LearnStack.SharedKernel.Results` implementing `IResultBase`: carries `IsSuccess`, `Value`, optional `Error`, and an optional `SuccessMessage` (a `LocalizedMessage`). Primary constructor is `internal`; callers go through `Ok` (throws on null value) / `Fail`. Application + Domain layer methods return `Result<T>` for **expected** outcomes (validation failed, not found, forbidden, business-rule violation). Exceptions are reserved for **unexpected** bugs / infrastructure faults. `Result<None>` is the payload-less success shape. See [09-error-handling.md § Two-Track Model](standards/09-error-handling.md) and [ADR-0032](decisions/0032-exception-handling-logging-and-observability.md). | | **`Error`** | The sealed record `(LocalizedMessage Message, IReadOnlyDictionary<string, IReadOnlyList<LocalizedMessage>>? Details)` that travels inside `Result<T>.Fail(error)`. `Code` is the **unprefixed stable identifier** (e.g. `"validation_failed"`) derived by stripping the `lockey_` prefix from `Message.Key` — so routing logic (`Result.ToActionResult()`, Problem Details writers) and frontend localization read consistent values without manual sync. Field-level `Details` flow as `LocalizedMessage` lists, keeping the prefix invariant uniform across the entire error payload. See [09-error-handling.md § Result Type](standards/09-error-handling.md). | | **`LocalizedMessage`** | The sealed record `(string Key, IReadOnlyDictionary<string,string>? Params)` in `LearnStack.SharedKernel.Localization`. `Key` MUST begin with `lockey_`, enforced at construction; this is the backend-message wire namespace, distinct from general frontend UI identifiers. Consumers own supported error resources; the SDK normalizes errors and contains no translations. See [Error Handling § Mapping Problem Details → UI](standards/09-error-handling.md#mapping-problem-details--ui). Equality is structural (Key + Params); ctor defensively copies Params; empty params normalised to null. `Params` values must be plain text — resolved messages use React text nodes, never `dangerouslySetInnerHTML`. Per [Phase 02a Packet 2](roadmap/phase-02a-kernel-tenancy.md). | -| **UI Catalogue** | App-owned platform messages under `frontend/apps/web/src/i18n/messages/<locale>/<namespace>.json`. Accepted [ADR-0027](decisions/0027-frontend-i18n.md) selects exact next-intl 4.14.9 and complete `en`/`tr` `public` catalogues for P02d-6; implementation remains pending. General UI identifiers are dotted feature keys, distinct from backend `lockey_*`. Whole-catalogue language fallback never changes admitted content locale; missing used keys in supported catalogues fail validation. The SDK contains no translations. | +| **UI Catalogue** | App-owned platform messages under `frontend/apps/web/src/i18n/messages/<locale>/<namespace>.json`. Accepted [ADR-0027](decisions/0027-frontend-i18n.md) selects exact next-intl 4.14.9 and complete `en`/`tr` `public` catalogues, implemented by P02d-6 Step 1 with the server request runtime and catalogue controls. General UI identifiers are dotted feature keys, distinct from backend `lockey_*`. Whole-catalogue language fallback never changes admitted content locale; missing used keys in supported catalogues fail validation. The SDK contains no translations. | | **`None`** | The `readonly record struct` value used as `Result<None>` when a command/query succeeds without returning data — replaces the `Result<T>` with `IsSuccess = true` and `Value = null` shape Standards 09 § Forbidden bans. | | **`IClock` / `IRandom` / `IGuidFactory`** | The three deterministic-test abstractions in `LearnStack.SharedKernel`. Production code never reads `DateTime.UtcNow`, instantiates `System.Random`, or calls `Guid.NewGuid()` directly — those calls go through the abstractions so tests pin the values via `FixedClock` / `FixedRandom` / `FixedGuidFactory`. Per Standards 02 § Time. | | **`UserId`** | The cross-cutting strongly-typed actor identifier in `LearnStack.SharedKernel.Identifiers` (Vogen `[ValueObject<Guid>]`). Audit columns on `AuditableEntity<TId>` reference users by `UserId` so the "no raw `Guid` on the public surface" rule (Standards 02) holds even though the Identity module lands in Phase 02b. Identity consumes the same type when it ships. | diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 86215e71..17f82448 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4202,6 +4202,69 @@ Five Release corpus cases and seven ADR workflow cases pass with zero failures o skips. Relative-file/fragment, added-prose wrapping and diff checks pass. These are documentation checks; no product-runtime or accessibility proof is claimed. +### P02d-6 Step 1: Localization and document foundation + +**Implementation in progress — 2026-10-10; both independent review rounds not +started.** Work stays on `development`. This record covers the foundation only; +actual catalog/course pages remain Step 2 and lesson presentation remains Step 3. +It does not close P02d-6 or any product/manual accessibility criterion. + +#### Delivered foundation + +- Exact `next-intl` 4.14.9 dependency/lockfile, App Router plugin and server-only + request configuration, with complete app-local English/Turkish `public` + catalogues. ICU/key/argument and production-callsite census controls include + planted missing-key, absent-from-all, misspelled-callsite, malformed-ICU and + argument-mismatch failures. Formatter failures select bounded translated copy. +- Shared server-only request-local admission and content loaders re-verify ingress, + read live site configuration and take content locale/route/pagination identity + from the signed target. The request loader imports neither next-intl nor + messages. UI fallback and runtime direction selection preserve content identity. + A private weak identity memo keyed only by Next's exact request-store headers + object retains same-request admission/content work across error rendering; no + header value, host or envelope keys a representation cache. `getPublicUi` uses + React cache only to reuse selected messages/translators inside an RSC render; + it does not own admission or content-read deduplication. +- Root document language/direction, atomic validation of all four color slots + before fixed style-element emission, and tenant chrome with one main/skip + target, catalog anchor and effective conditional platform attribution. Null or + malformed themes retain the entire existing CSS default palette. +- Closed translated state views, loading and unexpected-error boundaries. The + interactive retry boundary receives bounded labels only. The admitted fixed + status page supplies localized title/noindex metadata and a catalog recovery + link, echoes no original target and performs no Education read. Shared resource + admission selects the approved local 307→404 path for missing content. + +The production fixture adds a separate foundation-consumer mode while retaining +P5's isolated transport probe. The temporary consumer exercises foundation code; +it does not deliver or prove unchanged catalog/course/lesson product pages. +The real production fixture verifies three API calls for a normal document, +three again on its next request and five across the missing-resource redirect +chain. Parallel status requests, bodyless HEAD, renewed host/locale refusals and +canonical redirects pass. Enabled `ar`, `tr` and `tr-TR` coverage checks visible +localized status HTML/metadata, content direction and labelled English fallback +without an Education read. The historical seed remains unchanged. + +#### Verification and review boundary + +The guarded frontend run passes **817 tests: 757 web and 60 SDK**, with zero +skips/todos. All **198 Release architecture cases** pass. The production build +passes with the dynamic status route and root language/theme injection; the real +fixture checks that building performs no API prerender calls. Workspace lint and +typecheck pass. The focused real-API production integration run passes **16 cases, +zero failures/skips**, retaining the separate P5 transport and cleanup controls. +Native ingress regression verification passes TLS, method/upgrade admission, +query redirects, production containment and development HMR. Missing static assets +are refused before tenant bootstrap even when Next invokes the root fallback. +No unchanged content-page, +browser E2E, Lighthouse or manual accessibility pass is claimed here. + +All five P02d-6 architecture-test rules remain **Registered** until their complete +named proof obligations are verified; a foundation test alone does not promote the +broader page-state or accessibility rule. Accessibility remains **Adopted**. +Round 1 and Round 2 have not started. Commit, both independent rounds and affected +revalidation precede Step 2 under the accepted implementation loop. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/roadmap/phase-04-cms-media-pages.md b/docs/roadmap/phase-04-cms-media-pages.md index 86bbe289..ef33d580 100644 --- a/docs/roadmap/phase-04-cms-media-pages.md +++ b/docs/roadmap/phase-04-cms-media-pages.md @@ -29,7 +29,7 @@ Decisions consumed in this phase: directly here: presentation and content shape are inside it, so nothing in this phase needs a code branch per tenant. -**Accepted foundation — 2026-10-09; implementation pending in P02d-6.** +**P02d-6 Step 1 foundation implemented — 2026-10-10.** [ADR-0027](../decisions/0027-frontend-i18n.md) closes G39 at the first public UI consumer: exact `next-intl` 4.14.9, server-first request configuration and app-local `src/i18n/messages/<locale>/<namespace>.json` catalogues. This phase consumes that @@ -37,7 +37,10 @@ foundation and adds CMS/Studio message coverage; it does not reselect the librar or own its initial installation. Tenant-authored content localization remains this phase's responsibility. The [P02d-6 package](phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09) -records accepted page/status behavior and distinguishes it from delivery. +records accepted page/status behavior; its +[Step 1 delivery record](phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) +owns foundation verification. Actual content pages and product proofs remain +later P02d-6 steps. ## Scope diff --git a/docs/standards/07-frontend-architecture.md b/docs/standards/07-frontend-architecture.md index e205aec0..5fca1082 100644 --- a/docs/standards/07-frontend-architecture.md +++ b/docs/standards/07-frontend-architecture.md @@ -44,8 +44,8 @@ frontend/ layout.tsx # root layout middleware.ts # ingress verification and live locale/path admission components/ - public/ # Accepted P6 synchronous views; implementation pending - i18n/ # Accepted ADR-0027 home; implementation pending + public/ # P6 Step 1 chrome/states; content views pending + i18n/ # ADR-0027 foundation implemented in P6 Step 1 request.ts messages/ en/public.json @@ -146,15 +146,20 @@ public-site URL canonicalization and header transport remain P02d-5. An unavailable/no-locale site supplies no synthesized `en` default. - Client-side locale switching navigates to the new locale path. -**Accepted P02d-6 G39 — 2026-10-09; implementation pending.** `next-intl` -request configuration uses the same server-only request-cached admission loader +**P02d-6 Step 1 foundation implemented — 2026-10-10.** `next-intl` +request configuration uses the same server-only request-local admission loader as document/layout/page consumers. That loader re-verifies the ingress envelope, takes the canonical locale from its signed target and checks live enabled membership before selecting messages. It imports neither next-intl nor messages; configuration adds no bootstrap call or cycle. No i18n routing middleware, `requestLocale`, callsite locale override, cookie or `Accept-Language` replaces -this authority. [Standards 08](08-localization.md#strings-in-code) owns catalogue -fallback and language attributes. +this authority. The private identity memo keys admission/content work only by +Next's exact request-store headers object, retaining same-request work across +framework error rendering. It never keys a cache by header values, host or +envelope. `getPublicUi` uses React cache for selected messages/translators inside +an RSC render; it does not deduplicate admission/content reads. +[Standards 08](08-localization.md#strings-in-code) owns catalogue fallback and +language attributes. ## SDK @@ -205,7 +210,9 @@ owns the source, pin, diff policy, bootstrap exception and required-check rollou target; observed `params`/`searchParams` do not replace it. - Each route group has its own `layout.tsx`, `loading.tsx`, `error.tsx`. -**Accepted P02d-6 G40 — 2026-10-09; implementation pending.** Keep the three +**Accepted P02d-6 G40 — 2026-10-09.** Step 1 implements shared resource admission, +controlled state views and the fixed status page; actual content pages remain +Steps 2 and 3. Keep the three `/{locale}/courses` list/course/lesson routes and minimal tenant chrome. Use plain same-host relative anchors, including opaque catalog and outline pagination; disable automatic prefetch. A new document request re-reads API state. @@ -232,8 +239,11 @@ routes render dynamically with no-store API transport. No ISR, positive `revalidate`, `generateStaticParams`, `unstable_cache` or shared bootstrap/data/route cache is permitted. Request-local reuse is isolated to one incoming request. Freshness is the next new server/document request, not client Router Cache history. -Disable local Server Component HMR caching. Accepted P02d-6/G41 selects components; -implementation remains pending. +Disable local Server Component HMR caching. P02d-6 Step 1 implements document +loaders, chrome, state views and atomic theme injection. Accepted G41 content +components remain Steps 2 and 3. The +[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) +owns verification and review status. P5 delivers the dynamic layout, transport and source/runtime proofs. Test-owned production routes exercise the real API; P6 public pages are not delivered by them. @@ -285,8 +295,8 @@ names the controls; the remediation record owns execution evidence. **G16(a–e)/G21 writer contract delivered in P02d-2 — 2026-10-02.** The [Tenancy contract](../modules/tenancy/README.md#whole-theme-setting-and-public-boundary) selects one whole-theme color document, contrast refusal, no organization override -and no font/logo/URL/layout value. Accepted G42 selects document injection below; -implementation remains pending. +and no font/logo/URL/layout value. P02d-6 Step 1 implements the accepted G42 +atomic document injection below. P02d-4 accepts complete typed theme or null and attribution-only entitlement; Step 2 delivers that public projection. diff --git a/docs/standards/08-localization.md b/docs/standards/08-localization.md index 53dff92d..b4955859 100644 --- a/docs/standards/08-localization.md +++ b/docs/standards/08-localization.md @@ -42,10 +42,12 @@ uses exact enabled query locale; display-only Pattern B values carry authored `{value,locale}`. Neither fallback nor headers authorize a content locale. Internal fallback remains the delivered P02d-3 contract; language attributes are P02d-6. -**Accepted P02d-6 UI foundation — 2026-10-09; implementation pending.** -[ADR-0027](../decisions/0027-frontend-i18n.md) selects next-intl and one app-local -catalogue home, with UI-language fallback separate from content admission. P6 is -the first consumer; Phase 04 inherits this foundation for CMS/Studio coverage. +**P02d-6 Step 1 UI foundation implemented — 2026-10-10.** +[ADR-0027](../decisions/0027-frontend-i18n.md) supplies next-intl and one app-local +catalogue home, with UI-language fallback separate from content admission. The +[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) +owns validation; actual content pages remain Steps 2 and 3. Phase 04 inherits this +foundation for CMS/Studio coverage. ## URL Strategy @@ -221,8 +223,9 @@ applying the fallback chain. - Frontend: server-first `next-intl` **4.14.9**, with app-local `apps/web/src/i18n/messages/{en,tr}/public.json` and `src/i18n/request.ts`. - This is Accepted in P02d-6; dependency installation, catalogues and checks remain - pending. No shared i18n package is created without ADR-0009's duplication trigger. + P02d-6 Step 1 implements the dependency/plugin, complete catalogues and key/ICU/ + argument/callsite controls. No shared i18n package is created without + ADR-0009's duplication trigger. - Backend: localized strings live in resource files under each module. - Strings are referenced by key, never duplicated: @@ -306,7 +309,8 @@ runtime internationalization data; an undescribed admitted tag falls back to `ltr`, never refusal. UI fallback groups retain their own direction. Test-owned enabled `ar` content proves `lang="ar"`/`dir="rtl"` and labelled English fallback UI independently; it neither rewrites the seed nor claims an Arabic UI catalogue. -These renderer controls remain implementation obligations. +Step 1 implements direction selection and labelled fallback chrome; actual +content-page and production RTL evidence remains a later P02d-6 obligation. ## Admin Studio UI diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 3e0d3a18..5ec71ad4 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -6,7 +6,7 @@ [ADR-0054 Bounded Public Renderer Admission](../decisions/0054-bounded-public-renderer-admission.md) (accepted replacement proofs implemented), [ADR-0027 Frontend UI Localization](../decisions/0027-frontend-i18n.md) -(P02d-6 rules Registered; implementation pending). The catalogue grows as +(P02d-6 rules Registered; complete proofs pending). The catalogue grows as subsequent ADRs and phases land their tests; per-test ownership stays with the originating ADR / standard. @@ -4198,7 +4198,10 @@ verification pass; the delivery record owns execution evidence. - **Status:** **Implemented** — `public-boundaries.test.ts` names this rule and pins the nonempty production TypeScript graph, marker, direct/global/aliased fetches, hop setters and transitive Client Component dependencies. Clean/planted - controls cover imports, reexports and literal dynamic imports. Arbitrary eval, + controls cover imports, reexports and literal dynamic imports. P02d-6 Step 1 + includes server configuration and bundled JSON catalogues in the graph and + plants direct Client Component imports of each bundle; ordinary public JSON + remains admissible. Arbitrary eval, runtime reassignment, external package bodies and native MJS are outside the source scan; production socket/TLS and `PublicServerRenderingTests` prove the runtime boundary, actual client-import rejection and private-value containment. @@ -4215,7 +4218,13 @@ verification pass; the delivery record owns execution evidence. - **Status:** **Implemented** — `public-boundaries.test.ts` names this rule, checks the public layout policy and follows public/helper imports for shared storage, forbidden Next cache APIs and static/revalidation overrides. Clean/planted - controls accompany it; request-local React cache remains allowed. + controls accompany it; request-local React cache remains allowed. P02d-6 Step 1 + also constrains the complete AST shape of `request-memo.ts`: a private weak map + keyed only by Next's exact request-store headers object, publish-before-load + semantics and retained rejected work. Planted strong maps, copied/value/caller + keys, eager loads, rejection retries and exposed/additional collections fail. + Its unit controls distinguish overlapping equal-header requests and subsequent + requests; this does not claim the pending complete P6 product-page proof. `PublicServerRenderingTests` proves cold/interleaved same-path HTML/RSC host isolation and next-request publication freshness as `learnstack_app`. It owns disposable routes, not P6 product pages or P7 browser delivery. diff --git a/docs/standards/README.md b/docs/standards/README.md index 04ce7b5f..b5135e13 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -67,7 +67,7 @@ bookkeeping pass. ### Honest status today -The table below describes the implementation as of 2026-10-09: +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 merged. P02d-5's ingress, configured server caller, dynamic entry and frontend guards are delivered through PR #26; the @@ -75,9 +75,12 @@ guards are delivered through PR #26; the owns its final review and CI evidence. P6's [decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09) and [ADR-0027](../decisions/0027-frontend-i18n.md) are Accepted — 2026-10-09; -dependency installation, product pages and their proofs remain pending. P7 owns -demo/browser/Lighthouse. Each packet's delivery record owns actual review and -validation evidence; acceptance alone promotes no standard or test status. +P6 Step 1 implements the pinned i18n/document foundation, shared request-local +loaders, chrome/states and atomic theme injection. Its +[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) +owns current validation and review evidence. Actual catalog/course/lesson +pages and product/accessibility proofs remain later P6 steps. P7 owns +demo/browser/Lighthouse. Acceptance alone promotes no standard or test 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 @@ -91,12 +94,12 @@ included. | 00 | [Principles](00-principles.md) | **Active** | Governs every PR and every ADR; principles 1, 16 and 17 are already deciding live scope questions. | | 01 | [Architecture Standards](01-architecture-standards.md) | **Active** | Module layout shipped; `ModuleDomain_DoesNotDependOn_*` and the planted-violation meta-test are green. | | 02 | [Backend Coding](02-backend-coding.md) | **Active** | MediatR pipeline, `Result<T>`, `IClock`, the `LS0001` analyzer and the pipeline-order test all ship. Packet 6 brought the first `DbContext`, the first aggregates and the ambient unit of work, so its EF Core and domain-modelling clauses became live. Tenancy, Customization, Audit and Education now implement domain and persistence code. | -| 03 | [Frontend Coding](03-frontend-coding.md) | **Active** | ESLint and TypeScript strict mode are configured, the Vitest harness runs, and Packet 10 added the two rules this document's § Forbidden names — the direct-`fetch` ban and `dangerouslySetInnerHTML` outside the sanitised-HTML primitive — with a test that lints fixtures through the app's own configuration, because a configured rule and a missing one look identical from a green `pnpm lint`. P02d-5 delivers trusted entry/caller controls; Accepted P02d-6 adds UI/ICU and synchronous public-view obligations, whose implementation remains pending. | +| 03 | [Frontend Coding](03-frontend-coding.md) | **Active** | ESLint and TypeScript strict mode are configured, the Vitest harness runs, and Packet 10 added the two rules this document's § Forbidden names — the direct-`fetch` ban and `dangerouslySetInnerHTML` outside the sanitised-HTML primitive — with a test that lints fixtures through the app's own configuration, because a configured rule and a missing one look identical from a green `pnpm lint`. P02d-5 delivers trusted entry/caller controls; P02d-6 Step 1 implements complete UI catalogues, ICU/callsite controls and synchronous chrome/state views; actual content views remain Steps 2 and 3. | | 04 | [API Design](04-api-design.md) | **Active** | Packet 4 shipped the versioned route convention and its startup guards, one Problem Details shape on every error including the framework-minted ones, cursor pagination, the sort grammar, idempotency keys, ETag concurrency, correlation ids, the request-body limit and the tenancy edge — each with tests in the required `backend` check. P02d-4 adds four institution public GET/HEAD pairs, served/snapshot contract controls and compatibility CI; application writers remain unrouted. | | 05 | [Database](05-database.md) | **Active** | Packet 6 applied it — two migration chains, ten tables — and Packets 8 and 9 took it to four chains and seventeen data tables. P02d-1 Step 2 adds Education: five chains and twenty-one data tables (twenty-six including five migration-history tables), with parent-mirror and Pattern A proofs. The four-role model and the canonical RLS template this document owns — `ENABLE` **and** `FORCE`, one `AND`-ed policy per table, an explicit `WITH CHECK` — are asserted against a real PostgreSQL as `learnstack_app`. Its § Concurrency, § Table classes, § Indexes and § GRANT matrix each have a test that fails without them. Partitioning and the retention job are still ahead. | | 06 | [Testing](06-testing.md) | **Active** | Unit, architecture and contract suites, and the Docker-free integration tests, run in the required `backend` job — Packet 4 removed the filter that used to exclude the integration assembly, which by then held the only tests that could catch an unversioned route. The Docker-bound `backend-integration` job activated in Packet 6 with the four-role provisioning suite; the split is by `[Trait("Requires","Docker")]` and the two jobs' filters are exact complements. The `backend-integration` job is required as of P02d-1, 2026-09-14 ([CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main)). | | 07 | [Frontend Architecture](07-frontend-architecture.md) | **Active** | The one-app rule is mechanical — `Frontend_Has_Only_The_Web_App` fails a second application in this repository — and the route groups it prescribes exist as layouts. The rest, the server/client split and the tenant context an SDK call carries, is exercised first in [Phase 02d](../roadmap/phase-02d-walking-skeleton.md): Active for what ships, and the phase that adds components is the one that tests them. | -| 08 | [Localization](08-localization.md) | **Active** | Packet 6 shipped `tenant_locales` and the slug schema. The partial unique default index enforces at most one default, not existence of a default for every enabled set; P02d-2 ships supported-write validation and the default-enabled CHECK constraint; raw deletes can still leave an enabled locale set without a default. `LocalizedText` and `LocalizedMessage` ship with their own cases, and API errors are keyed. P02d-1 shipped Education Pattern A satellites, canonical locales and flat localized-slug uniqueness; P02d-4 Step 3 delivers exact enabled public locale admission and response labels. P02d-5 Step 3 delivers membership-first redirect G36. Accepted ADR-0027/G39 selects next-intl 4.14.9 and app-local `src/i18n/messages/{en,tr}/public.json` at P6; installation/runtime/checks remain pending. Phase 04 inherits the foundation for CMS/Studio. | +| 08 | [Localization](08-localization.md) | **Active** | Packet 6 shipped `tenant_locales` and the slug schema. The partial unique default index enforces at most one default, not existence of a default for every enabled set; P02d-2 ships supported-write validation and the default-enabled CHECK constraint; raw deletes can still leave an enabled locale set without a default. `LocalizedText` and `LocalizedMessage` ship with their own cases, and API errors are keyed. P02d-1 shipped Education Pattern A satellites, canonical locales and flat localized-slug uniqueness; P02d-4 Step 3 delivers exact enabled public locale admission and response labels. P02d-5 Step 3 delivers membership-first redirect G36. P02d-6 Step 1 implements Accepted ADR-0027/G39's next-intl 4.14.9 plugin/request runtime, app-local `src/i18n/messages/{en,tr}/public.json` and catalogue controls. Phase 04 inherits the foundation for CMS/Studio. | | 09 | [Error Handling](09-error-handling.md) | **Active** | L1 `IExceptionHandler`, the exception hierarchy, `ProblemDetailsFactory` and `HttpStatusMap` shipped in Packet 3. | | 10 | [Observability](10-observability.md) | **Active** | Serilog → OTLP, OpenTelemetry SDK, `TenantContextSpanProcessor` and the redaction enrichers shipped in Packet 3. | | 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. | @@ -104,7 +107,7 @@ included. | 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) | **Adopted** | P6 product pages are not delivered. Accepted G43 requires applicable jsx-a11y error rules with actual-config planted controls, actual-page semantic/language proofs and manual keyboard/focus/reflow/contrast/screen-reader evidence. Implementation and evidence remain pending; promotion occurs with enforcement. Full Playwright/axe remains [Phase 06](../roadmap/phase-06-renderer-admin-studio.md); Accepted G44 assigns Lighthouse to P02d-7 after P6. | +| 16 | [Accessibility](16-accessibility.md) | **Adopted** | P6 product pages are not delivered. Accepted G43 requires applicable jsx-a11y error rules with actual-config planted controls, actual-page semantic/language proofs and manual keyboard/focus/reflow/contrast/screen-reader evidence. Step 1 supplies chrome/state semantics only; actual content-page enforcement and manual evidence remain pending. Promotion occurs with enforcement. Full Playwright/axe remains [Phase 06](../roadmap/phase-06-renderer-admin-studio.md); Accepted G44 assigns Lighthouse to P02d-7 after P6. | | 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). | diff --git a/frontend/apps/web/next.config.ts b/frontend/apps/web/next.config.ts index 7e2bf28b..111a69a5 100644 --- a/frontend/apps/web/next.config.ts +++ b/frontend/apps/web/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from 'next'; +import createNextIntlPlugin from 'next-intl/plugin'; const nextConfig: NextConfig = { reactStrictMode: true, @@ -9,4 +10,4 @@ const nextConfig: NextConfig = { experimental: { serverComponentsHmrCache: false }, }; -export default nextConfig; +export default createNextIntlPlugin('./src/i18n/request.ts')(nextConfig); diff --git a/frontend/apps/web/package.json b/frontend/apps/web/package.json index 2c169997..bbfb7bef 100644 --- a/frontend/apps/web/package.json +++ b/frontend/apps/web/package.json @@ -18,11 +18,13 @@ "@learnstack/sdk": "workspace:*", "@learnstack/ui": "workspace:*", "next": "15.5.18", + "next-intl": "4.14.9", "react": "19.0.0", "react-dom": "19.0.0", "server-only": "0.0.1" }, "devDependencies": { + "@formatjs/icu-messageformat-parser": "3.5.18", "@learnstack/config": "workspace:*", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/frontend/apps/web/scripts/fixture-support.mjs b/frontend/apps/web/scripts/fixture-support.mjs index be8f1f17..2bfb19b3 100644 --- a/frontend/apps/web/scripts/fixture-support.mjs +++ b/frontend/apps/web/scripts/fixture-support.mjs @@ -1,4 +1,5 @@ -import { rmSync } from 'node:fs'; +import { realpathSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; import { stopTestChild } from './stop-test-child.mjs'; @@ -57,6 +58,9 @@ export function fixtureEnvironment({ }) { const env = { TMPDIR: root, + // SWC's native carrier rejects symlink ancestors (including macOS /var). + // Canonicalize the owned root instead of inheriting a user's cache or HOME. + SWC_NATIVE_BINDING_CACHE: join(realpathSync(root), 'swc-native-cache'), NODE_ENV: nodeEnv, NODE_TLS_REJECT_UNAUTHORIZED: '1', NEXT_TELEMETRY_DISABLED: '1', diff --git a/frontend/apps/web/scripts/verify-ingress.mjs b/frontend/apps/web/scripts/verify-ingress.mjs index 12b5069d..1e1e9460 100644 --- a/frontend/apps/web/scripts/verify-ingress.mjs +++ b/frontend/apps/web/scripts/verify-ingress.mjs @@ -529,9 +529,9 @@ async function run() { ['/faviconXico', 404, 1], ['/favicon.ico/extra', 404, 1], ['/nested/favicon.ico', 404, 1], - ['/en/courses', 404, 1], - ['/studio', 200, 1], - ['/portal', 200, 1], + ['/en/status/not-found', 404, 2], + ['/studio', 200, 2], + ['/portal', 200, 2], ]) { for (const method of ['GET', 'HEAD']) { const before = bootstrapCalls; @@ -573,15 +573,15 @@ async function run() { assert.equal((await observe(native)).nextCalls, beforeHead.nextCalls); const beforePositive = bootstrapCalls; - const positive = await call(true, 3000, '/en/courses?next=https%3A%2F%2Fevil.example', { + const positive = await call(true, 3000, '/en/status/not-found?next=https%3A%2F%2Fevil.example', { Host: 'tenant.example:3000', 'X-LearnStack-Ingress-Provenance': 'forged', 'X-Forwarded-For': 'attacker', 'X-Middleware-Subrequest': 'middleware:middleware:middleware', }); assert.equal(listenerFailed, false, 'Bootstrap fixture refused the hop'); - assert.equal(positive.status, 404); // P6 pages are absent; verified bootstrap still ran. - assert.equal(bootstrapCalls, beforePositive + 1); + assert.equal(positive.status, 404); // Actual branded status route is admitted without Education. + assert.equal(bootstrapCalls, beforePositive + 2); // Middleware and request-local RSC bootstrap. assert.match(positive.headers['cache-control'], /(?:^|,\s*)no-store(?:,|$)/); for (const query of [ '?', diff --git a/frontend/apps/web/scripts/verify-public-rendering.mjs b/frontend/apps/web/scripts/verify-public-rendering.mjs index 7bf576f6..1307e1c4 100644 --- a/frontend/apps/web/scripts/verify-public-rendering.mjs +++ b/frontend/apps/web/scripts/verify-public-rendering.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { cpSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -13,6 +14,7 @@ import { } from 'node:fs'; import { request as httpRequest } from 'node:http'; import { request as httpsRequest } from 'node:https'; +import { createRequire } from 'node:module'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -26,6 +28,7 @@ import { } from './fixture-support.mjs'; const sourceApp = fileURLToPath(new URL('../', import.meta.url)); +const { JSDOM } = createRequire(join(sourceApp, 'package.json'))('jsdom'); const owner = createFixtureOwner(); const scanners = []; let fixtureRoot; @@ -36,6 +39,7 @@ let stage = 'configuration'; let configuration; let certificate; let privateScanner; +let catalogueCanaries = []; function checkPrivate(value) { return privateScanner.contains(value); @@ -155,6 +159,7 @@ function call(tls, port, path, headers = {}, method = 'GET') { timeout: 15_000, }, (response) => { + response.setEncoding('utf8'); let body = ''; response.on('data', (chunk) => { body += chunk.toString(); @@ -205,14 +210,20 @@ function safeResponse( rsc = false, contentType = rsc ? /^text\/x-component/ : /^text\/html/, ) { + const phase = stage; if (response.status !== status) stage += ` (status ${response.status}, expected ${status})`; assert.equal(response.status, status, 'Public response status'); + stage = phase + ' cache-control'; assert.match(response.headers['cache-control'], /(?:^|,\s*)no-store(?:,|$)/); + stage = phase + ' forbidden headers'; assert.equal(response.headers.etag, undefined); assert.equal(response.headers['set-cookie'], undefined); assert.equal(response.headers['x-powered-by'], undefined); + stage = phase + ' private containment'; assert.equal(checkPrivate(JSON.stringify(response)), false, 'Response private-data containment'); + stage = phase + ' content-type'; if (status === 200) assert.match(response.headers['content-type'], contentType); + stage = phase; } async function representation(tenant, path, rsc = false, extra = {}, status = 200) { @@ -248,6 +259,11 @@ function scanClientAssets(directory, expectedMarker = false) { marker ||= content.includes(clientMarker); } assert.equal(checkPrivate(content), false, 'Static client private-data containment'); + assert.equal( + catalogueCanaries.some((value) => content.includes(value)), + false, + 'Complete UI catalogue cannot enter browser assets', + ); } } if (expectedMarker) assert.equal(marker, true, 'Built public Client Component marker'); @@ -289,6 +305,228 @@ export default async function FixturePage({ params }: { } `; +// P5 deliberately keeps its own neutral document and independent SDK calls. +// Replacing only this disposable copy prevents later product layouts from adding +// reads to the transport proof or its synthetic catch-all shadowing product pages. +const transportRoot = `import type { ReactNode } from 'react'; +import './globals.css'; +export default function RootLayout({ children }: { children: ReactNode }) { + return <html lang="en"><body>{children}</body></html>; +} +`; +const transportLayout = `import type { ReactNode } from 'react'; +export const dynamic = 'force-dynamic'; +export const revalidate = 0; +export const fetchCache = 'force-no-store'; +export default function PublicLayout({ children }: { children: ReactNode }) { + return <main>{children}</main>; +} +`; + +// Step 1 proves the resource-loader foundation with a deliberately test-owned +// consumer. The actual root, public layout and status page stay byte-for-byte +// copied from production. Step 2 replaces this probe with unchanged product pages. +function installFoundationConsumer(coursesDirectory) { + const fixturePage = join(coursesDirectory, '[slug]/page.tsx'); + assert.equal( + existsSync(coursesDirectory), + false, + 'Foundation probe cannot shadow product routes', + ); + mkdirSync(dirname(fixturePage), { recursive: true }); + writeFileSync( + fixturePage, + `import { getTranslations } from 'next-intl/server'; +import { requirePublicResource } from '@/server/public-resource'; + +export async function generateMetadata() { + await getTranslations('public'); + const resource = await requirePublicResource(); + return { title: resource.kind === 'course' ? resource.data.course.title : 'Foundation fixture' }; +} +export default async function FoundationFixturePage() { + const resource = await requirePublicResource(); + const t = await getTranslations('public'); + if (resource.kind !== 'course') throw new Error('Foundation fixture expected a course'); + return <section data-foundation-fixture="shared-resource"><h1>{resource.data.course.title}</h1><p>{t('catalog.title')}</p></section>; +} +`, + ); +} + +function document(response) { + const dom = new JSDOM(response.body); + // Assertions below inspect the rendered document, never strings embedded in + // Flight/bootstrap scripts that may describe UI absent from visible HTML. + dom.window.document + .querySelectorAll('script, template, [hidden]') + .forEach((node) => node.remove()); + return dom.window.document; +} + +function statusDocument(response, tenant, locale, uiLocale) { + const phase = stage; + stage = phase + ' status headers'; + safeResponse(response, 404); + stage = phase + ' HTML content-type'; + assert.match(response.headers['content-type'], /^text\/html/); + stage = phase + ' catalogue containment'; + assert.equal( + catalogueCanaries.some((value) => response.body.includes(value)), + false, + 'Complete UI catalogue cannot enter Flight payload', + ); + const doc = document(response); + stage = phase + ' document locale'; + assert.equal(doc.documentElement.lang, locale, 'Document retains admitted content locale'); + assert.equal(doc.documentElement.dir, locale === 'ar' ? 'rtl' : 'ltr'); + stage = phase + ' visible heading'; + const heading = doc.querySelector('h1'); + assert.equal(doc.querySelectorAll('h1').length, 1, 'One visible localized status heading'); + assert.equal(doc.querySelectorAll('main').length, 1, 'One main landmark'); + const title = uiLocale === 'tr' ? 'Sayfa bulunamadı' : 'Page not found'; + assert.equal(heading.textContent, title, 'Visible status translation'); + stage = phase + ' localized metadata'; + if (!doc.title.includes(title)) { + const original = new JSDOM(response.body).window.document; + const titleNode = original.querySelector('title'); + stage += titleNode?.closest('[hidden]') ? ' hidden' : titleNode ? ' unmatched' : ' absent'; + } + assert.ok(doc.title.includes(title), 'Localized document title'); + stage = phase + ' UI locale'; + assert.equal(heading.closest('[lang]').getAttribute('lang'), uiLocale, 'UI fallback language'); + assert.equal(heading.closest('[dir]').getAttribute('dir'), 'ltr', 'UI fallback direction'); + stage = phase + ' robots recovery'; + assert.ok(doc.querySelector('meta[name="robots"]').content.includes('noindex')); + assert.ok(doc.querySelector('a[href="/' + locale + '/courses"]'), 'Same-locale recovery'); + stage = phase + ' tenant chrome'; + assert.ok(doc.body.textContent.includes(tenant.name), 'Actual tenant chrome'); + const other = configuration.tenants.find((candidate) => candidate.host !== tenant.host); + assert.equal(doc.body.textContent.includes(other.name), false, 'No opposite tenant chrome'); + assert.equal( + response.body.includes('missing-foundation-course'), + false, + 'No original resource echo', + ); + assert.equal(response.body.includes('private-query-value'), false, 'No original query echo'); + stage = phase; + return doc; +} + +async function verifyFoundation(native, nextBin) { + const [first, second] = configuration.tenants; + const course = '/' + configuration.locale + '/courses/' + configuration.courseSlug; + const request = (tenant, path, method = 'GET') => + call(true, 3000, path, { Host: tenant.host + ':3000' }, method); + + for (const checkpointName of ['foundation-normal', 'foundation-repeat']) { + stage = checkpointName; + const response = await request(first, course); + safeResponse(response, 200); + const doc = document(response); + assert.equal(doc.documentElement.lang, configuration.locale); + assert.equal(doc.querySelector('h1').textContent, first.courseTitle); + assert.equal(doc.querySelectorAll('[data-foundation-fixture="shared-resource"]').length, 1); + assert.ok(doc.title.includes(first.courseTitle), 'Metadata consumes the same resource'); + await checkpoint(checkpointName); + } + + stage = 'foundation-missing'; + const missing = await request( + first, + '/' + configuration.locale + '/courses/missing-foundation-course?cursor=private-query-value', + ); + safeResponse(missing, 307); + const statusPath = '/' + configuration.locale + '/status/not-found'; + stage = 'foundation missing redirect location'; + assert.equal(missing.headers.location, statusPath, 'Fixed relative missing-resource redirect'); + stage = 'foundation missing follow'; + statusDocument(await request(first, statusPath), first, configuration.locale, 'en'); + await checkpoint('foundation-missing'); + + stage = 'foundation-status'; + // Concurrent different hosts and catalogue languages exercise request config + // isolation; Arabic and exact Turkish are test-owned live memberships. + const concurrentFailures = await Promise.all( + [ + [first, 'en', 'en'], + [second, second.defaultLocale, 'tr'], + [first, 'ar', 'en'], + [first, 'tr', 'tr'], + ].map(async ([tenant, locale, uiLocale]) => { + try { + const response = await request(tenant, '/' + locale + '/status/not-found'); + stage = 'foundation status ' + locale; + statusDocument(response, tenant, locale, uiLocale); + return null; + } catch { + // Freeze the bounded diagnostic before another concurrent check changes + // the shared stage. Wait for every request before reporting a failure. + return stage; + } + }), + ); + const failedStage = concurrentFailures.find((failure) => failure !== null); + if (failedStage) { + stage = failedStage; + throw new Error('Concurrent status assertion failed'); + } + await checkpoint('foundation-status'); + + stage = 'foundation-head'; + const head = await request(first, '/ar/status/not-found', 'HEAD'); + safeResponse(head, 404); + assert.equal(head.body, '', 'Localized status HEAD is bodyless'); + await checkpoint('foundation-head'); + + stage = 'foundation-refused'; + for (const path of ['/fr/status/not-found', '/ar/status/unknown']) { + const refused = await request(first, path); + safeResponse(refused, 404); + assert.equal(refused.body.includes(first.name), false, 'Admission failure has neutral output'); + assert.equal( + refused.body.includes('Page not found'), + false, + 'UI fallback cannot admit a locale', + ); + } + await checkpoint('foundation-refused'); + + stage = 'foundation-canonical'; + const canonical = await request(first, '/TR-tr/status/not-found'); + safeResponse(canonical, 308); + assert.equal( + canonical.headers.location, + 'https://' + first.host + ':3000/tr-TR/status/not-found', + ); + statusDocument(await request(first, '/tr-TR/status/not-found'), first, 'tr-TR', 'tr'); + await checkpoint('foundation-canonical'); + + stage = 'foundation stock bypass'; + const stock = start(process.execPath, [ + nextBin, + 'start', + '--hostname', + '127.0.0.1', + '--port', + '3011', + ]); + await ready(stock, false, 3011); + await checkpoint('stock-before'); + for (const rsc of [false, true]) { + const response = await call(false, 3011, '/ar/status/not-found', { + Host: first.host + ':3000', + ...(rsc ? { RSC: '1' } : {}), + 'X-LearnStack-Ingress-Provenance': 'forged', + 'X-Middleware-Subrequest': 'middleware:middleware:middleware:middleware:middleware', + }); + safeResponse(response, 404); + assert.equal(response.body.includes(first.name), false); + } + await checkpoint('stock-after'); + assert.equal(native.exitCode, null, 'Foundation checks use one native process'); +} + // The real Next adapter removes Flight inputs before user middleware, then // restores them after its request-header override. Observe that final request // through a disposable admitted route, without exposing any header values. @@ -313,6 +551,15 @@ try { assert.match(configuration.apiOrigin, /^http:\/\/127\.0\.0\.1:\d+$/); assert.match(configuration.secret, /^[A-Za-z0-9_-]{43}$/); assert.equal(configuration.tenants.length, 2); + const foundation = configuration.mode === 'foundation'; + if (foundation) { + catalogueCanaries = ['en', 'tr'].map( + (locale) => + JSON.parse( + readFileSync(join(sourceApp, 'src/i18n/messages', locale, 'public.json'), 'utf8'), + ).catalog.course_count, + ); + } privateScanner = createPrivateScanner([configuration.secret]); owner.assertActive(); fixtureRoot = owner.ownRoot(mkdtempSync(join(tmpdir(), 'learnstack-public-rendering-'))); @@ -342,55 +589,69 @@ try { }); } symlinkSync(join(sourceApp, 'node_modules'), join(app, 'node_modules'), 'dir'); - const fixturePage = join(app, 'src/app/(public)/[locale]/courses/[[...segments]]/page.tsx'); - mkdirSync(dirname(fixturePage), { recursive: true }); - writeFileSync(fixturePage, page); - const fixtureClient = join(dirname(fixturePage), 'fixture-client.tsx'); - writeFileSync(fixtureClient, clientComponent); - const fixtureProbe = join(app, 'src/app/(public)/[locale]/courses/protocol-probe/route.ts'); - mkdirSync(dirname(fixtureProbe), { recursive: true }); - writeFileSync(fixtureProbe, protocolProbe); - - stage = 'client import rejection'; - const mutant = join(app, 'src/app/client-import-probe/page.tsx'); - mkdirSync(dirname(mutant), { recursive: true }); - writeFileSync( - mutant, - "'use client';\n" + - "import { createConfiguredPublicClient } from '@/server/configured-public-client';\n" + - 'export default function Probe() { return <p>{String(createConfiguredPublicClient(null))}</p>; }\n', - ); + const coursesDirectory = join(app, 'src/app/(public)/[locale]/courses'); + let fixtureClient; + if (foundation) { + installFoundationConsumer(coursesDirectory); + } else { + rmSync(join(app, 'src/app/(public)/[locale]'), { recursive: true, force: true }); + // The original transport probe predates product Suspense/error boundaries. + for (const name of ['loading.tsx', 'error.tsx']) + rmSync(join(app, 'src/app/(public)', name), { force: true }); + writeFileSync(join(app, 'src/app/layout.tsx'), transportRoot); + writeFileSync(join(app, 'src/app/(public)/layout.tsx'), transportLayout); + const fixturePage = join(coursesDirectory, '[[...segments]]/page.tsx'); + mkdirSync(dirname(fixturePage), { recursive: true }); + writeFileSync(fixturePage, page); + fixtureClient = join(dirname(fixturePage), 'fixture-client.tsx'); + writeFileSync(fixtureClient, clientComponent); + const fixtureProbe = join(coursesDirectory, 'protocol-probe/route.ts'); + mkdirSync(dirname(fixtureProbe), { recursive: true }); + writeFileSync(fixtureProbe, protocolProbe); + } const nextBin = join(sourceApp, 'node_modules/next/dist/bin/next'); - const rejected = start(process.execPath, [nextBin, 'build']); - assert.notEqual( - await completion(rejected), - 0, - 'Client import mutant must fail a real production build', - ); - assert.match(rejected.output, /server-only/); - assert.match(rejected.output, /client-import-probe\/page\.tsx/); - rmSync(dirname(mutant), { recursive: true }); - rmSync(join(app, '.next'), { recursive: true, force: true }); - - stage = 'built client canary rejection'; - // This mutant must compile successfully. Only scanning real emitted browser - // assets can reject it; source fences or an import failure prove another seam. - writeFileSync( - fixtureClient, - clientComponent.replace( - '{displayName}</p>', - '{displayName}{' + JSON.stringify(configuration.secret) + '}</p>', - ), - ); - const canary = start(process.execPath, [nextBin, 'build']); - assert.equal(await completion(canary), 0, 'Client canary builds successfully'); - assert.throws( - () => scanClientAssets(join(app, '.next/static'), true), - /Static client private-data containment/, - 'The emitted client canary must be rejected by asset scanning', - ); - writeFileSync(fixtureClient, clientComponent); - rmSync(join(app, '.next'), { recursive: true, force: true }); + + if (!foundation) { + stage = 'client import rejection'; + const mutant = join(app, 'src/app/client-import-probe/page.tsx'); + mkdirSync(dirname(mutant), { recursive: true }); + writeFileSync( + mutant, + "'use client';\n" + + "import { createConfiguredPublicClient } from '@/server/configured-public-client';\n" + + 'export default function Probe() { return <p>{String(createConfiguredPublicClient(null))}</p>; }\n', + ); + const rejected = start(process.execPath, [nextBin, 'build']); + assert.notEqual( + await completion(rejected), + 0, + 'Client import mutant must fail a real production build', + ); + assert.match(rejected.output, /server-only/); + assert.match(rejected.output, /client-import-probe\/page\.tsx/); + rmSync(dirname(mutant), { recursive: true }); + rmSync(join(app, '.next'), { recursive: true, force: true }); + + stage = 'built client canary rejection'; + // This mutant must compile successfully. Only scanning real emitted browser + // assets can reject it; source fences or an import failure prove another seam. + writeFileSync( + fixtureClient, + clientComponent.replace( + '{displayName}</p>', + '{displayName}{' + JSON.stringify(configuration.secret) + '}</p>', + ), + ); + const canary = start(process.execPath, [nextBin, 'build']); + assert.equal(await completion(canary), 0, 'Client canary builds successfully'); + assert.throws( + () => scanClientAssets(join(app, '.next/static'), true), + /Static client private-data containment/, + 'The emitted client canary must be rejected by asset scanning', + ); + writeFileSync(fixtureClient, clientComponent); + rmSync(join(app, '.next'), { recursive: true, force: true }); + } stage = 'healthy production build'; const ingress = start(process.execPath, [ @@ -402,7 +663,7 @@ try { const build = start(process.execPath, [nextBin, 'build']); assert.equal(await completion(build), 0, 'Healthy production build'); assert.ok( - scanClientAssets(join(app, '.next/static'), true).files > 0, + scanClientAssets(join(app, '.next/static'), !foundation).files > 0, 'Nonempty production client assets', ); await checkpoint('build-complete'); @@ -438,150 +699,158 @@ try { const catalog = '/' + configuration.locale + '/courses'; const course = catalog + '/' + configuration.courseSlug; - stage = 'cold and interleaved host representations'; - const firstCatalog = await representation(first, catalog, false, { - traceparent: configuration.traceparent, - 'X-LearnStack-Ingress-Provenance': 'forged', - 'X-LearnStack-Host': second.host, - 'X-Forwarded-For': '203.0.113.99', - Cookie: 'locale=invalid', - }); - assert.ok(firstCatalog.body.includes(first.courseTitle)); - await checkpoint('trace-supplied'); - assert.ok((await representation(second, catalog)).body.includes(second.courseTitle)); - await checkpoint('trace-missing'); - assert.ok( - (await representation(first, course, false, { traceparent: 'malformed' })).body.includes( - first.courseTitle, - ), - ); - await checkpoint('trace-malformed'); - assert.ok((await representation(second, course)).body.includes(second.courseTitle)); - for (const path of [catalog, course]) + if (foundation) { + await verifyFoundation(native, nextBin); + } else { + stage = 'cold and interleaved host representations'; + const firstCatalog = await representation(first, catalog, false, { + traceparent: configuration.traceparent, + 'X-LearnStack-Ingress-Provenance': 'forged', + 'X-LearnStack-Host': second.host, + 'X-Forwarded-For': '203.0.113.99', + Cookie: 'locale=invalid', + }); + assert.ok(firstCatalog.body.includes(first.courseTitle)); + await checkpoint('trace-supplied'); + assert.ok((await representation(second, catalog)).body.includes(second.courseTitle)); + await checkpoint('trace-missing'); + assert.ok( + (await representation(first, course, false, { traceparent: 'malformed' })).body.includes( + first.courseTitle, + ), + ); + await checkpoint('trace-malformed'); + assert.ok((await representation(second, course)).body.includes(second.courseTitle)); + for (const path of [catalog, course]) + for (const tenant of configuration.tenants) { + assert.ok((await representation(tenant, path, true)).body.includes(tenant.courseTitle)); + } for (const tenant of configuration.tenants) { - assert.ok((await representation(tenant, path, true)).body.includes(tenant.courseTitle)); + const lesson = await representation(tenant, course + '/lessons/' + tenant.lessonSlug); + assert.ok(lesson.body.includes(tenant.lessonTitle), 'Real SDK lesson body'); + assert.ok(lesson.body.includes(tenant.lessonText), 'Public projected field'); } - for (const tenant of configuration.tenants) { - const lesson = await representation(tenant, course + '/lessons/' + tenant.lessonSlug); - assert.ok(lesson.body.includes(tenant.lessonTitle), 'Real SDK lesson body'); - assert.ok(lesson.body.includes(tenant.lessonText), 'Public projected field'); - } - const redirect = await call(true, 3000, '/', { Host: second.host + ':3000' }); - safeResponse(redirect, 307); - assert.equal( - redirect.headers.location, - 'https://' + second.host + ':3000/' + second.defaultLocale + '/courses', - ); + const redirect = await call(true, 3000, '/', { Host: second.host + ':3000' }); + safeResponse(redirect, 307); + assert.equal( + redirect.headers.location, + 'https://' + second.host + ':3000/' + second.defaultLocale + '/courses', + ); - stage = 'real adapter Flight header preservation'; - await checkpoint('protocol-before'); - const absentProtocol = { - rsc: false, - stateTree: false, - routerPrefetch: false, - segmentPrefetch: false, - hmrRefresh: false, - }; - const navigationHeaders = { - RSC: '1', - 'Next-Router-State-Tree': '%5B%22%22%2C%7B%7D%5D', - }; - for (const [headers, expected] of [ - [{}, absentProtocol], - [navigationHeaders, { ...absentProtocol, rsc: true, stateTree: true }], - [ - { ...navigationHeaders, 'Next-Router-Prefetch': '1' }, - { ...absentProtocol, rsc: true, stateTree: true, routerPrefetch: true }, - ], - [ - { RSC: '1', 'Next-Router-Segment-Prefetch': '/_tree' }, - { ...absentProtocol, rsc: true, segmentPrefetch: true }, - ], - [ - { RSC: '1', 'Next-Hmr-Refresh': '1' }, - { ...absentProtocol, rsc: true, hmrRefresh: true }, - ], - ]) { - const response = await call(true, 3000, catalog + '/protocol-probe', { - Host: first.host + ':3000', - ...headers, - }); - safeResponse(response, 200, false, /^application\/json/); - assert.deepEqual(JSON.parse(response.body), expected, 'Final route protocol inputs'); - } - stage = 'real adapter bodyless HEAD'; - const head = await call( - true, - 3000, - catalog + '/protocol-probe', - { Host: first.host + ':3000', ...navigationHeaders }, - 'HEAD', - ); - safeResponse(head, 200, false, /^application\/json/); - assert.equal(head.body, '', 'Supported HEAD has no response body'); - await checkpoint('protocol-after'); + stage = 'real adapter Flight header preservation'; + await checkpoint('protocol-before'); + const absentProtocol = { + rsc: false, + stateTree: false, + routerPrefetch: false, + segmentPrefetch: false, + hmrRefresh: false, + }; + const navigationHeaders = { + RSC: '1', + 'Next-Router-State-Tree': '%5B%22%22%2C%7B%7D%5D', + }; + for (const [headers, expected] of [ + [{}, absentProtocol], + [navigationHeaders, { ...absentProtocol, rsc: true, stateTree: true }], + [ + { ...navigationHeaders, 'Next-Router-Prefetch': '1' }, + { ...absentProtocol, rsc: true, stateTree: true, routerPrefetch: true }, + ], + [ + { RSC: '1', 'Next-Router-Segment-Prefetch': '/_tree' }, + { ...absentProtocol, rsc: true, segmentPrefetch: true }, + ], + [ + { RSC: '1', 'Next-Hmr-Refresh': '1' }, + { ...absentProtocol, rsc: true, hmrRefresh: true }, + ], + ]) { + const response = await call(true, 3000, catalog + '/protocol-probe', { + Host: first.host + ':3000', + ...headers, + }); + safeResponse(response, 200, false, /^application\/json/); + assert.deepEqual(JSON.parse(response.body), expected, 'Final route protocol inputs'); + } + stage = 'real adapter bodyless HEAD'; + const head = await call( + true, + 3000, + catalog + '/protocol-probe', + { Host: first.host + ':3000', ...navigationHeaders }, + 'HEAD', + ); + safeResponse(head, 200, false, /^application\/json/); + assert.equal(head.body, '', 'Supported HEAD has no response body'); + await checkpoint('protocol-after'); - stage = 'stock launcher forgery before bootstrap'; - const stock = start(process.execPath, [ - nextBin, - 'start', - '--hostname', - '127.0.0.1', - '--port', - '3011', - ]); - await ready(stock, false, 3011); - await checkpoint('stock-before'); - for (const rsc of [false, true]) { - const bypass = await call(false, 3011, course, { - Host: first.host + ':3000', - ...(rsc ? { RSC: '1' } : {}), - 'X-LearnStack-Ingress-Provenance': 'forged', - 'X-LearnStack-Host': first.host, - 'X-LearnStack-Visitor-Address': '203.0.113.99', - 'X-Middleware-Subrequest': 'middleware:middleware:middleware:middleware:middleware', - 'X-Middleware-Subrequest-Id': 'attacker', - }); - safeResponse(bypass, 404); - assert.equal(bypass.body.includes(first.name), false); - } - await checkpoint('stock-after'); + stage = 'stock launcher forgery before bootstrap'; + const stock = start(process.execPath, [ + nextBin, + 'start', + '--hostname', + '127.0.0.1', + '--port', + '3011', + ]); + await ready(stock, false, 3011); + await checkpoint('stock-before'); + for (const rsc of [false, true]) { + const bypass = await call(false, 3011, course, { + Host: first.host + ':3000', + ...(rsc ? { RSC: '1' } : {}), + 'X-LearnStack-Ingress-Provenance': 'forged', + 'X-LearnStack-Host': first.host, + 'X-LearnStack-Visitor-Address': '203.0.113.99', + 'X-Middleware-Subrequest': 'middleware:middleware:middleware:middleware:middleware', + 'X-Middleware-Subrequest-Id': 'attacker', + }); + safeResponse(bypass, 404); + assert.equal(bypass.body.includes(first.name), false); + } + await checkpoint('stock-after'); - stage = 'same process publication freshness'; - const nativePid = native.pid; - await checkpoint('make-draft'); - for (const rsc of [false, true]) { - stage = rsc ? 'draft RSC catalog' : 'draft HTML catalog'; - const fresh = await representation(first, catalog, rsc); - assert.equal(fresh.body.includes(first.courseTitle), false, 'Draft vanished from catalog'); - stage = rsc ? 'draft RSC detail refusal' : 'draft HTML detail refusal'; - // Next's streamed RSC refusal carries its not-found digest after HTTP 200; - // the document is 404. Both must discard the previously rendered body. - const hidden = rsc - ? await call(true, 3000, course, { Host: first.host + ':3000', RSC: '1' }) - : await representation(first, course, false, {}, 404); - if (rsc) { - safeResponse(hidden, 200, true); - assert.ok(hidden.body.includes('NEXT_HTTP_ERROR_FALLBACK;404'), 'RSC not-found digest'); - assert.equal( - hidden.body.includes(first.name), - false, - 'Refused RSC has no tenant representation', - ); - assert.equal(hidden.body.includes(second.name), false, 'Refused RSC has no opposite tenant'); + stage = 'same process publication freshness'; + const nativePid = native.pid; + await checkpoint('make-draft'); + for (const rsc of [false, true]) { + stage = rsc ? 'draft RSC catalog' : 'draft HTML catalog'; + const fresh = await representation(first, catalog, rsc); + assert.equal(fresh.body.includes(first.courseTitle), false, 'Draft vanished from catalog'); + stage = rsc ? 'draft RSC detail refusal' : 'draft HTML detail refusal'; + // Next's streamed RSC refusal carries its not-found digest after HTTP 200; + // the document is 404. Both must discard the previously rendered body. + const hidden = rsc + ? await call(true, 3000, course, { Host: first.host + ':3000', RSC: '1' }) + : await representation(first, course, false, {}, 404); + if (rsc) { + safeResponse(hidden, 200, true); + assert.ok(hidden.body.includes('NEXT_HTTP_ERROR_FALLBACK;404'), 'RSC not-found digest'); + assert.equal( + hidden.body.includes(first.name), + false, + 'Refused RSC has no tenant representation', + ); + assert.equal( + hidden.body.includes(second.name), + false, + 'Refused RSC has no opposite tenant', + ); + } + assert.equal(hidden.body.includes(first.courseTitle), false, 'Draft course is hidden'); } - assert.equal(hidden.body.includes(first.courseTitle), false, 'Draft course is hidden'); + stage = 'unaffected tenant after draft'; + assert.ok((await representation(second, course)).body.includes(second.courseTitle)); + assert.equal(native.pid, nativePid); + assert.equal(native.exitCode, null, 'Freshness used the same live native process'); + await checkpoint('restore-published'); + stage = 'restored publication'; + assert.ok( + (await representation(first, course)).body.includes(first.courseTitle), + 'Restored row is fresh too', + ); } - stage = 'unaffected tenant after draft'; - assert.ok((await representation(second, course)).body.includes(second.courseTitle)); - assert.equal(native.pid, nativePid); - assert.equal(native.exitCode, null, 'Freshness used the same live native process'); - await checkpoint('restore-published'); - stage = 'restored publication'; - assert.ok( - (await representation(first, course)).body.includes(first.courseTitle), - 'Restored row is fresh too', - ); stage = 'private output containment'; assert.equal( @@ -589,7 +858,7 @@ try { false, 'Private data entered child logs', ); - assert.ok(scanClientAssets(join(app, '.next/static'), true).files > 0); + assert.ok(scanClientAssets(join(app, '.next/static'), !foundation).files > 0); await checkpoint('verified'); } catch { // Assertions and compiler/provider errors may contain private values. Report diff --git a/frontend/apps/web/src/app/(public)/[locale]/status/not-found/layout.tsx b/frontend/apps/web/src/app/(public)/[locale]/status/not-found/layout.tsx new file mode 100644 index 00000000..e9773038 --- /dev/null +++ b/frontend/apps/web/src/app/(public)/[locale]/status/not-found/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import type { ReactNode } from 'react'; + +import { requirePublicResource } from '@/server/public-resource'; +import { getPublicUi } from '@/server/public-ui'; + +// Next's 404 metadata convention retains layout metadata and skips page metadata. +export async function generateMetadata(): Promise<Metadata> { + const resource = await requirePublicResource(); + if (resource.kind !== 'status') notFound(); + const { t } = await getPublicUi(); + return { + title: t('page.missing.title'), + description: t('page.missing.description'), + robots: { index: false, follow: false }, + }; +} + +export default function MissingLayout({ children }: { children: ReactNode }) { + return children; +} diff --git a/frontend/apps/web/src/app/(public)/[locale]/status/not-found/page.tsx b/frontend/apps/web/src/app/(public)/[locale]/status/not-found/page.tsx new file mode 100644 index 00000000..f3be2cfd --- /dev/null +++ b/frontend/apps/web/src/app/(public)/[locale]/status/not-found/page.tsx @@ -0,0 +1,20 @@ +import { notFound } from 'next/navigation'; + +import { PublicState } from '@/components/public/state'; +import { requirePublicResource } from '@/server/public-resource'; +import { getPublicUi } from '@/server/public-ui'; + +export default async function MissingPage() { + const resource = await requirePublicResource(); + if (resource.kind !== 'status') notFound(); + const ui = await getPublicUi(); + return ( + <PublicState + state="missing" + recoveryPath={`/${resource.request.locale}/courses`} + locale={ui.locale} + direction={ui.direction} + t={ui.t} + /> + ); +} diff --git a/frontend/apps/web/src/app/(public)/error.tsx b/frontend/apps/web/src/app/(public)/error.tsx new file mode 100644 index 00000000..39b87913 --- /dev/null +++ b/frontend/apps/web/src/app/(public)/error.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { useErrorLabels } from '@/components/public/error-labels'; + +export default function PublicError({ + reset, +}: { + readonly error: Error & { digest?: string }; + readonly reset: () => void; +}) { + const labels = useErrorLabels(); + if (!labels) return null; + return ( + <section className="public-state" lang={labels.locale} dir={labels.direction}> + <h1>{labels.title}</h1> + <p className="public-muted">{labels.description}</p> + <p> + <button type="button" onClick={reset}> + {labels.retry} + </button> + </p> + <p> + <a href={labels.recoveryPath}>{labels.recovery}</a> + </p> + </section> + ); +} diff --git a/frontend/apps/web/src/app/(public)/layout.tsx b/frontend/apps/web/src/app/(public)/layout.tsx index c92f5ab4..2d768186 100644 --- a/frontend/apps/web/src/app/(public)/layout.tsx +++ b/frontend/apps/web/src/app/(public)/layout.tsx @@ -1,5 +1,10 @@ import type { ReactNode } from 'react'; +import { PublicChrome } from '@/components/public/chrome'; +import { ErrorLabelsProvider } from '@/components/public/error-labels'; +import { requirePublicResource } from '@/server/public-resource'; +import { getPublicUi } from '@/server/public-ui'; + // ADR-0053: no public representation may survive into another host/request. export const dynamic = 'force-dynamic'; export const revalidate = 0; @@ -9,6 +14,32 @@ type PublicLayoutProps = { readonly children: ReactNode; }; -export default function PublicLayout({ children }: PublicLayoutProps) { - return <main className="min-h-screen">{children}</main>; +export default async function PublicLayout({ children }: PublicLayoutProps) { + const { request } = await requirePublicResource(); + const ui = await getPublicUi(); + const contentLocale = request.locale ?? 'en'; + const recoveryPath = `/${contentLocale}/courses`; + return ( + <PublicChrome + site={request.site} + contentLocale={contentLocale} + uiLocale={ui.locale} + uiDirection={ui.direction} + t={ui.t} + > + <ErrorLabelsProvider + labels={{ + locale: ui.locale, + direction: ui.direction, + title: ui.t('page.error.title'), + description: ui.t('page.error.description'), + retry: ui.t('page.error.retry'), + recovery: ui.t('page.recovery'), + recoveryPath, + }} + > + {children} + </ErrorLabelsProvider> + </PublicChrome> + ); } diff --git a/frontend/apps/web/src/app/(public)/loading.tsx b/frontend/apps/web/src/app/(public)/loading.tsx new file mode 100644 index 00000000..39744f36 --- /dev/null +++ b/frontend/apps/web/src/app/(public)/loading.tsx @@ -0,0 +1,15 @@ +import { PublicState } from '@/components/public/state'; +import { getPublicUi } from '@/server/public-ui'; + +export default async function PublicLoading() { + const ui = await getPublicUi(); + return ( + <PublicState + state="loading" + recoveryPath="/" + locale={ui.locale} + direction={ui.direction} + t={ui.t} + /> + ); +} diff --git a/frontend/apps/web/src/app/globals.css b/frontend/apps/web/src/app/globals.css index 8a936f39..724363c9 100644 --- a/frontend/apps/web/src/app/globals.css +++ b/frontend/apps/web/src/app/globals.css @@ -2,10 +2,7 @@ @tailwind components; @tailwind utilities; -/* Tenant theme tokens default to neutral values. How a layout overrides them - * per tenant from validated tenant settings is G42 in Phase 02d's decision - * register (docs/roadmap/phase-02d-walking-skeleton.md; Standards 07 § Tenant - * Branding). */ +/* Null or malformed whole themes retain these local defaults (P02d-6 G42). */ :root { --ls-primary: #1f6feb; --ls-bg: #ffffff; @@ -13,3 +10,85 @@ --ls-muted: #64748b; --ls-font-sans: ui-sans-serif, system-ui, sans-serif; } + +@layer base { + body { + overflow-wrap: anywhere; + } + a { + color: var(--ls-fg); + text-decoration: underline; + text-underline-offset: 0.2em; + } + a:focus-visible, + button:focus-visible, + main:focus { + outline: 3px solid var(--ls-primary); + outline-offset: 4px; + } + button { + border: 1px solid var(--ls-fg); + border-radius: 0.4rem; + padding: 0.6rem 1rem; + } + h1 { + font-size: clamp(1.8rem, 5vw, 2.6rem); + font-weight: 650; + line-height: 1.2; + } + h2 { + font-size: 1.4rem; + font-weight: 600; + line-height: 1.3; + } +} + +@layer components { + .public-header, + .public-main, + .public-footer { + width: min(100%, 68rem); + margin-inline: auto; + padding-inline: 1.5rem; + } + .public-header { + display: flex; + flex-wrap: wrap; + gap: 1rem 2rem; + justify-content: space-between; + align-items: center; + padding-block: 1.75rem; + border-block-end: 1px solid var(--ls-muted); + } + .public-institution { + font-size: 1.2rem; + font-weight: 600; + } + .public-main { + min-height: 65vh; + padding-block: 3rem; + } + .public-footer { + padding-block: 2rem; + } + .public-muted { + color: var(--ls-muted); + } + .public-state { + max-width: 44rem; + display: grid; + gap: 1.25rem; + } + .public-skip { + position: fixed; + inset-inline-start: 1rem; + top: 1rem; + z-index: 50; + padding: 0.75rem 1rem; + background: var(--ls-bg); + transform: translateY(-200%); + } + .public-skip:focus { + transform: translateY(0); + } +} diff --git a/frontend/apps/web/src/app/layout.tsx b/frontend/apps/web/src/app/layout.tsx index aa84856c..2ba27711 100644 --- a/frontend/apps/web/src/app/layout.tsx +++ b/frontend/apps/web/src/app/layout.tsx @@ -1,20 +1,29 @@ import type { Metadata } from 'next'; import type { ReactNode } from 'react'; +import { PublicTheme } from '@/components/public/theme'; +import { textDirection } from '@/i18n/locale'; +import { requirePublicResource } from '@/server/public-resource'; + import './globals.css'; export const metadata: Metadata = { title: 'LearnStack', - description: 'Multi-tenant core platform for building education products.', }; type RootLayoutProps = { readonly children: ReactNode; }; -export default function RootLayout({ children }: RootLayoutProps) { +export default async function RootLayout({ children }: RootLayoutProps) { + // Do not let loading.tsx flush a shell before the missing-resource redirect. + const { request } = await requirePublicResource(); + const locale = request.locale ?? 'en'; return ( - <html lang="en"> + <html lang={locale} dir={textDirection(locale)}> + <head> + <PublicTheme theme={request.site.theme} /> + </head> <body className="bg-ls-bg text-ls-fg font-sans antialiased">{children}</body> </html> ); diff --git a/frontend/apps/web/src/components/public/chrome.tsx b/frontend/apps/web/src/components/public/chrome.tsx new file mode 100644 index 00000000..8530805e --- /dev/null +++ b/frontend/apps/web/src/components/public/chrome.tsx @@ -0,0 +1,46 @@ +import type { ReactNode } from 'react'; + +import type { PublicTranslate } from '@/i18n/catalogues'; +import type { TextDirection } from '@/i18n/locale'; +import type { PublicSite } from '@/server/public-entry'; + +type PublicChromeProps = { + readonly site: PublicSite; + readonly contentLocale: string; + readonly uiLocale: string; + readonly uiDirection: TextDirection; + readonly t: PublicTranslate; + readonly children: ReactNode; +}; + +export function PublicChrome({ + site, + contentLocale, + uiLocale, + uiDirection, + t, + children, +}: PublicChromeProps) { + const catalogPath = `/${contentLocale}/courses`; + return ( + <> + <a className="public-skip" href="#main-content" lang={uiLocale} dir={uiDirection}> + {t('chrome.skip_to_content')} + </a> + <header className="public-header"> + <p className="public-institution">{site.displayName}</p> + <nav lang={uiLocale} dir={uiDirection} aria-label={t('chrome.catalog')}> + <a href={catalogPath}>{t('chrome.catalog')}</a> + </nav> + </header> + <main id="main-content" tabIndex={-1} className="public-main"> + {children} + </main> + {site.showPlatformAttribution && ( + <footer className="public-footer" lang={uiLocale} dir={uiDirection}> + <p>{t('chrome.attribution')}</p> + </footer> + )} + </> + ); +} diff --git a/frontend/apps/web/src/components/public/error-labels.tsx b/frontend/apps/web/src/components/public/error-labels.tsx new file mode 100644 index 00000000..df80c8f0 --- /dev/null +++ b/frontend/apps/web/src/components/public/error-labels.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { createContext, useContext } from 'react'; +import type { ReactNode } from 'react'; + +type ErrorLabels = { + readonly locale: string; + readonly direction: 'ltr' | 'rtl'; + readonly title: string; + readonly description: string; + readonly retry: string; + readonly recovery: string; + readonly recoveryPath: string; +}; +const ErrorLabelsContext = createContext<ErrorLabels | null>(null); + +/** Only bounded translated labels enter the interactive boundary, never catalogues or DTOs. */ +export function ErrorLabelsProvider({ + labels, + children, +}: { + readonly labels: ErrorLabels; + readonly children: ReactNode; +}) { + return <ErrorLabelsContext.Provider value={labels}>{children}</ErrorLabelsContext.Provider>; +} + +export function useErrorLabels() { + return useContext(ErrorLabelsContext); +} diff --git a/frontend/apps/web/src/components/public/foundation.test.tsx b/frontend/apps/web/src/components/public/foundation.test.tsx new file mode 100644 index 00000000..ab520330 --- /dev/null +++ b/frontend/apps/web/src/components/public/foundation.test.tsx @@ -0,0 +1,153 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import PublicError from '@/app/(public)/error'; +import { createPublicTranslator } from '@/i18n/catalogues'; +import type { PublicSite } from '@/server/public-entry'; + +import { PublicChrome } from './chrome'; +import { ErrorLabelsProvider } from './error-labels'; +import { PublicState } from './state'; +import { PublicTheme, publicThemeCss } from './theme'; + +const palette = { + primary: '#1f6feb', + background: '#ffffff', + foreground: '#0f172a', + muted: '#64748b', +}; +const site: PublicSite = { + displayName: '<script>private institution</script>', + enabledLocales: ['tr', 'en', 'ar'], + defaultLocale: 'tr', + theme: palette, + showPlatformAttribution: true, +}; + +describe('P02d-6 document foundation', () => { + it('Public_Theme_Emits_Only_Validated_Color_Tokens', () => { + expect(publicThemeCss(palette)).toBe( + ':root{--ls-primary:#1f6feb;--ls-bg:#ffffff;--ls-fg:#0f172a;--ls-muted:#64748b;}', + ); + const { container } = render(<PublicTheme theme={palette} />); + expect(container.querySelectorAll('style')).toHaveLength(1); + expect(container.querySelector('[style]')).toBeNull(); + expect(container.querySelector('style')?.textContent).toBe(publicThemeCss(palette)); + }); + + it.each([ + null, + {}, + [], + { ...palette, muted: null }, + { ...palette, primary: '#abc' }, + { ...palette, background: 'red' }, + { ...palette, foreground: '#ffffff;}' }, + { ...palette, muted: '</style><script>private</script>' }, + { ...palette, url: 'https://private.invalid' }, + { ...palette, font: 'private' }, + ])('rejects the whole malformed theme without partial tokens: %j', (theme) => { + expect(publicThemeCss(theme)).toBeNull(); + const { container } = render(<PublicTheme theme={theme} />); + expect(container.innerHTML).toBe(''); + }); + + it('provides one main/skip target, inert institution text and labelled fallback chrome', () => { + const ui = createPublicTranslator('ar'); + const { container } = render( + <PublicChrome + site={site} + contentLocale="ar" + uiLocale={ui.locale} + uiDirection={ui.direction} + t={ui.t} + > + <h1 lang="ar">عنوان</h1> + </PublicChrome>, + ); + expect(screen.getAllByRole('main')).toHaveLength(1); + const main = screen.getByRole('main'); + expect(main.id).toBe('main-content'); + expect(main).toHaveAttribute('tabindex', '-1'); + expect(screen.getByRole('link', { name: 'Skip to content' })).toHaveAttribute( + 'href', + '#main-content', + ); + const nav = screen.getByRole('navigation'); + expect(nav).toHaveAttribute('lang', 'en'); + expect(nav).toHaveAttribute('dir', 'ltr'); + expect(screen.getByRole('link', { name: 'Course catalog' })).toHaveAttribute( + 'href', + '/ar/courses', + ); + expect(screen.getByText(site.displayName)).toBeInTheDocument(); + expect(container.querySelector('script')).toBeNull(); + expect(screen.getByText('Powered by LearnStack')).toBeInTheDocument(); + }); + + it('uses only the effective attribution value independently of the safe theme', () => { + const ui = createPublicTranslator('tr'); + render( + <PublicChrome + site={{ ...site, showPlatformAttribution: false }} + contentLocale="tr" + uiLocale={ui.locale} + uiDirection={ui.direction} + t={ui.t} + > + <h1>Başlık</h1> + </PublicChrome>, + ); + expect(screen.queryByRole('contentinfo')).toBeNull(); + expect(screen.getByRole('link', { name: 'Kurs kataloğu' })).toHaveAttribute( + 'href', + '/tr/courses', + ); + expect(publicThemeCss(site.theme)).not.toBeNull(); + }); + + it.each(['missing', 'invalid_cursor', 'rate_limited', 'unavailable', 'loading'] as const)( + 'renders a bounded translated %s state with one descriptive heading', + (state) => { + const ui = createPublicTranslator('tr'); + const { container } = render( + <PublicState + state={state} + recoveryPath="/tr/courses" + locale={ui.locale} + direction={ui.direction} + t={ui.t} + />, + ); + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); + expect(container.querySelector('section')).toHaveAttribute('lang', 'tr'); + expect(container.querySelector('section')).toHaveAttribute('dir', 'ltr'); + expect(screen.queryAllByRole('link')).toHaveLength(state === 'loading' ? 0 : 1); + expect(container.textContent).not.toMatch(/lockey_|public\.|private/); + }, + ); + + it('keeps unexpected error details outside the interactive boundary and permits retry', () => { + const ui = createPublicTranslator('tr'); + const reset = vi.fn(); + const labels = { + locale: ui.locale, + direction: ui.direction, + title: ui.t('page.error.title'), + description: ui.t('page.error.description'), + retry: ui.t('page.error.retry'), + recovery: ui.t('page.recovery'), + recoveryPath: '/tr/courses', + }; + const { container } = render( + <ErrorLabelsProvider labels={labels}> + <PublicError error={new Error('private-secret-raw-key')} reset={reset} /> + </ErrorLabelsProvider>, + ); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(labels.title); + screen.getByRole('button', { name: labels.retry }).click(); + expect(reset).toHaveBeenCalledOnce(); + expect(container.textContent).not.toContain('private-secret-raw-key'); + expect(screen.getByRole('link')).toHaveAttribute('href', '/tr/courses'); + }); +}); diff --git a/frontend/apps/web/src/components/public/state.tsx b/frontend/apps/web/src/components/public/state.tsx new file mode 100644 index 00000000..62077b38 --- /dev/null +++ b/frontend/apps/web/src/components/public/state.tsx @@ -0,0 +1,48 @@ +import type { PublicTranslate } from '@/i18n/catalogues'; +import type { TextDirection } from '@/i18n/locale'; +import type { PublicFailureState } from '@/server/public-resource'; + +type StateProps = { + readonly state: PublicFailureState | 'missing' | 'loading'; + readonly recoveryPath: string; + readonly locale: string; + readonly direction: TextDirection; + readonly t: PublicTranslate; +}; + +/** Closed page outcomes select owned UI keys; no Problem Details enters this view. */ +export function PublicState({ state, recoveryPath, locale, direction, t }: StateProps) { + const title = + state === 'missing' + ? t('page.missing.title') + : state === 'invalid_cursor' + ? t('page.invalid_cursor.title') + : state === 'rate_limited' + ? t('page.rate_limited.title') + : state === 'loading' + ? t('page.loading.title') + : t('page.unavailable.title'); + const description = + state === 'missing' + ? t('page.missing.description') + : state === 'invalid_cursor' + ? t('page.invalid_cursor.description') + : state === 'rate_limited' + ? t('page.rate_limited.description') + : state === 'loading' + ? t('page.loading.description') + : t('page.unavailable.description'); + return ( + <section className="public-state" lang={locale} dir={direction}> + <h1>{title}</h1> + <p className="public-muted">{description}</p> + {state !== 'loading' && ( + <p> + <a href={recoveryPath}> + {state === 'invalid_cursor' ? t('page.invalid_cursor.reset') : t('page.recovery')} + </a> + </p> + )} + </section> + ); +} diff --git a/frontend/apps/web/src/components/public/theme.tsx b/frontend/apps/web/src/components/public/theme.tsx new file mode 100644 index 00000000..20d16cd6 --- /dev/null +++ b/frontend/apps/web/src/components/public/theme.tsx @@ -0,0 +1,17 @@ +/** Revalidate the whole anonymous projection; only fixed color slots reach CSS. */ +export function publicThemeCss(theme: unknown): string | null { + if (typeof theme !== 'object' || theme === null || Array.isArray(theme)) return null; + const tokens = theme as Record<string, unknown>; + const names = ['primary', 'background', 'foreground', 'muted'] as const; + if ( + Object.keys(tokens).length !== names.length || + !names.every((name) => typeof tokens[name] === 'string' && /^#[0-9a-f]{6}$/.test(tokens[name])) + ) + return null; + return `:root{--ls-primary:${tokens.primary};--ls-bg:${tokens.background};--ls-fg:${tokens.foreground};--ls-muted:${tokens.muted};}`; +} + +export function PublicTheme({ theme }: { readonly theme: unknown }) { + const css = publicThemeCss(theme); + return css === null ? null : <style>{css}</style>; +} diff --git a/frontend/apps/web/src/i18n/catalogues.test.ts b/frontend/apps/web/src/i18n/catalogues.test.ts new file mode 100644 index 00000000..4b80158b --- /dev/null +++ b/frontend/apps/web/src/i18n/catalogues.test.ts @@ -0,0 +1,288 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { isStructurallySame, parse, TYPE } from '@formatjs/icu-messageformat-parser'; +import type { MessageFormatElement } from '@formatjs/icu-messageformat-parser'; +import ts from 'typescript'; +import { describe, expect, it, vi } from 'vitest'; + +import { createPublicTranslator, getPublicCatalogue, publicFormattingOptions } from './catalogues'; +import en from './messages/en/public.json'; +import tr from './messages/tr/public.json'; + +type Catalogue = Record<string, unknown>; + +function flatten(catalogue: Catalogue, prefix = ''): Map<string, string> { + const entries = new Map<string, string>(); + for (const [segment, value] of Object.entries(catalogue)) { + if (!/^[a-z][a-z0-9_]*$/.test(segment) || segment.startsWith('lockey_')) { + throw new Error('Invalid UI key grammar'); + } + const key = prefix ? `${prefix}.${segment}` : segment; + if (typeof value === 'string' && value.trim()) { + entries.set(key, value); + } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + const nested = flatten(value as Catalogue, key); + if (!nested.size) throw new Error('Empty catalogue group'); + for (const [nestedKey, message] of nested) entries.set(nestedKey, message); + } else { + throw new Error('Empty or invalid UI message'); + } + } + return entries; +} + +function assertPlainMessages(elements: MessageFormatElement[]): void { + for (const element of elements) { + if (element.type === TYPE.tag) throw new Error('Rich UI messages are forbidden'); + if (element.type === TYPE.plural || element.type === TYPE.select) { + for (const option of Object.values(element.options)) assertPlainMessages(option.value); + } + } +} + +function checkCatalogues(catalogues: Catalogue[], usedKeys: readonly string[]): void { + const flattened = catalogues.map((catalogue) => flatten(catalogue)); + const reference = flattened[0]; + if (!reference?.size) throw new Error('UI catalogues must be nonempty'); + const referenceKeys = [...reference.keys()].sort(); + for (const catalogue of flattened) { + if (JSON.stringify([...catalogue.keys()].sort()) !== JSON.stringify(referenceKeys)) { + throw new Error('UI catalogue keys differ'); + } + for (const [key, message] of catalogue) { + const parsed = parse(message); + assertPlainMessages(parsed); + const expected = parse(reference.get(key)!); + if (!isStructurallySame(expected, parsed).success) { + throw new Error('UI message arguments differ'); + } + } + for (const key of usedKeys) { + if (!catalogue.has(key)) throw new Error('Used UI key is missing'); + } + } +} + +/** Census literal calls to the public translator, including destructured aliases. */ +function publicCallsiteKeys(source: string, filename = 'consumer.tsx'): string[] { + const file = ts.createSourceFile( + filename, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + const translatorNames = new Set(['t']); + const translationFactories = new Set(['getTranslations']); + const collectImports = (node: ts.Node): void => { + if ( + ts.isImportSpecifier(node) && + (node.propertyName?.text ?? node.name.text) === 'getTranslations' + ) { + translationFactories.add(node.name.text); + } + ts.forEachChild(node, collectImports); + }; + collectImports(file); + const collectBindings = (node: ts.Node): void => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) { + const initializer = ts.isAwaitExpression(node.initializer) + ? node.initializer.expression + : node.initializer; + if ( + ts.isCallExpression(initializer) && + ts.isIdentifier(initializer.expression) && + translationFactories.has(initializer.expression.text) + ) { + translatorNames.add(node.name.text); + } + if (ts.isPropertyAccessExpression(initializer) && initializer.name.text === 't') { + translatorNames.add(node.name.text); + } + } + if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name)) { + for (const element of node.name.elements) { + if ( + (element.propertyName?.getText(file) ?? element.name.getText(file)) === 't' && + ts.isIdentifier(element.name) + ) + translatorNames.add(element.name.text); + } + } + ts.forEachChild(node, collectBindings); + }; + collectBindings(file); + const keys: string[] = []; + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const expression = node.expression; + const isTranslator = ts.isIdentifier(expression) + ? translatorNames.has(expression.text) + : (ts.isPropertyAccessExpression(expression) && expression.name.text === 't') || + (ts.isElementAccessExpression(expression) && + ts.isStringLiteralLike(expression.argumentExpression) && + expression.argumentExpression.text === 't'); + if (isTranslator) { + const key = node.arguments[0]; + if (!key || !ts.isStringLiteralLike(key)) + throw new Error('UI callsite key must be literal'); + keys.push(key.text); + } + } + ts.forEachChild(node, visit); + }; + visit(file); + return keys; +} + +function productionCallsiteKeys(directory: string): string[] { + const keys: string[] = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory() && entry.name !== 'test') keys.push(...productionCallsiteKeys(path)); + if (entry.isFile() && /\.tsx?$/.test(entry.name) && !/\.(test|spec)\.tsx?$/.test(entry.name)) { + keys.push(...publicCallsiteKeys(readFileSync(path, 'utf8'), path)); + } + } + return keys; +} + +describe('Ui_Catalogues_Cover_Public_Call_Sites', () => { + it('validates complete bundled catalogues and every production callsite', () => { + const root = join(process.cwd(), 'src'); + checkCatalogues([en, tr], productionCallsiteKeys(root)); + }); + + it('rejects empty catalogues, empty messages, and empty groups', () => { + expect(() => checkCatalogues([{}, {}], [])).toThrow('nonempty'); + expect(() => checkCatalogues([{ page: { title: '' } }], [])).toThrow('Empty'); + expect(() => checkCatalogues([{ page: {} }], [])).toThrow('Empty'); + }); + + it('rejects a planted key missing in one locale', () => { + const incomplete = structuredClone(tr); + Reflect.deleteProperty(incomplete.catalog, 'title'); + expect(() => checkCatalogues([en, incomplete], [])).toThrow('keys differ'); + }); + + it('rejects a used key absent from every catalogue', () => { + const first = structuredClone(en); + const second = structuredClone(tr); + Reflect.deleteProperty(first.catalog, 'title'); + Reflect.deleteProperty(second.catalog, 'title'); + const keys = publicCallsiteKeys( + "const t = await getTranslations('public'); t('catalog.title');", + ); + expect(() => checkCatalogues([first, second], keys)).toThrow('Used UI key'); + }); + + it('rejects a misspelled callsite and a dynamic wire key', () => { + const keys = publicCallsiteKeys( + "const {t: copy} = createPublicTranslator(locale); copy('catalog.titel');", + ); + expect(() => checkCatalogues([en, tr], keys)).toThrow('Used UI key'); + expect(() => publicCallsiteKeys('publicUi.t(problem.messageKey);')).toThrow('must be literal'); + expect(() => + checkCatalogues( + [en, tr], + publicCallsiteKeys( + "import {getTranslations as messages} from 'next-intl/server'; const labels = await messages('public'); labels('catalog.titel');", + ), + ), + ).toThrow('Used UI key'); + expect(() => + checkCatalogues([en, tr], publicCallsiteKeys("ui['t']('catalog.titel');")), + ).toThrow('Used UI key'); + }); + + it('rejects malformed ICU even if all locales share the same malformed value', () => { + expect(() => + checkCatalogues([{ catalog: { count: '{count, plural, one {#}' } }], []), + ).toThrow(); + }); + + it.each([ + '{total, plural, one {# kurs} other {# kurs}}', + '{count, select, one {Bir kurs} other {Kurslar}}', + '{count, number} kurs', + '{count, plural, one {{name} kurs} other {# kurs}}', + ])('rejects mismatched ICU arguments: %s', (message) => { + const mismatch = structuredClone(tr); + mismatch.catalog.course_count = message; + expect(() => checkCatalogues([en, mismatch], [])).toThrow('arguments differ'); + }); + + it('rejects rich messages and backend wire identifiers', () => { + expect(() => checkCatalogues([{ catalog: { title: '<b>Courses</b>' } }], [])).toThrow('Rich'); + expect(() => checkCatalogues([{ lockey_missing: 'Missing' }], [])).toThrow('grammar'); + }); + + it('does not mistake comments, unrelated strings or helper internals for calls', () => { + expect( + publicCallsiteKeys("// t('missing')\nconst copy = 't(unknown)'; translate(key, values);"), + ).toEqual([]); + expect(publicCallsiteKeys("ui.t('page.recovery'); t('catalog.title');")).toEqual([ + 'page.recovery', + 'catalog.title', + ]); + }); +}); + +describe('server-only public message formatting', () => { + it('selects whole static bundles without changing the supplied content locale', () => { + expect(getPublicCatalogue('tr-Latn-TR')).toEqual({ + locale: 'tr', + direction: 'ltr', + messages: tr, + }); + expect(getPublicCatalogue('ar')).toEqual({ locale: 'en', direction: 'ltr', messages: en }); + }); + + it('uses the selected UI language for pluralization and number formatting', () => { + const { t } = createPublicTranslator('zh'); + expect(t('catalog.course_count', { count: 1 })).toBe('1 course'); + expect(t('catalog.course_count', { count: 2 })).toBe('2 courses'); + expect(t('catalog.course_count', { count: 1000 })).toBe('1,000 courses'); + expect(createPublicTranslator('tr-TR').t('course.lesson_count', { count: 1000 })).toBe( + '1.000 ders', + ); + }); + + it('returns translated bounded copy on formatter failure without logging inputs', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { t } = createPublicTranslator('tr-TR'); + // @ts-expect-error Planted missing argument exercises the runtime failure boundary. + expect(t('catalog.course_count')).toBe(tr.page.unavailable.description); + // @ts-expect-error Wire keys cannot become general UI lookup identifiers. + expect(t('lockey_private_wire_key', { secret: 'private-parameter' })).toBe( + tr.page.unavailable.description, + ); + expect(error).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }); + + it('returns the selected translated fallback when the ICU formatter itself fails', () => { + const { messages } = getPublicCatalogue('tr'); + const original = messages.catalog.course_count; + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + messages.catalog.course_count = '{private_diagnostic'; + expect(createPublicTranslator('tr').t('catalog.course_count', { count: 7 })).toBe( + tr.page.unavailable.description, + ); + expect(error).not.toHaveBeenCalled(); + } finally { + messages.catalog.course_count = original; + } + }); + + it('shares bounded callbacks with the server request configuration', () => { + const options = publicFormattingOptions('ar'); + expect(options.getMessageFallback()).toBe(en.page.unavailable.description); + expect(options.onError()).toBeUndefined(); + }); +}); diff --git a/frontend/apps/web/src/i18n/catalogues.ts b/frontend/apps/web/src/i18n/catalogues.ts new file mode 100644 index 00000000..1b0213f1 --- /dev/null +++ b/frontend/apps/web/src/i18n/catalogues.ts @@ -0,0 +1,55 @@ +import 'server-only'; + +import { createTranslator } from 'next-intl'; + +import { selectUiLocale, textDirection } from './locale'; +import en from './messages/en/public.json'; +import tr from './messages/tr/public.json'; + +const catalogues = { en, tr }; + +type LeafKeys<T> = { + [K in keyof T & string]: T[K] extends string ? K : `${K}.${LeafKeys<T[K]>}`; +}[keyof T & string]; + +export type PublicMessageKey = LeafKeys<typeof en>; +export type PublicMessageArguments<K extends PublicMessageKey> = K extends + | 'catalog.course_count' + | 'course.lesson_count' + ? { count: number } + : never; +export type PublicTranslate = <K extends PublicMessageKey>( + key: K, + ...args: [PublicMessageArguments<K>] extends [never] + ? [values?: undefined] + : [values: PublicMessageArguments<K>] +) => string; + +export function getPublicCatalogue(contentLocale: string) { + const locale = selectUiLocale(contentLocale); + return { locale, direction: textDirection(locale), messages: catalogues[locale] }; +} + +/** Also supplied to next-intl's server request configuration. */ +export function publicFormattingOptions(contentLocale: string) { + const { messages } = getPublicCatalogue(contentLocale); + return { + // Library diagnostics may contain keys and interpolated values. + onError: () => {}, + getMessageFallback: () => messages.page.unavailable.description, + }; +} + +export function createPublicTranslator(contentLocale: string) { + const catalogue = getPublicCatalogue(contentLocale); + const translate = createTranslator({ + locale: catalogue.locale, + messages: { public: catalogue.messages }, + namespace: 'public', + ...publicFormattingOptions(contentLocale), + }); + // JSON imports preserve key shapes but widen ICU values to string. This + // interface restores the checked argument contract without exposing rich/raw. + const t = translate as unknown as PublicTranslate; + return { locale: catalogue.locale, direction: catalogue.direction, t }; +} diff --git a/frontend/apps/web/src/i18n/locale.test.ts b/frontend/apps/web/src/i18n/locale.test.ts new file mode 100644 index 00000000..c84a77f9 --- /dev/null +++ b/frontend/apps/web/src/i18n/locale.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { selectUiLocale, textDirection } from './locale'; + +describe('public UI locale selection', () => { + it.each([ + ['en', 'en'], + ['tr', 'tr'], + ['tr-TR', 'tr'], + ['tr-Latn-TR', 'tr'], + ['en-Latn-US', 'en'], + ['zh-Hans-CN', 'en'], + ['ar', 'en'], + ['und', 'en'], + ])('selects one whole catalogue for admitted %s', (contentLocale, expected) => { + expect(selectUiLocale(contentLocale)).toBe(expected); + }); + + it.each([ + ['ar', 'rtl'], + ['ar-EG', 'rtl'], + ['he', 'rtl'], + ['az-Arab', 'rtl'], + ['az-Latn', 'ltr'], + ['en', 'ltr'], + ['tr-TR', 'ltr'], + ['und', 'ltr'], + ['not_a_locale', 'ltr'], + ])('derives %s direction from runtime data', (locale, expected) => { + expect(textDirection(locale)).toBe(expected); + }); + + it('uses deterministic ltr when runtime locale information is unavailable', () => { + vi.spyOn(Intl, 'Locale').mockImplementation(() => { + throw new RangeError('unavailable runtime data'); + }); + expect(textDirection('ar')).toBe('ltr'); + }); +}); diff --git a/frontend/apps/web/src/i18n/locale.ts b/frontend/apps/web/src/i18n/locale.ts new file mode 100644 index 00000000..902c823d --- /dev/null +++ b/frontend/apps/web/src/i18n/locale.ts @@ -0,0 +1,30 @@ +export const uiLocales = ['en', 'tr'] as const; +export type UiLocale = (typeof uiLocales)[number]; +export type TextDirection = 'ltr' | 'rtl'; + +/** Selects UI copy only; callers retain the admitted content locale. */ +export function selectUiLocale(contentLocale: string): UiLocale { + let candidate = contentLocale; + while (candidate) { + const authored = uiLocales.find((locale) => locale === candidate); + if (authored) return authored; + const separator = candidate.lastIndexOf('-'); + if (separator < 0) break; + candidate = candidate.slice(0, separator); + } + return 'en'; +} + +/** Runtime data supplies script direction, including script-qualified tags. */ +export function textDirection(locale: string): TextDirection { + try { + const data = new Intl.Locale(locale) as Intl.Locale & { + getTextInfo?: () => { direction?: string }; + textInfo?: { direction?: string }; + }; + const direction = data.getTextInfo?.().direction ?? data.textInfo?.direction; + return direction === 'rtl' ? 'rtl' : 'ltr'; + } catch { + return 'ltr'; + } +} diff --git a/frontend/apps/web/src/i18n/messages/en/public.json b/frontend/apps/web/src/i18n/messages/en/public.json new file mode 100644 index 00000000..356d2441 --- /dev/null +++ b/frontend/apps/web/src/i18n/messages/en/public.json @@ -0,0 +1,57 @@ +{ + "chrome": { + "skip_to_content": "Skip to content", + "catalog": "Course catalog", + "attribution": "Powered by LearnStack" + }, + "catalog": { + "title": "Courses", + "course_count": "{count, plural, one {# course} other {# courses}}", + "empty": "No courses are available in this language yet.", + "next": "Next courses", + "restart": "Back to the first courses", + "level_unavailable": "Level unavailable" + }, + "course": { + "outline": "Course outline", + "lesson_count": "{count, plural, one {# lesson} other {# lessons}}", + "empty_outline": "No public lessons are available in this course yet.", + "restricted_outline": "The lessons in this course are not publicly available.", + "next": "Next lessons", + "restart": "Back to the first lessons", + "level_unavailable": "Level unavailable" + }, + "lesson": { + "unavailable": "This lesson content is currently unavailable.", + "empty": "This lesson has no content to display yet." + }, + "page": { + "missing": { + "title": "Page not found", + "description": "This page is not available. Browse the course catalog to continue." + }, + "invalid_cursor": { + "title": "This page link is no longer valid", + "description": "Return to the first page to continue browsing.", + "reset": "Return to the first page" + }, + "rate_limited": { + "title": "Please try again later", + "description": "There have been too many requests. Wait a moment before trying again." + }, + "unavailable": { + "title": "Content unavailable", + "description": "Content is temporarily unavailable. Please try again later." + }, + "loading": { + "title": "Loading content", + "description": "Please wait while the content loads." + }, + "error": { + "title": "Something went wrong", + "description": "This page could not be displayed. Please try again.", + "retry": "Try again" + }, + "recovery": "Browse courses" + } +} diff --git a/frontend/apps/web/src/i18n/messages/tr/public.json b/frontend/apps/web/src/i18n/messages/tr/public.json new file mode 100644 index 00000000..a5ba626b --- /dev/null +++ b/frontend/apps/web/src/i18n/messages/tr/public.json @@ -0,0 +1,57 @@ +{ + "chrome": { + "skip_to_content": "İçeriğe geç", + "catalog": "Kurs kataloğu", + "attribution": "LearnStack altyapısıyla" + }, + "catalog": { + "title": "Kurslar", + "course_count": "{count, plural, one {# kurs} other {# kurs}}", + "empty": "Bu dilde henüz sunulan bir kurs yok.", + "next": "Sonraki kurslar", + "restart": "İlk kurslara dön", + "level_unavailable": "Seviye bilgisi mevcut değil" + }, + "course": { + "outline": "Kurs içeriği", + "lesson_count": "{count, plural, one {# ders} other {# ders}}", + "empty_outline": "Bu kursta henüz herkese açık bir ders yok.", + "restricted_outline": "Bu kursun dersleri herkese açık değildir.", + "next": "Sonraki dersler", + "restart": "İlk derslere dön", + "level_unavailable": "Seviye bilgisi mevcut değil" + }, + "lesson": { + "unavailable": "Bu dersin içeriği şu anda kullanılamıyor.", + "empty": "Bu derste henüz görüntülenecek içerik yok." + }, + "page": { + "missing": { + "title": "Sayfa bulunamadı", + "description": "Bu sayfa mevcut değil. Devam etmek için kurs kataloğuna göz atın." + }, + "invalid_cursor": { + "title": "Bu sayfa bağlantısı artık geçerli değil", + "description": "Göz atmaya devam etmek için ilk sayfaya dönün.", + "reset": "İlk sayfaya dön" + }, + "rate_limited": { + "title": "Lütfen daha sonra tekrar deneyin", + "description": "Çok fazla istek alındı. Tekrar denemeden önce biraz bekleyin." + }, + "unavailable": { + "title": "İçerik kullanılamıyor", + "description": "İçerik geçici olarak kullanılamıyor. Lütfen daha sonra tekrar deneyin." + }, + "loading": { + "title": "İçerik yükleniyor", + "description": "İçerik yüklenirken lütfen bekleyin." + }, + "error": { + "title": "Bir sorun oluştu", + "description": "Bu sayfa görüntülenemedi. Lütfen tekrar deneyin.", + "retry": "Tekrar dene" + }, + "recovery": "Kurslara göz at" + } +} diff --git a/frontend/apps/web/src/i18n/request.ts b/frontend/apps/web/src/i18n/request.ts new file mode 100644 index 00000000..1d111c21 --- /dev/null +++ b/frontend/apps/web/src/i18n/request.ts @@ -0,0 +1,21 @@ +import 'server-only'; + +import { notFound } from 'next/navigation'; +import { getRequestConfig } from 'next-intl/server'; + +import { getPublicRequest } from '@/server/public-request'; + +import { getPublicCatalogue, publicFormattingOptions } from './catalogues'; + +export default getRequestConfig(async () => { + const request = await getPublicRequest(); + if (!request) notFound(); + // English applies only to an admitted non-localized scaffold, never a refusal. + const contentLocale = request.locale ?? 'en'; + const catalogue = getPublicCatalogue(contentLocale); + return { + locale: catalogue.locale, + messages: { public: catalogue.messages }, + ...publicFormattingOptions(contentLocale), + }; +}); diff --git a/frontend/apps/web/src/middleware.test.ts b/frontend/apps/web/src/middleware.test.ts index 140951b7..30b8f551 100644 --- a/frontend/apps/web/src/middleware.test.ts +++ b/frontend/apps/web/src/middleware.test.ts @@ -70,6 +70,20 @@ beforeEach(() => { afterEach(() => vi.unstubAllEnvs()); describe('Node public middleware', () => { + it('continues only the exact admitted status namespace with a no-store 404', async () => { + const response = await middleware(incoming('/tr/status/not-found?slug=private')); + expect(response.status).toBe(404); + expect(response.headers.get('x-middleware-next')).toBe('1'); + expect(response.headers.get('location')).toBeNull(); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(bootstrap).toHaveBeenCalledTimes(1); + for (const path of ['/fr/status/not-found', '/tr/status/other', '/tr/status/not-found/extra']) { + const refused = await middleware(incoming(path)); + expect(refused.status).toBe(404); + expect(refused.headers.get('x-middleware-next')).toBeNull(); + } + }); + it.each(['missing', 'forged'] as const)( 'refuses %s provenance before bootstrap', async (stamp) => { diff --git a/frontend/apps/web/src/middleware.ts b/frontend/apps/web/src/middleware.ts index 2d52835c..e0a68656 100644 --- a/frontend/apps/web/src/middleware.ts +++ b/frontend/apps/web/src/middleware.ts @@ -2,7 +2,7 @@ import { NextResponse, type NextRequest } from 'next/server'; import { createConfiguredPublicClient, publicTraceparent } from '@/server/configured-public-client'; import { INGRESS_HEADER, verifyNextProvenance } from '@/server/ingress'; -import { publicEntry, publicRedirect } from '@/server/public-entry'; +import { isPublicStatusTarget, publicEntry, publicRedirect } from '@/server/public-entry'; function refusal(status: 404 | 429 | 503, retryAfter?: number): NextResponse { return new NextResponse( @@ -64,7 +64,10 @@ export async function middleware(request: NextRequest) { if (accept !== null) downstream.set('accept', accept); downstream.set(INGRESS_HEADER, envelope!); // Verified above; the caller verifies again. downstream.set('traceparent', traceparent); - const response = NextResponse.next({ request: { headers: downstream } }); + const response = NextResponse.next({ + status: isPublicStatusTarget(context.target) ? 404 : 200, + request: { headers: downstream }, + }); response.headers.set('cache-control', 'no-store'); return response; } catch { diff --git a/frontend/apps/web/src/server/configured-public-client.test.ts b/frontend/apps/web/src/server/configured-public-client.test.ts index ccb5f279..cba2211b 100644 --- a/frontend/apps/web/src/server/configured-public-client.test.ts +++ b/frontend/apps/web/src/server/configured-public-client.test.ts @@ -165,20 +165,25 @@ describe('configured public authority and operation boundary', () => { ]); }); - it.each(['/', '/courses', '/tr-tr/courses', '/%74r-TR/courses', '/tr_TR/courses', '/studio'])( - 'refuses Education calls without a canonical content route: %s', - async (target) => { - const transport = stubResponse(); - const configured = client({}, target); - expect(await configured.getCourses()).toEqual({ kind: 'invalid-request' }); - expect(await configured.getCourse({ slug: 'intro' })).toEqual({ kind: 'invalid-request' }); - expect(await configured.getLesson({ slug: 'intro', lessonSlug: 'first' })).toEqual({ - kind: 'invalid-request', - }); - expect(transport).not.toHaveBeenCalled(); - expect((await configured.getSite()).kind).toBe('success'); - }, - ); + it.each([ + '/', + '/courses', + '/tr-tr/courses', + '/%74r-TR/courses', + '/tr_TR/courses', + '/studio', + '/tr/status/not-found', + ])('refuses Education calls without a canonical content route: %s', async (target) => { + const transport = stubResponse(); + const configured = client({}, target); + expect(await configured.getCourses()).toEqual({ kind: 'invalid-request' }); + expect(await configured.getCourse({ slug: 'intro' })).toEqual({ kind: 'invalid-request' }); + expect(await configured.getLesson({ slug: 'intro', lessonSlug: 'first' })).toEqual({ + kind: 'invalid-request', + }); + expect(transport).not.toHaveBeenCalled(); + expect((await configured.getSite()).kind).toBe('success'); + }); it('preserves SDK dot-segment rejection and contains encoded slug values on the private origin', async () => { const transport = stubResponse(); diff --git a/frontend/apps/web/src/server/public-entry.test.ts b/frontend/apps/web/src/server/public-entry.test.ts index 033f655e..a366bdc3 100644 --- a/frontend/apps/web/src/server/public-entry.test.ts +++ b/frontend/apps/web/src/server/public-entry.test.ts @@ -45,6 +45,7 @@ describe('public locale grammar and route-only identity', () => { ['/courses/courses', 'courses'], ['/studio/courses/foundation/lessons/intro', 'studio'], ['/portal/courses', 'portal'], + ['/tr/status/not-found', null], ['/TR/courses', null], ['/tr', null], ['/courses', null], @@ -76,6 +77,7 @@ describe('membership-first public entry', () => { ['/en', 307, '/en/courses'], ['/en/', 307, '/en/courses'], ['/TR/courses', 308, '/tr/courses'], + ['/TR/status/not-found?cursor=private', 308, '/tr/status/not-found?cursor=private'], ['/zh-hans-cn/courses/foundation', 308, '/zh-Hans-CN/courses/foundation'], ['/ZH-Hans-cn/', 308, '/zh-Hans-CN/'], ])('redirects %s with the configured non-English default', (target, status, path) => { @@ -87,6 +89,7 @@ describe('membership-first public entry', () => { '/en/courses/foundation', '/tr/courses/foundation/lessons/intro', '/tr/courses/%66oundation', + '/tr/status/not-found', ])('continues only supported content: %s', (target) => { expect(publicEntry(target, site())).toEqual({ kind: 'continue', @@ -112,6 +115,11 @@ describe('membership-first public entry', () => { '/courses//foundation', '/tr/courses/', '/tr/unknown', + '/fr/status/not-found', + '/tr/status', + '/tr/status/not-found/', + '/tr/status/not-found/extra', + '/tr/status/other', '/tr/courses/foundation/other', '/tr/courses/foundation/lessons/intro/extra', '/tr/courses/UPPER', diff --git a/frontend/apps/web/src/server/public-entry.ts b/frontend/apps/web/src/server/public-entry.ts index 6c7ceab2..4d69d214 100644 --- a/frontend/apps/web/src/server/public-entry.ts +++ b/frontend/apps/web/src/server/public-entry.ts @@ -57,6 +57,12 @@ function contentPath(parts: readonly string[]): boolean { ); } +/** Fixed error namespace; it grants no Education-route authority. */ +export function isPublicStatusTarget(target: string): boolean { + const parts = (target.split('?')[0] ?? '').slice(1).split('/'); + return parts.length === 3 && parts[1] === 'status' && parts[2] === 'not-found'; +} + /** The configured caller uses only a canonical locale from a supported signed route. */ export function canonicalRouteLocale(target: string): string | null { if (!validTarget(target)) return null; @@ -88,7 +94,8 @@ export function publicEntry(target: string, site: PublicSite): PublicEntry { const canonical = canonicalLocale(prefix); if (canonical !== null && enabled.includes(canonical)) { const root = parts.length === 1 || (parts.length === 2 && parts[1] === ''); - if (!root && !contentPath(parts.slice(1))) return { kind: 'refuse', status: 404 }; + if (!root && !contentPath(parts.slice(1)) && !isPublicStatusTarget(target)) + return { kind: 'refuse', status: 404 }; if (canonical !== prefix) return { kind: 'redirect', diff --git a/frontend/apps/web/src/server/public-request.test.ts b/frontend/apps/web/src/server/public-request.test.ts new file mode 100644 index 00000000..4830e04e --- /dev/null +++ b/frontend/apps/web/src/server/public-request.test.ts @@ -0,0 +1,233 @@ +// @vitest-environment node +import { randomBytes } from 'node:crypto'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createConfiguredPublicClient } from './configured-public-client'; +import type { ConfiguredPublicClient, publicTraceparent } from './configured-public-client'; +import { INGRESS_HEADER, mintProvenance, verifyProvenance } from './ingress'; +import type { PublicSite } from './public-entry'; +import { getPublicRequest } from './public-request'; + +const mocks = vi.hoisted(() => ({ headers: vi.fn<() => Promise<Headers>>() })); +vi.mock('next/headers', () => ({ headers: mocks.headers })); +vi.mock('./configured-public-client', async (importOriginal) => ({ + ...(await importOriginal<{ publicTraceparent: typeof publicTraceparent }>()), + createConfiguredPublicClient: vi.fn(), +})); +// Real memoization has dedicated controls and a production RSC proof. Admission +// cases deliberately execute a fresh loader for every invocation. +vi.mock('./request-memo', () => ({ + requestMemo: (load: (incoming: Headers) => Promise<unknown>) => async () => + load(await mocks.headers()), +})); + +const secret = randomBytes(32).toString('base64url'); +const traceparent = '00-11223344556677889900aabbccddeeff-1234567890abcdef-01'; +const createClient = vi.mocked(createConfiguredPublicClient); +const context = { + host: 'first.example:3000', + peer: '203.0.113.7', + method: 'GET', + target: '/tr-TR/courses?locale=en', +}; + +function site(enabledLocales = ['tr-TR', 'en', 'ar'], defaultLocale = 'tr-TR'): PublicSite { + return { + displayName: 'First institution', + enabledLocales, + defaultLocale, + theme: null, + showPlatformAttribution: true, + }; +} + +function client(configuration = site()) { + return { + getSite: vi + .fn<ConfiguredPublicClient['getSite']>() + .mockResolvedValue({ kind: 'success', status: 200, data: configuration }), + getCourses: vi.fn<ConfiguredPublicClient['getCourses']>(), + getCourse: vi.fn<ConfiguredPublicClient['getCourse']>(), + getLesson: vi.fn<ConfiguredPublicClient['getLesson']>(), + }; +} + +function incoming(target = context.target, host = context.host): Headers { + return new Headers({ + [INGRESS_HEADER]: mintProvenance({ ...context, target, host }, secret), + host: 'attacker.example', + 'x-learnstack-host': 'attacker.example', + 'x-forwarded-host': 'attacker.example', + 'accept-language': 'en', + cookie: 'locale=en; session=attacker', + 'x-next-intl-locale': 'en', + traceparent, + }); +} + +beforeEach(() => { + vi.stubEnv('LEARNSTACK_PUBLIC_HOP_SECRET', secret); + mocks.headers.mockReset(); + createClient.mockReset(); +}); +afterEach(() => vi.unstubAllEnvs()); + +describe('shared verified public request admission', () => { + it('uses authentic envelope authority, live enabled membership and signed locale only', async () => { + const headers = incoming(); + const configured = client(); + mocks.headers.mockResolvedValue(headers); + createClient.mockReturnValue(configured); + + const request = await getPublicRequest(); + + expect(request).toEqual({ + context, + client: configured, + site: site(), + locale: 'tr-TR', + route: { kind: 'catalog', path: '/tr-TR/courses' }, + }); + expect(createClient).toHaveBeenCalledWith(headers.get(INGRESS_HEADER), { traceparent }); + expect(createClient).toHaveBeenCalledOnce(); + expect(configured.getSite).toHaveBeenCalledOnce(); + expect(configured.getCourses).not.toHaveBeenCalled(); + expect(configured.getCourse).not.toHaveBeenCalled(); + expect(configured.getLesson).not.toHaveBeenCalled(); + }); + + it.each([null, '', 'unsigned-host', 'v1.invalid.invalid'])( + 'refuses %s before configured caller or private configuration', + async (envelope) => { + const headers = incoming(); + if (envelope === null) headers.delete(INGRESS_HEADER); + else headers.set(INGRESS_HEADER, envelope); + mocks.headers.mockResolvedValue(headers); + createClient.mockImplementation(() => { + throw new Error('Configuration must not be read'); + }); + + expect(await getPublicRequest()).toBeNull(); + expect(createClient).not.toHaveBeenCalled(); + }, + ); + + it.each([ + '/favicon.ico', + '/favicon.ico?x=1', + '/api/healthz', + '/_next/static/missing.js', + '/_next/webpack-hmr', + ])('never bootstraps UI from a transport fallback: %s', async (target) => { + mocks.headers.mockResolvedValue(incoming(target)); + createClient.mockImplementation(() => { + throw new Error('Transport path cannot construct a caller'); + }); + expect(await getPublicRequest()).toBeNull(); + expect(createClient).not.toHaveBeenCalled(); + }); + + it('rejects forged and repeated authentic stamps before caller construction', async () => { + const stamp = mintProvenance(context, secret); + for (const envelope of [ + mintProvenance(context, randomBytes(32).toString('base64url')), + `${stamp}, ${stamp}`, + ]) { + mocks.headers.mockResolvedValue(new Headers({ [INGRESS_HEADER]: envelope })); + expect(await getPublicRequest()).toBeNull(); + } + expect(createClient).not.toHaveBeenCalled(); + }); + + it.each([ + ['/en/courses', ['tr-TR'], 'tr-TR'], + ['/tr-TR/courses', [], 'tr-TR'], + ['/tr-TR/courses', ['tr-TR', 'tr-TR'], 'tr-TR'], + ['/tr-TR/courses', ['tr-TR'], 'en'], + ['/tr-TR/courses', ['TR-tr'], 'TR-tr'], + ['/TR-tr/courses', ['tr-TR'], 'tr-TR'], + ['/tr-TR/unknown', ['tr-TR'], 'tr-TR'], + ['/', ['tr-TR'], 'tr-TR'], + ])( + 'does not expose a request when live entry refuses or redirects %s', + async (target, locales, defaultLocale) => { + mocks.headers.mockResolvedValue(incoming(target)); + const configured = client(site(locales, defaultLocale)); + createClient.mockReturnValue(configured); + expect(await getPublicRequest()).toBeNull(); + expect(configured.getSite).toHaveBeenCalledOnce(); + }, + ); + + it.each(['/ar/courses', '/tr-TR/status/not-found', '/studio', '/portal'])( + 'admits enabled unsupported UI locales, fixed status and scaffolds: %s', + async (target) => { + mocks.headers.mockResolvedValue(incoming(target)); + createClient.mockReturnValue(client()); + const request = await getPublicRequest(); + expect(request?.locale).toBe( + target === '/studio' || target === '/portal' ? null : target.split('/')[1], + ); + expect(request?.context.target).toBe(target); + }, + ); + + it('bounds configuration exceptions, missing clients and unsuccessful bootstrap results', async () => { + mocks.headers.mockResolvedValue(incoming()); + createClient.mockImplementationOnce(() => { + throw new Error('private configuration'); + }); + expect(await getPublicRequest()).toBeNull(); + createClient.mockReturnValueOnce(null); + expect(await getPublicRequest()).toBeNull(); + for (const result of [ + { kind: 'transport-error' }, + { kind: 'invalid-response', reason: 'invalid-success' }, + ] as const) { + const configured = client(); + configured.getSite.mockResolvedValue(result); + createClient.mockReturnValueOnce(configured); + expect(await getPublicRequest()).toBeNull(); + } + const configured = client(); + configured.getSite.mockRejectedValue(new Error('private diagnostic')); + createClient.mockReturnValueOnce(configured); + expect(await getPublicRequest()).toBeNull(); + }); + + it('keeps overlapping host and locale admissions independent and re-reads later requests', async () => { + let releaseFirst!: (value: Awaited<ReturnType<ConfiguredPublicClient['getSite']>>) => void; + const first = client(); + first.getSite.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = resolve; + }), + ); + const secondSite = { ...site(['en'], 'en'), displayName: 'Second institution' }; + const second = client(secondSite); + mocks.headers + .mockResolvedValueOnce(incoming()) + .mockResolvedValueOnce(incoming('/en/courses', 'second.example:3000')); + createClient.mockImplementation((envelope) => + verifyProvenance(envelope, secret)?.host === context.host ? first : second, + ); + + const pendingFirst = getPublicRequest(); + const pendingSecond = getPublicRequest(); + expect((await pendingSecond)?.site).toBe(secondSite); + releaseFirst({ kind: 'success', status: 200, data: site() }); + const firstRequest = await pendingFirst; + expect(firstRequest?.locale).toBe('tr-TR'); + expect(firstRequest?.context.host).toBe(context.host); + expect(first.getSite).toHaveBeenCalledOnce(); + expect(second.getSite).toHaveBeenCalledOnce(); + + // A later live disablement must refuse rather than reuse the earlier admission. + mocks.headers.mockResolvedValueOnce(incoming()); + first.getSite.mockResolvedValueOnce({ kind: 'success', status: 200, data: site(['en'], 'en') }); + expect(await getPublicRequest()).toBeNull(); + expect(first.getSite).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/apps/web/src/server/public-request.ts b/frontend/apps/web/src/server/public-request.ts new file mode 100644 index 00000000..00589f9a --- /dev/null +++ b/frontend/apps/web/src/server/public-request.ts @@ -0,0 +1,50 @@ +import 'server-only'; + +import { createConfiguredPublicClient, publicTraceparent } from './configured-public-client'; +import type { ConfiguredPublicClient } from './configured-public-client'; +import { INGRESS_HEADER, verifyProvenance } from './ingress'; +import { publicEntry } from './public-entry'; +import type { PublicSite } from './public-entry'; +import { publicRoute } from './public-route'; +import type { PublicRoute } from './public-route'; +import { requestMemo } from './request-memo'; + +export type PublicRequest = { + readonly context: NonNullable<ReturnType<typeof verifyProvenance>>; + readonly client: ConfiguredPublicClient; + readonly site: PublicSite; + readonly locale: string | null; + readonly route: PublicRoute; +}; + +/** One incoming RSC request only; this module deliberately knows no UI catalogue. */ +export const getPublicRequest = requestMemo(async (incoming): Promise<PublicRequest | null> => { + const envelope = incoming.get(INGRESS_HEADER); + const context = verifyProvenance(envelope, process.env.LEARNSTACK_PUBLIC_HOP_SECRET); + if (!context) return null; + // A missing static asset can invoke Next's root not-found renderer. These + // transport paths bypass public middleware and must not bootstrap tenant UI. + const path = context.target.split('?')[0]; + if (path === '/favicon.ico' || path === '/api/healthz' || path?.startsWith('/_next/')) + return null; + try { + const client = createConfiguredPublicClient(envelope, { + traceparent: publicTraceparent(incoming.get('traceparent')), + }); + if (!client) return null; + const result = await client.getSite(); + if (result.kind !== 'success') return null; + const entry = publicEntry(context.target, result.data); + if (entry.kind !== 'continue') return null; + return { + context, + client, + site: result.data, + locale: entry.locale, + route: publicRoute(context.target), + }; + } catch { + // Fail before selecting a tenant document; private configuration is never logged. + return null; + } +}); diff --git a/frontend/apps/web/src/server/public-resource.test.ts b/frontend/apps/web/src/server/public-resource.test.ts new file mode 100644 index 00000000..0b7d9325 --- /dev/null +++ b/frontend/apps/web/src/server/public-resource.test.ts @@ -0,0 +1,278 @@ +// @vitest-environment node +import type { ApiResult, AppError } from '@learnstack/sdk/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ConfiguredPublicClient } from './configured-public-client'; +import { getPublicRequest } from './public-request'; +import type { PublicRequest } from './public-request'; +import { getPublicResource, requirePublicResource } from './public-resource'; +import { publicRoute } from './public-route'; + +const navigation = vi.hoisted(() => ({ + notFound: vi.fn<() => never>(), + redirect: vi.fn<(path: string) => never>(), +})); +vi.mock('next/navigation', () => navigation); +vi.mock('./public-request', () => ({ getPublicRequest: vi.fn() })); +// Dedicated memo controls and the production RSC fixture prove request reuse; +// these invocations independently exercise dispatch and closed outcome mapping. +vi.mock('./request-memo', () => ({ requestMemo: <T>(load: T): T => load })); + +const getRequest = vi.mocked(getPublicRequest); +const catalog = { + locale: 'tr-TR', + items: [], + pageInfo: { hasNext: false, hasPrevious: false, nextCursor: null, previousCursor: null }, +}; +const course = { + locale: 'tr-TR', + alternates: [], + lessons: null, + course: { + slug: 'foundation', + title: 'Foundation', + summary: null, + level: null, + contentAccess: 'enrollment_required' as const, + }, +}; +const lesson = { + locale: 'tr-TR', + alternates: [], + content: { state: 'unavailable' as const }, + course: { slug: 'foundation', title: 'Foundation' }, + lesson: { slug: 'intro', title: 'Introduction' }, +}; +const client = { + getSite: vi.fn<ConfiguredPublicClient['getSite']>(), + getCourses: vi.fn<ConfiguredPublicClient['getCourses']>(), + getCourse: vi.fn<ConfiguredPublicClient['getCourse']>(), + getLesson: vi.fn<ConfiguredPublicClient['getLesson']>(), +}; + +function request(target: string): PublicRequest { + return { + context: { host: 'institution.example:3000', peer: '203.0.113.7', method: 'GET', target }, + client, + site: { + displayName: 'Institution', + defaultLocale: 'tr-TR', + enabledLocales: ['tr-TR'], + showPlatformAttribution: true, + theme: null, + }, + locale: target === '/studio' || target === '/portal' ? null : 'tr-TR', + route: publicRoute(target), + }; +} + +function apiError(status: number, error: AppError): ApiResult<never> { + return { + kind: 'api-error', + status, + error, + problem: { + status, + code: 'private_backend_code', + messageKey: 'lockey_private_backend_key', + title: 'PRIVATE BACKEND TITLE', + instance: '/private-resource?cursor=private', + type: 'https://private.example/problem', + correlationId: 'private-correlation', + errors: { + privateField: [{ key: 'lockey_private_field', params: { secret: 'private-value' } }], + }, + }, + }; +} + +function expectNoEducation() { + expect(client.getCourses).not.toHaveBeenCalled(); + expect(client.getCourse).not.toHaveBeenCalled(); + expect(client.getLesson).not.toHaveBeenCalled(); +} + +beforeEach(() => { + getRequest.mockReset(); + for (const operation of Object.values(client)) operation.mockReset(); + client.getCourses.mockResolvedValue({ kind: 'success', status: 200, data: catalog }); + client.getCourse.mockResolvedValue({ kind: 'success', status: 200, data: course }); + client.getLesson.mockResolvedValue({ kind: 'success', status: 200, data: lesson }); + navigation.notFound.mockReset().mockImplementation(() => { + throw new Error('NEXT_NOT_FOUND'); + }); + navigation.redirect.mockReset().mockImplementation(() => { + throw new Error('NEXT_REDIRECT'); + }); +}); + +describe('public resource dispatch after shared live admission', () => { + it('refuses before any content call when request admission fails', async () => { + getRequest.mockResolvedValue(null); + expect(await getPublicResource()).toEqual({ kind: 'refused' }); + expectNoEducation(); + expect(client.getSite).not.toHaveBeenCalled(); + }); + + it.each(['/tr-TR/status/not-found?cursor=&limit=bad', '/studio', '/portal'])( + 'loads no Education data for %s', + async (target) => { + const admitted = request(target); + getRequest.mockResolvedValue(admitted); + expect(await getPublicResource()).toEqual({ + kind: target.includes('/status/') ? 'status' : 'scaffold', + request: admitted, + }); + expectNoEducation(); + expect(client.getSite).not.toHaveBeenCalled(); + }, + ); + + it('dispatches catalog using only its signed owned pagination', async () => { + const admitted = request( + '/tr-TR/courses?locale=en&cursor=opaque_1&limit=0005&lessonCursor=&lessonLimit=bad', + ); + getRequest.mockResolvedValue(admitted); + expect(await getPublicResource()).toEqual({ + kind: 'catalog', + request: admitted, + data: catalog, + pagination: { cursor: 'opaque_1', limit: '5', isPaginated: true }, + }); + expect(client.getCourses).toHaveBeenCalledWith({ cursor: 'opaque_1', limit: '5' }); + expect(client.getCourses).toHaveBeenCalledOnce(); + expect(client.getCourse).not.toHaveBeenCalled(); + expect(client.getLesson).not.toHaveBeenCalled(); + }); + + it('dispatches decoded admitted course slug and outline pagination only', async () => { + const admitted = request( + '/tr-TR/courses/%66oundation?slug=other&lessonCursor=opaque_2&lessonLimit=101&cursor=&limit=bad', + ); + getRequest.mockResolvedValue(admitted); + expect(await getPublicResource()).toEqual({ + kind: 'course', + request: admitted, + data: course, + pagination: { cursor: 'opaque_2', limit: '100', isPaginated: true }, + }); + expect(client.getCourse).toHaveBeenCalledWith( + { slug: 'foundation' }, + { lessonCursor: 'opaque_2', lessonLimit: '100' }, + ); + expect(client.getCourse).toHaveBeenCalledOnce(); + expect(client.getCourses).not.toHaveBeenCalled(); + expect(client.getLesson).not.toHaveBeenCalled(); + }); + + it('dispatches lesson path slugs and ignores pagination from either list surface', async () => { + const admitted = request( + '/tr-TR/courses/%66oundation/lessons/%69ntro?slug=other&lessonSlug=other&cursor=&limit=0&lessonCursor=&lessonLimit=0', + ); + getRequest.mockResolvedValue(admitted); + expect(await getPublicResource()).toEqual({ kind: 'lesson', request: admitted, data: lesson }); + expect(client.getLesson).toHaveBeenCalledWith({ slug: 'foundation', lessonSlug: 'intro' }); + expect(client.getLesson).toHaveBeenCalledOnce(); + expect(client.getCourses).not.toHaveBeenCalled(); + expect(client.getCourse).not.toHaveBeenCalled(); + }); + + it.each([ + '/tr-TR/courses?cursor=', + '/tr-TR/courses?cursor=a&cursor=b', + '/tr-TR/courses?limit=0', + '/tr-TR/courses?limit=1&limit=1', + '/tr-TR/courses/foundation?lessonCursor=bad%2Ftoken', + '/tr-TR/courses/foundation?lessonLimit=', + '/tr-TR/courses/foundation?lessonCursor=a&lessonCursor=b', + ])('refuses malformed owned pagination before Education: %s', async (target) => { + const admitted = request(target); + getRequest.mockResolvedValue(admitted); + expect(await getPublicResource()).toEqual({ + kind: 'failure', + request: admitted, + state: 'invalid_cursor', + }); + expectNoEducation(); + }); +}); + +describe.each([ + { target: '/tr-TR/courses', operation: client.getCourses }, + { target: '/tr-TR/courses/foundation', operation: client.getCourse }, + { target: '/tr-TR/courses/foundation/lessons/intro', operation: client.getLesson }, +])('controlled resource outcomes: $target', ({ target, operation }) => { + it.each([ + ['missing', apiError(404, { code: 'not_found', resource: 'PRIVATE RESOURCE' }), undefined], + ['failure', apiError(429, { code: 'rate_limited', retryAfter: 99 }), 'rate_limited'], + [ + 'failure', + apiError(400, { + code: 'validation_failed', + fieldErrors: { private: ['private diagnostic'] }, + }), + 'invalid_cursor', + ], + [ + 'failure', + apiError(503, { code: 'dependency_unavailable', provider: 'private provider' }), + 'unavailable', + ], + [ + 'failure', + apiError(500, { code: 'unknown', correlationId: 'private-correlation' }), + 'unavailable', + ], + ['failure', apiError(400, { code: 'request_rejected' }), 'unavailable'], + ['failure', { kind: 'transport-error' }, 'unavailable'], + ['failure', { kind: 'invalid-response', reason: 'invalid-success' }, 'unavailable'], + ['failure', { kind: 'invalid-request' }, 'unavailable'], + ['failure', { kind: 'cancelled' }, 'unavailable'], + ] as const)( + 'maps %s without retaining backend messages or problem details', + async (kind, result, state) => { + const admitted = request(target); + getRequest.mockResolvedValue(admitted); + operation.mockResolvedValue(result); + const resource = await getPublicResource(); + expect(resource).toEqual( + state ? { kind, request: admitted, state } : { kind, request: admitted }, + ); + expect(operation).toHaveBeenCalledOnce(); + expect(JSON.stringify(resource)).not.toMatch(/private|PRIVATE|lockey_/); + }, + ); +}); + +describe('consumer admission and missing-content navigation', () => { + it('invokes framework refusal when admission failed', async () => { + getRequest.mockResolvedValue(null); + await expect(requirePublicResource()).rejects.toThrow('NEXT_NOT_FOUND'); + expect(navigation.notFound).toHaveBeenCalledOnce(); + expect(navigation.redirect).not.toHaveBeenCalled(); + expectNoEducation(); + }); + + it('redirects missing content to the fixed locale status path without original input', async () => { + getRequest.mockResolvedValue( + request('/tr-TR/courses/private-slug?cursor=private_cursor&next=https://evil.example'), + ); + client.getCourse.mockResolvedValue(apiError(404, { code: 'not_found' })); + await expect(requirePublicResource()).rejects.toThrow('NEXT_REDIRECT'); + expect(navigation.redirect).toHaveBeenCalledWith('/tr-TR/status/not-found'); + expect(navigation.redirect).toHaveBeenCalledOnce(); + expect(navigation.notFound).not.toHaveBeenCalled(); + }); + + it('returns controlled failures for ordinary rendering without redirecting', async () => { + const admitted = request('/tr-TR/courses?cursor='); + getRequest.mockResolvedValue(admitted); + expect(await requirePublicResource()).toEqual({ + kind: 'failure', + request: admitted, + state: 'invalid_cursor', + }); + expect(navigation.redirect).not.toHaveBeenCalled(); + expect(navigation.notFound).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/apps/web/src/server/public-resource.ts b/frontend/apps/web/src/server/public-resource.ts new file mode 100644 index 00000000..f3d4aa68 --- /dev/null +++ b/frontend/apps/web/src/server/public-resource.ts @@ -0,0 +1,95 @@ +import 'server-only'; + +import type { ApiResult, ServerSdk } from '@learnstack/sdk/server'; +import { notFound, redirect } from 'next/navigation'; + +import { getPublicRequest } from './public-request'; +import type { PublicRequest } from './public-request'; +import { publicPagination } from './public-route'; +import type { PublicPagination } from './public-route'; +import { requestMemo } from './request-memo'; + +type Success<T> = T extends { kind: 'success'; data: infer Data } ? Data : never; +type Catalog = Success<Awaited<ReturnType<ServerSdk['getCourses']>>>; +type Course = Success<Awaited<ReturnType<ServerSdk['getCourse']>>>; +type Lesson = Success<Awaited<ReturnType<ServerSdk['getLesson']>>>; +export type PublicFailureState = 'invalid_cursor' | 'rate_limited' | 'unavailable'; +export type PublicResource = + | { + readonly kind: 'catalog'; + readonly request: PublicRequest; + readonly data: Catalog; + readonly pagination: PublicPagination; + } + | { + readonly kind: 'course'; + readonly request: PublicRequest; + readonly data: Course; + readonly pagination: PublicPagination; + } + | { readonly kind: 'lesson'; readonly request: PublicRequest; readonly data: Lesson } + | { readonly kind: 'status'; readonly request: PublicRequest } + | { readonly kind: 'scaffold'; readonly request: PublicRequest } + | { readonly kind: 'missing'; readonly request: PublicRequest } + | { + readonly kind: 'failure'; + readonly request: PublicRequest; + readonly state: PublicFailureState; + } + | { readonly kind: 'refused' }; + +function failure<T>( + result: Exclude<ApiResult<T>, { kind: 'success' }>, + request: PublicRequest, +): PublicResource { + if (result.kind === 'api-error') { + if (result.status === 404) return { kind: 'missing', request }; + if (result.status === 429) return { kind: 'failure', request, state: 'rate_limited' }; + if (result.error.code === 'validation_failed') + return { kind: 'failure', request, state: 'invalid_cursor' }; + } + return { kind: 'failure', request, state: 'unavailable' }; +} + +/** Metadata, root and page share a single content read; the next document re-reads. */ +export const getPublicResource = requestMemo(async (): Promise<PublicResource> => { + const request = await getPublicRequest(); + if (!request) return { kind: 'refused' }; + const { route, client, context } = request; + if (route.kind === 'status') return { kind: 'status', request }; + if (route.kind === 'scaffold') return { kind: 'scaffold', request }; + if (route.kind === 'lesson') { + const result = await client.getLesson({ slug: route.slug, lessonSlug: route.lessonSlug }); + return result.kind === 'success' + ? { kind: 'lesson', request, data: result.data } + : failure(result, request); + } + const pagination = publicPagination(context.target, route.kind === 'course'); + if (!pagination) return { kind: 'failure', request, state: 'invalid_cursor' }; + if (route.kind === 'catalog') { + const result = await client.getCourses({ cursor: pagination.cursor, limit: pagination.limit }); + return result.kind === 'success' + ? { kind: 'catalog', request, data: result.data, pagination } + : failure(result, request); + } + const result = await client.getCourse( + { slug: route.slug }, + { + lessonCursor: pagination.cursor, + lessonLimit: pagination.limit, + }, + ); + return result.kind === 'success' + ? { kind: 'course', request, data: result.data, pagination } + : failure(result, request); +}); + +/** Each consumer honors admission; layouts do not serialize child execution. */ +export async function requirePublicResource(): Promise< + Exclude<PublicResource, { kind: 'missing' | 'refused' }> +> { + const resource = await getPublicResource(); + if (resource.kind === 'refused') notFound(); + if (resource.kind === 'missing') redirect(`/${resource.request.locale}/status/not-found`); + return resource; +} diff --git a/frontend/apps/web/src/server/public-route.test.ts b/frontend/apps/web/src/server/public-route.test.ts new file mode 100644 index 00000000..50793ab8 --- /dev/null +++ b/frontend/apps/web/src/server/public-route.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +import { publicPagination, publicRoute } from './public-route'; + +describe('admitted public route dispatch', () => { + it.each([ + ['/tr/courses?locale=en', { kind: 'catalog', path: '/tr/courses' }], + [ + '/zh-Hans-CN/courses/%66oundation?slug=other', + { kind: 'course', path: '/zh-Hans-CN/courses/%66oundation', slug: 'foundation' }, + ], + [ + '/ar/courses/foundation/lessons/%69ntro?lessonSlug=other', + { + kind: 'lesson', + path: '/ar/courses/foundation/lessons/%69ntro', + slug: 'foundation', + lessonSlug: 'intro', + }, + ], + ['/tr/status/not-found?cursor=private', { kind: 'status', path: '/tr/status/not-found' }], + ['/studio', { kind: 'scaffold', path: '/studio' }], + ['/portal', { kind: 'scaffold', path: '/portal' }], + ])('dispatches the signed admitted target %s', (target, route) => { + expect(publicRoute(target)).toEqual(route); + }); +}); + +describe.each([ + { outline: false, cursor: 'cursor', limit: 'limit' }, + { outline: true, cursor: 'lessonCursor', limit: 'lessonLimit' }, +])('owned pagination: $cursor / $limit', ({ outline, cursor, limit }) => { + const target = '/tr/courses/foundation'; + + it('defaults to 20 and does not mark a limit-only request as a continuation', () => { + expect(publicPagination(target, outline)).toEqual({ limit: '20', isPaginated: false }); + expect(publicPagination(`${target}?${limit}=7`, outline)).toEqual({ + limit: '7', + isPaginated: false, + }); + }); + + it.each([ + ['1', '1'], + ['0002', '2'], + ['100', '100'], + ['101', '100'], + ['9999999999', '100'], + ])('normalizes positive decimal %s to the API bound %s', (value, expected) => { + expect(publicPagination(`${target}?${limit}=${value}`, outline)).toEqual({ + limit: expected, + isPaginated: false, + }); + }); + + it.each([ + '', + '0', + '000', + '-1', + '+1', + '%2B1', + '%201', + '1%20', + '1.5', + '1e2', + '12', + '10000000000', + '%0A1', + ])('refuses malformed owned limit %s', (value) => { + expect(publicPagination(`${target}?${limit}=${value}`, outline)).toBeNull(); + }); + + it.each(['', 'a+b', 'a%2Bb', 'a%2Fb', 'a=', 'a.b', '%20', '%0A', 'é', '%FF'])( + 'refuses malformed owned cursor %s', + (value) => { + expect(publicPagination(`${target}?${cursor}=${value}`, outline)).toBeNull(); + }, + ); + + it('rejects duplicate owned names even when encoded or equal', () => { + for (const name of [cursor, limit]) { + const encoded = `%${name.charCodeAt(0).toString(16)}${name.slice(1)}`; + expect(publicPagination(`${target}?${name}=1&${encoded}=1`, outline)).toBeNull(); + expect(publicPagination(`${target}?${name}=1&${name}=2`, outline)).toBeNull(); + } + }); + + it('accepts opaque base64url characters without decoding or interpreting the cursor', () => { + // These tokens are intentionally not valid API cursor envelopes. + for (const token of ['A', 'not_an_api_cursor-0', 'a'.repeat(1024)]) { + expect(publicPagination(`${target}?${cursor}=${token}`, outline)).toEqual({ + cursor: token, + limit: '20', + isPaginated: true, + }); + } + expect(publicPagination(`${target}?${cursor}=${'a'.repeat(1025)}`, outline)).toBeNull(); + }); + + it('ignores foreign query data including the other surface pagination names', () => { + const foreignCursor = outline ? 'cursor' : 'lessonCursor'; + const foreignLimit = outline ? 'limit' : 'lessonLimit'; + const query = `${foreignCursor}=&${foreignCursor}=bad%2Ftoken&${foreignLimit}=0&${foreignLimit}=huge&locale=en&next=https://evil.example&unknown=raw`; + expect(publicPagination(`${target}?${query}`, outline)).toEqual({ + limit: '20', + isPaginated: false, + }); + expect(publicPagination(`${target}?${query}&${cursor}=opaque_1&${limit}=3`, outline)).toEqual({ + cursor: 'opaque_1', + limit: '3', + isPaginated: true, + }); + }); +}); diff --git a/frontend/apps/web/src/server/public-route.ts b/frontend/apps/web/src/server/public-route.ts new file mode 100644 index 00000000..02ab177d --- /dev/null +++ b/frontend/apps/web/src/server/public-route.ts @@ -0,0 +1,55 @@ +import { canonicalRouteLocale, isPublicStatusTarget } from './public-entry'; + +export type PublicRoute = + | { readonly kind: 'catalog'; readonly path: string } + | { readonly kind: 'course'; readonly path: string; readonly slug: string } + | { + readonly kind: 'lesson'; + readonly path: string; + readonly slug: string; + readonly lessonSlug: string; + } + | { readonly kind: 'status'; readonly path: string } + | { readonly kind: 'scaffold'; readonly path: string }; + +/** Call only after live publicEntry admission; observed params are not authority. */ +export function publicRoute(target: string): PublicRoute { + const path = target.split('?')[0] ?? ''; + if (isPublicStatusTarget(target)) return { kind: 'status', path }; + if (canonicalRouteLocale(target) === null) return { kind: 'scaffold', path }; + const parts = path.split('/'); + if (parts.length === 3) return { kind: 'catalog', path }; + const slug = decodeURIComponent(parts[3] ?? ''); + return parts.length === 4 + ? { kind: 'course', path, slug } + : { kind: 'lesson', path, slug, lessonSlug: decodeURIComponent(parts[5] ?? '') }; +} + +export type PublicPagination = { + readonly cursor?: string; + readonly limit: string; + readonly isPaginated: boolean; +}; + +/** Cursor contents remain opaque; the API owns decoding and scope validation. */ +export function publicPagination(target: string, outline: boolean): PublicPagination | null { + const parameters = new URLSearchParams( + target.includes('?') ? target.slice(target.indexOf('?') + 1) : '', + ); + const cursorName = outline ? 'lessonCursor' : 'cursor'; + const limitName = outline ? 'lessonLimit' : 'limit'; + const cursors = parameters.getAll(cursorName); + const limits = parameters.getAll(limitName); + if (cursors.length > 1 || limits.length > 1) return null; + const cursor = cursors[0]; + const rawLimit = limits[0]; + if (cursor !== undefined && (cursor.length > 1024 || !/^[A-Za-z0-9_-]+$/.test(cursor))) + return null; + if (rawLimit !== undefined && (!/^[0-9]{1,10}$/.test(rawLimit) || Number(rawLimit) === 0)) + return null; + return { + cursor, + limit: rawLimit === undefined ? '20' : String(Math.min(100, Number(rawLimit))), + isPaginated: cursor !== undefined, + }; +} diff --git a/frontend/apps/web/src/server/public-ui.ts b/frontend/apps/web/src/server/public-ui.ts new file mode 100644 index 00000000..63a18c67 --- /dev/null +++ b/frontend/apps/web/src/server/public-ui.ts @@ -0,0 +1,14 @@ +import 'server-only'; + +import { getLocale, getTranslations } from 'next-intl/server'; +import { cache } from 'react'; + +import type { PublicTranslate } from '@/i18n/catalogues'; +import { textDirection } from '@/i18n/locale'; + +/** Uses the plugin's verified request configuration, never a callsite locale override. */ +export const getPublicUi = cache(async () => { + const [locale, translate] = await Promise.all([getLocale(), getTranslations('public')]); + const t: PublicTranslate = (key, ...args) => translate(key as string, args[0]); + return { locale, direction: textDirection(locale), t }; +}); diff --git a/frontend/apps/web/src/server/request-memo.test.ts b/frontend/apps/web/src/server/request-memo.test.ts new file mode 100644 index 00000000..54a8074e --- /dev/null +++ b/frontend/apps/web/src/server/request-memo.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment node +import { AsyncLocalStorage } from 'node:async_hooks'; + +import type { headers } from 'next/headers'; +import { describe, expect, it, vi } from 'vitest'; + +import { requestMemo } from './request-memo'; + +const mocks = vi.hoisted(() => ({ headers: vi.fn<() => Promise<Headers>>() })); +vi.mock('next/headers', () => ({ headers: mocks.headers })); + +describe('request identity memoization', () => { + it('shares pending and completed work across consumers using the exact request object', async () => { + const incoming = new Headers({ host: 'first.example' }); + mocks.headers.mockResolvedValue(incoming); + let release!: (value: object) => void; + const load = vi.fn( + () => + new Promise<object>((resolve) => { + release = resolve; + }), + ); + const get = requestMemo(load); + const first = get(); + const second = get(); + await vi.waitFor(() => expect(load).toHaveBeenCalledOnce()); + expect(load).toHaveBeenCalledWith(incoming); + const value = {}; + release(value); + expect(await first).toBe(value); + expect(await second).toBe(value); + expect(await get()).toBe(value); + expect(load).toHaveBeenCalledOnce(); + }); + + it('keeps equal headers in different requests separate, including overlapping execution', async () => { + const context = new AsyncLocalStorage<Headers>(); + mocks.headers.mockImplementation(async () => { + const incoming = context.getStore(); + if (!incoming) throw new Error('No request'); + return incoming; + }); + const first = new Headers({ host: 'same.example' }); + const second = new Headers(first); + let release!: () => void; + const paused = new Promise<void>((resolve) => { + release = resolve; + }); + const load = vi.fn(async (incoming: Awaited<ReturnType<typeof headers>>) => { + if (incoming === first) await paused; + return { incoming }; + }); + const get = requestMemo(load); + const firstResult = context.run(first, get); + const secondResult = await context.run(second, get); + expect(secondResult.incoming).toBe(second); + release(); + expect((await firstResult).incoming).toBe(first); + expect(load).toHaveBeenCalledTimes(2); + const next = new Headers(first); + expect((await context.run(next, get)).incoming).toBe(next); + expect(load).toHaveBeenCalledTimes(3); + }); + + it('does not collide independently memoized loaders within one request', async () => { + mocks.headers.mockResolvedValue(new Headers()); + const admission = requestMemo(async () => 'admission'); + const resource = requestMemo(async () => 42); + expect(await admission()).toBe('admission'); + expect(await resource()).toBe(42); + expect(await admission()).toBe('admission'); + }); + + it.each(['rejected', 'thrown'] as const)( + 'retains %s work without retrying the request', + async (kind) => { + mocks.headers.mockResolvedValue(new Headers()); + const failure = new Error('controlled failure'); + const load = vi.fn(() => { + if (kind === 'thrown') throw failure; + return Promise.reject(failure); + }); + const get = requestMemo(load); + await expect(get()).rejects.toBe(failure); + await expect(get()).rejects.toBe(failure); + expect(load).toHaveBeenCalledOnce(); + mocks.headers.mockResolvedValue(new Headers()); + await expect(get()).rejects.toBe(failure); + expect(load).toHaveBeenCalledTimes(2); + }, + ); + + it('retains a refused admission value for the request', async () => { + mocks.headers.mockResolvedValue(new Headers()); + const load = vi.fn(async () => null); + const get = requestMemo(load); + expect(await get()).toBeNull(); + expect(await get()).toBeNull(); + expect(load).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/apps/web/src/server/request-memo.ts b/frontend/apps/web/src/server/request-memo.ts new file mode 100644 index 00000000..9e29e55d --- /dev/null +++ b/frontend/apps/web/src/server/request-memo.ts @@ -0,0 +1,25 @@ +import 'server-only'; + +import { headers } from 'next/headers'; + +/** + * Next's request headers retain their identity when an error render replaces + * React's cache. Weak keys keep reuse inside that incoming request, including + * rejected work, without retaining requests after their framework lifetime. + * The public source guard constrains this small mechanism; the production + * fixture proves the pinned Next runtime's identity and freshness behavior. + */ +export function requestMemo<T>( + load: (incoming: Awaited<ReturnType<typeof headers>>) => Promise<T>, +): () => Promise<T> { + const requests = new WeakMap<Awaited<ReturnType<typeof headers>>, Promise<T>>(); + return async () => { + const incoming = await headers(); + let pending = requests.get(incoming); + if (!pending) { + pending = Promise.resolve().then(() => load(incoming)); + requests.set(incoming, pending); + } + return pending; + }; +} diff --git a/frontend/apps/web/src/test/fixture-support.test.ts b/frontend/apps/web/src/test/fixture-support.test.ts index ffc28bc3..956b8927 100644 --- a/frontend/apps/web/src/test/fixture-support.test.ts +++ b/frontend/apps/web/src/test/fixture-support.test.ts @@ -1,12 +1,12 @@ // @vitest-environment node import type { ChildProcess } from 'node:child_process'; import { EventEmitter } from 'node:events'; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import type { Server } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createFixtureOwner, @@ -102,9 +102,18 @@ describe('fixture private-output scanner', () => { }); describe('fixture positive environment', () => { + let root: string; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'learnstack-environment-')); + }); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); it('drops inherited secrets, loaders, proxies, debug and TLS trust overrides', () => { for (const [key, value] of Object.entries({ NODE_OPTIONS: '--require=/unsafe/loader', + SWC_NATIVE_BINDING_CACHE: '/unsafe/cache', + SWC_NATIVE_BINDING_CACHE_SKIP_SECURITY_CHECK: '1', NODE_TLS_REJECT_UNAUTHORIZED: '0', NODE_EXTRA_CA_CERTS: '/unsafe/ca', SSL_CERT_FILE: '/unsafe/ca', @@ -114,10 +123,11 @@ describe('fixture positive environment', () => { LEARNSTACK_PUBLIC_HOP_SECRET: 'inherited', })) vi.stubEnv(key, value); - const env = fixtureEnvironment({ root: '/owned', nodePath: '/explicit/modules' }); + const env = fixtureEnvironment({ root, nodePath: '/explicit/modules' }); expect(env).toEqual({ PATH: process.env.PATH, - TMPDIR: '/owned', + TMPDIR: root, + SWC_NATIVE_BINDING_CACHE: join(realpathSync(root), 'swc-native-cache'), NODE_ENV: 'production', NODE_PATH: '/explicit/modules', NODE_TLS_REJECT_UNAUTHORIZED: '1', @@ -126,6 +136,15 @@ describe('fixture positive environment', () => { }); }); + it('keeps native materialization inside the owned root through a temporary-directory alias', () => { + const alias = join(root, 'alias'); + symlinkSync(root, alias, 'dir'); + const env = fixtureEnvironment({ root: alias }); + expect(env.SWC_NATIVE_BINDING_CACHE).toBe(join(realpathSync(root), 'swc-native-cache')); + expect(env.HOME).toBeUndefined(); + expect(env.SWC_NATIVE_BINDING_CACHE_SKIP_SECURITY_CHECK).toBeUndefined(); + }); + it('accepts only explicitly configured runtime keys and deliberate debug canaries', () => { const privateValues = { LEARNSTACK_PUBLIC_API_ORIGIN: 'http://127.0.0.1:5080', @@ -134,17 +153,17 @@ describe('fixture positive environment', () => { LEARNSTACK_PUBLIC_TLS_KEY: '/owned/key', }; const env = fixtureEnvironment({ - root: '/owned', + root, nodeEnv: 'development', privateValues, debug: 'next:*', }); expect(env).toMatchObject({ ...privateValues, NODE_ENV: 'development', DEBUG: 'next:*' }); + expect(() => fixtureEnvironment({ root, privateValues: { NODE_OPTIONS: 'unsafe' } })).toThrow( + 'Unsupported fixture environment', + ); expect(() => - fixtureEnvironment({ root: '/owned', privateValues: { NODE_OPTIONS: 'unsafe' } }), - ).toThrow('Unsupported fixture environment'); - expect(() => - fixtureEnvironment({ root: '/owned', privateValues: { NEXT_PUBLIC_SECRET: 'unsafe' } }), + fixtureEnvironment({ root, privateValues: { NEXT_PUBLIC_SECRET: 'unsafe' } }), ).toThrow('Unsupported fixture environment'); }); }); diff --git a/frontend/apps/web/src/test/public-boundaries.test.ts b/frontend/apps/web/src/test/public-boundaries.test.ts index d11b6496..e2456ef4 100644 --- a/frontend/apps/web/src/test/public-boundaries.test.ts +++ b/frontend/apps/web/src/test/public-boundaries.test.ts @@ -16,6 +16,7 @@ import { clientBoundaryFindings, hasDirective, hasServerOnlyMarker, + I18N_REQUEST, INGRESS, MIDDLEWARE, privateServerFiles, @@ -23,6 +24,7 @@ import { PUBLIC_LAYOUT, rawAuthorityFindings, reachable, + REQUEST_MEMO, resolutionCensusFindings, SDK_SERVER, transportFindings, @@ -53,7 +55,10 @@ function productionSources(): SourceCensus { if (entry.isDirectory()) { // Runtime/test fixtures never enter the production graph. if (!['test', '__tests__', '__fixtures__', 'fixtures'].includes(entry.name)) visit(path); - } else if (/\.tsx?$/.test(entry.name) && !/\.(?:test|spec|d)\.tsx?$/.test(entry.name)) { + } else if ( + /\.(?:tsx?|json)$/.test(entry.name) && + !/\.(?:test|spec|d)\.(?:tsx?|json)$/.test(entry.name) + ) { sources[relative(frontend, path).split('\\').join('/')] = readFileSync(path, 'utf8'); } else extensionFindings.push( @@ -71,9 +76,10 @@ function productionSources(): SourceCensus { const sources = productionSources(); const production = buildSourceGraph(sources); const publicRoots = [...production.files.keys()].filter((name) => name.includes('/app/(public)/')); -// P5 middleware supplies bootstrap; public page composition remains P6. const ROOT_LAYOUT = 'apps/web/src/app/layout.tsx'; -const renderRoots = [...publicRoots, ROOT_LAYOUT, MIDDLEWARE]; +// next-intl loads this configuration through its plugin, outside static imports. +const renderRoots = [...publicRoots, ROOT_LAYOUT, MIDDLEWARE, I18N_REQUEST]; +const catalogues = ['en', 'tr'].map((locale) => `apps/web/src/i18n/messages/${locale}/public.json`); const publicGraph = reachable(production, renderRoots); const clean = (findings: readonly Finding[], fix: string) => expect(findings, fix + '\n' + JSON.stringify(findings, null, 2)).toEqual([]); @@ -135,6 +141,7 @@ describe('ADR-0053 production public boundaries', () => { expect.arrayContaining([ ADAPTER, INGRESS, + I18N_REQUEST, MIDDLEWARE, PUBLIC_LAYOUT, ROOT_LAYOUT, @@ -143,11 +150,28 @@ describe('ADR-0053 production public boundaries', () => { 'packages/sdk/src/response-policy.ts', 'packages/ui/src/index.ts', 'apps/web/src/server/public-entry.ts', + 'apps/web/src/server/public-request.ts', + ...catalogues, ]), ); expect(publicRoots).toContain(PUBLIC_LAYOUT); + expect(renderRoots).toContain(I18N_REQUEST); + expect(reachable(production, [I18N_REQUEST])).toEqual( + expect.arrayContaining([ + I18N_REQUEST, + 'apps/web/src/server/public-request.ts', + ADAPTER, + ...catalogues, + ]), + ); expect(privateServerFiles(production)).toEqual( - expect.arrayContaining([ADAPTER, INGRESS, SDK_SERVER]), + expect.arrayContaining([ + ADAPTER, + INGRESS, + SDK_SERVER, + I18N_REQUEST, + 'apps/web/src/server/public-request.ts', + ]), ); expect(reachable(production, [MIDDLEWARE])).toEqual( expect.arrayContaining([ @@ -230,7 +254,7 @@ describe('ADR-0053 production public boundaries', () => { }); it('the actual public render closure contains no shared cache', () => { expect(publicGraph).toEqual( - expect.arrayContaining([PUBLIC_LAYOUT, MIDDLEWARE, ADAPTER, SDK_SERVER]), + expect.arrayContaining([PUBLIC_LAYOUT, MIDDLEWARE, I18N_REQUEST, ADAPTER, SDK_SERVER]), ); clean( cacheFindings(production, publicGraph), @@ -240,7 +264,13 @@ describe('ADR-0053 production public boundaries', () => { it('public pages and their transitive helpers do not read raw authority', () => { const helpers = publicGraph.filter((name) => name !== INGRESS); expect(helpers).toEqual( - expect.arrayContaining([PUBLIC_LAYOUT, MIDDLEWARE, 'apps/web/src/server/public-entry.ts']), + expect.arrayContaining([ + PUBLIC_LAYOUT, + MIDDLEWARE, + I18N_REQUEST, + 'apps/web/src/server/public-entry.ts', + 'apps/web/src/server/public-request.ts', + ]), ); clean(rawAuthorityFindings(production, helpers), 'Fix: use verified ingress host/peer.'); }); @@ -463,6 +493,33 @@ describe('server boundary planted controls', () => { }); expect(clientBoundaryFindings(graph)).toHaveLength(1); }); + it('refuses a direct client import of each bundled UI catalogue', () => { + const catalogues = Object.keys(sources).filter( + (name) => name.startsWith('apps/web/src/i18n/messages/') && name.endsWith('.json'), + ); + expect(catalogues.length).toBeGreaterThan(0); + for (const catalogue of catalogues) { + const graph = buildSourceGraph({ + ...sources, + [probe]: `"use client"; import messages from "@/${catalogue.slice('apps/web/src/'.length)}"; export const leaked = messages;`, + }); + expect(clientBoundaryFindings(graph)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ file: probe, reason: expect.stringContaining(catalogue) }), + ]), + ); + } + }); + it('permits ordinary public JSON data in a client graph', () => { + const data = 'apps/web/src/lib/public-options.json'; + const graph = buildSourceGraph({ + [probe]: '"use client"; import options from "@/lib/public-options.json"; export { options };', + [data]: '{"sizes":[10,20]}', + }); + expect(reachable(graph, [probe])).toContain(data); + expect(privateServerFiles(graph)).not.toContain(data); + expect(clientBoundaryFindings(graph)).toEqual([]); + }); it('permits UI-only imports and erased type imports/re-exports', () => { const graph = buildSourceGraph({ ...sources, @@ -481,6 +538,60 @@ describe('server boundary planted controls', () => { }); describe('public cache fence planted controls', () => { + it('permits the audited request-identity memo independent of formatting and comments', () => { + const original = sources[REQUEST_MEMO]!; + expect(original).toContain('export function requestMemo'); + const graph = graphWith( + REQUEST_MEMO, + original.replace('const incoming', '/* same key */ const incoming'), + ); + expect(cacheFindings(graph, [REQUEST_MEMO])).toEqual([]); + }); + it.each([ + ['strong key retention', 'new WeakMap<', 'new Map<'], + ['header value key', 'requests.get(incoming)', "requests.get(incoming.get('host'))"], + [ + 'changed insertion key', + 'requests.set(incoming, pending)', + 'requests.set(new Headers(), pending)', + ], + [ + 'copied request identity', + 'const incoming = await headers()', + 'const incoming = new Headers(await headers())', + ], + ['caller-selected key', 'return async () =>', 'return async (incoming) =>'], + [ + 'eager loader before publication', + 'Promise.resolve().then(() => load(incoming))', + 'load(incoming)', + ], + [ + 'retry after rejection', + 'return pending;', + 'return pending.finally(() => requests.delete(incoming));', + ], + ['exposed collection', 'return pending;', 'return requests;'], + ['untyped value', 'Promise<T>>();', 'Promise<unknown>>();'], + ])('refuses mutated request memo mechanism: %s', (_name, before, after) => { + const original = sources[REQUEST_MEMO]!; + expect(original).toContain(before); + const mutated = original.replace(before, after); + expect(mutated).not.toBe(original); + expect(cacheFindings(graphWith(REQUEST_MEMO, mutated), [REQUEST_MEMO])).toEqual( + expect.arrayContaining([expect.objectContaining({ file: REQUEST_MEMO })]), + ); + }); + it.each([ + 'const shared = new Map();', + 'const shared = new WeakMap();', + 'export const leaked = new Map();', + ])('the request helper is not exempt from additional shared collections: %s', (added) => { + const graph = graphWith(REQUEST_MEMO, sources[REQUEST_MEMO]! + '\n' + added); + expect(cacheFindings(graph, [REQUEST_MEMO])).toEqual( + expect.arrayContaining([expect.objectContaining({ file: REQUEST_MEMO })]), + ); + }); it('the layout policy detector refuses missing or weakened actual production declarations', () => { const withoutDynamic = graphWith( PUBLIC_LAYOUT, @@ -1042,6 +1153,79 @@ describe('remediation bounded header conversions', () => { }); describe('remediation source resolution census controls', () => { + it.each([ + 'import messages from "@/lib/control.json";', + 'export { default as messages } from "@/lib/control.json";', + 'const messages = import("@/lib/control.json");', + 'const messages = require("@/lib/control.json");', + ])('includes valid JSON as an inert graph leaf: %s', (source) => { + const json = 'apps/web/src/lib/control.json'; + const graph = buildSourceGraph({ + [probe]: source, + [json]: JSON.stringify({ + cache: 'force-cache', + 'x-learnstack-host': 'An inert message key', + body: 'import("./missing"); fetch("/api");', + }), + }); + clean(graph.unresolved, 'Existing strict JSON resolves like other local modules.'); + expect(reachable(graph, [probe])).toEqual([probe, json].sort()); + expect(graph.edges.get(json)).toEqual([]); + clean( + [...transportFindings(graph), ...cacheFindings(graph, reachable(graph, [probe]))], + 'JSON property names and text are data, not executable transport or cache configuration.', + ); + }); + it('rejects an absent JSON import instead of exempting its extension', () => { + const graph = buildSourceGraph({ [probe]: 'import messages from "@/lib/missing.json";' }); + expect(graph.unresolved).toEqual([ + expect.objectContaining({ + file: probe, + reason: expect.stringContaining('Unresolved local module @/lib/missing.json'), + }), + ]); + }); + it.each(['{"message":', '{"message": "hello",}', '/* comment */ {}', 'fetch("/api");'])( + 'rejects malformed JSON in the census: %s', + (source) => { + const json = 'apps/web/src/lib/control.json'; + const graph = buildSourceGraph({ + [probe]: 'import messages from "@/lib/control.json";', + [json]: source, + }); + expect(reachable(graph, [probe])).toContain(json); + expect(graph.unresolved).toEqual([ + { + file: json, + line: 1, + reason: 'Malformed JSON module: use strict inert JSON in the production census.', + }, + ]); + }, + ); + it('does not treat a top-level JSON string as a client directive', () => { + const json = 'apps/web/src/lib/control.json'; + const graph = buildSourceGraph({ [json]: '"use client"' }); + clean(graph.unresolved, 'A JSON string is valid inert data.'); + expect(hasDirective(graph.files.get(json)!, 'use client')).toBe(false); + }); + it('keeps the plugin-loaded request configuration inside authority and cache fences', () => { + const graph = graphWith( + I18N_REQUEST, + sources[I18N_REQUEST]! + + '\nimport { headers as unsafeHeaders } from "next/headers";\n' + + 'export async function dirty() { return (await unsafeHeaders()).get("host"); }\n' + + 'export const dynamic = "force-static";', + ); + const subjects = reachable(graph, renderRoots); + expect(subjects).toContain(I18N_REQUEST); + expect(rawAuthorityFindings(graph, subjects)).toEqual( + expect.arrayContaining([expect.objectContaining({ file: I18N_REQUEST })]), + ); + expect(cacheFindings(graph, subjects)).toEqual( + expect.arrayContaining([expect.objectContaining({ file: I18N_REQUEST })]), + ); + }); it.each([ [ 'new local alias', diff --git a/frontend/apps/web/src/test/public-boundary-analysis.ts b/frontend/apps/web/src/test/public-boundary-analysis.ts index a007c851..29551f6c 100644 --- a/frontend/apps/web/src/test/public-boundary-analysis.ts +++ b/frontend/apps/web/src/test/public-boundary-analysis.ts @@ -22,6 +22,8 @@ export const ADAPTER = 'apps/web/src/server/configured-public-client.ts'; export const INGRESS = 'apps/web/src/server/ingress.ts'; export const MIDDLEWARE = 'apps/web/src/middleware.ts'; export const PUBLIC_LAYOUT = 'apps/web/src/app/(public)/layout.tsx'; +export const I18N_REQUEST = 'apps/web/src/i18n/request.ts'; +export const REQUEST_MEMO = 'apps/web/src/server/request-memo.ts'; export const SDK_SERVER = 'packages/sdk/src/server.ts'; const HOP_HEADERS = new Set([ 'x-learnstack-host', @@ -38,6 +40,9 @@ const RAW_AUTHORITY_HEADERS = new Set([ ]); export function walk(node: ts.Node, visitor: (node: ts.Node) => void): void { + // JSON is a graph leaf, not executable syntax. buildSourceGraph separately + // validates every JSON source strictly, so malformed data cannot evade census. + if (ts.isSourceFile(node) && node.flags & ts.NodeFlags.JsonFile) return; visitor(node); ts.forEachChild(node, (child) => walk(child, visitor)); } @@ -292,6 +297,19 @@ export function unsupportedSourceFindings(names: readonly string[]): Finding[] { } export function buildSourceGraph(sources: SourceCensus): SourceGraph { + const unresolved: Finding[] = []; + for (const [file, source] of Object.entries(sources)) { + if (!file.endsWith('.json')) continue; + try { + JSON.parse(source); + } catch { + unresolved.push({ + file, + line: 1, + reason: 'Malformed JSON module: use strict inert JSON in the production census.', + }); + } + } const files = new Map( Object.entries(sources).map(([name, source]) => [ name, @@ -300,7 +318,11 @@ export function buildSourceGraph(sources: SourceCensus): SourceGraph { source, ts.ScriptTarget.ES2022, true, - name.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + name.endsWith('.json') + ? ts.ScriptKind.JSON + : name.endsWith('.tsx') + ? ts.ScriptKind.TSX + : ts.ScriptKind.TS, ), ]), ); @@ -319,6 +341,7 @@ export function buildSourceGraph(sources: SourceCensus): SourceGraph { }; const options: ts.CompilerOptions = { noLib: true, + resolveJsonModule: true, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.Preserve, }; @@ -341,7 +364,6 @@ export function buildSourceGraph(sources: SourceCensus): SourceGraph { }; const checker = ts.createProgram([...files.keys()], options, host).getTypeChecker(); const edges = new Map<string, string[]>(); - const unresolved: Finding[] = []; for (const [name, file] of files) { const targets: string[] = []; const add = (expression: ts.Node | undefined, node: ts.Node) => { @@ -402,6 +424,7 @@ export function reachable(graph: SourceGraph, roots: readonly string[]): string[ } export function hasDirective(file: ts.SourceFile, directive: string): boolean { + if (file.flags & ts.NodeFlags.JsonFile) return false; for (const statement of file.statements) { if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression)) break; if (statement.expression.text === directive) return true; @@ -557,7 +580,12 @@ function environmentCollection( export function privateServerFiles(graph: SourceGraph): string[] { return [...graph.files] .filter(([name, file]) => { - if (name.includes('/src/server/') || name === SDK_SERVER || hasServerOnlyMarker(file)) + if ( + name.includes('/src/server/') || + (name.startsWith('apps/web/src/i18n/messages/') && name.endsWith('.json')) || + name === SDK_SERVER || + hasServerOnlyMarker(file) + ) return true; let privateEnvironment = false; walk(file, (node) => { @@ -853,11 +881,63 @@ function globalCollection( ); } +/** Ignore formatting/comments while retaining every executable and type-bearing node. */ +function syntaxShape(node: ts.Node): unknown { + const children: unknown[] = []; + ts.forEachChild(node, (child) => { + children.push(syntaxShape(child)); + }); + const value = ts.isIdentifier(node) || ts.isLiteralExpression(node) ? node.text : undefined; + const declarationKind = ts.isVariableDeclarationList(node) + ? node.flags & (ts.NodeFlags.Const | ts.NodeFlags.Let) + : undefined; + return [node.kind, value, declarationKind, children]; +} + +// This narrowly audited primitive is intentionally syntax-constrained. The map +// lives across renders, so ordinary function-local allocation alone cannot prove +// its lifetime. Derivation from Next's exact request object, private weak keys, +// publication before load, and rejection retention must all remain intact. +// A change to this mechanism owes new controls, not a filename exemption. +const requestMemoContract = syntaxShape( + ts.createSourceFile( + REQUEST_MEMO, + `import 'server-only'; + import { headers } from 'next/headers'; + export function requestMemo<T>( + load: (incoming: Awaited<ReturnType<typeof headers>>) => Promise<T>, + ): () => Promise<T> { + const requests = new WeakMap<Awaited<ReturnType<typeof headers>>, Promise<T>>(); + return async () => { + const incoming = await headers(); + let pending = requests.get(incoming); + if (!pending) { + pending = Promise.resolve().then(() => load(incoming)); + requests.set(incoming, pending); + } + return pending; + }; + }`, + ts.ScriptTarget.ES2022, + true, + ), +); + export function cacheFindings(graph: SourceGraph, subjects: readonly string[]): Finding[] { const findings: Finding[] = []; for (const name of subjects) { const file = graph.files.get(name); if (!file) continue; + if ( + name === REQUEST_MEMO && + JSON.stringify(syntaxShape(file)) !== JSON.stringify(requestMemoContract) + ) + findings.push( + finding( + file, + 'Fix: preserve the audited exact-header, private WeakMap request memo mechanism.', + ), + ); walk(file, (node) => { if ( (ts.isVariableDeclaration(node) && diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 73477836..6c91d342 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: next: specifier: 15.5.18 version: 15.5.18(@babel/core@7.29.7)(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.18(@babel/core@7.29.7)(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 @@ -36,6 +39,9 @@ importers: specifier: 0.0.1 version: 0.0.1 devDependencies: + '@formatjs/icu-messageformat-parser': + specifier: 3.5.18 + version: 3.5.18 '@learnstack/config': specifier: workspace:* version: link:../../packages/config @@ -276,6 +282,15 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@eloqnt/config@0.1.1': + resolution: {integrity: sha512-YRDigSaiJEzKDfzTth5kG+L/PKfB3mo8h7yNzpXExPfvQmcD05WoZT57PdhJE0jI+Vva6eb0OCU8hnHHgdME3g==} + + '@eloqnt/format-json@0.1.0': + resolution: {integrity: sha512-4Pkb+HyzgWNJXoU48aJhUyNZSjZvMGooPCNG1ZFmBm55xTiNNPQCNXH66Ob6sXS6RvbWZrRpCf+Uo/Jz9K9g+A==} + + '@eloqnt/format-po@0.1.1': + resolution: {integrity: sha512-aLtmIoqXGEaeTT0RcsLN8sZ3aIrsT/c/dVs28zrLhDjO3+vA3KNP+r98N0xq8w/k36+/GIUt4uZNbqcA71GVWw==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -461,6 +476,24 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@formatjs/fast-memoize@3.1.7': + resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==} + + '@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@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.8.14': + resolution: {integrity: sha512-8O7V38QmbB11+ryJ3KM/znrzXtiqgpXzqNoqDsndHZZ5/zDKTTL+cf5F0mzXkQEwhx8IlWnqYvEovf2S5EwLTw==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -710,6 +743,82 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@parcel/watcher-android-arm64@2.6.0': + resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.6.0': + resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.6.0': + resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.6.0': + resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.6.0': + resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.6.0': + resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.6.0': + resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.6.0': + resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.6.0': + resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.6.0': + resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-x64@2.6.0': + resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.6.0': + resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} + engines: {node: '>= 10.0.0'} + '@redocly/ajv@8.11.2': resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} @@ -854,9 +963,99 @@ packages: '@rushstack/eslint-patch@1.16.1': resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} + '@schummar/icu-type-parser@1.21.5': + resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + + '@swc/core-darwin-arm64@1.16.13': + resolution: {integrity: sha512-JrMH2h7ohd4mOIneNRx/diqTmCDSdWfp9uOdQ8VJ2V2CA7jOJ1jWb+qp8RoZLqgiVmwDDC7eySKB1kbvZ+jVXQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.16.13': + resolution: {integrity: sha512-HauZMRxpOImRxgY6GXjw5gbD8ts2NcPZuLMM8htY8Mic7j1Izt/lxkXrKYUwqyU56YLwQZ4dHRT+6jFHkztcag==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.16.13': + resolution: {integrity: sha512-tUSojGIythnHJKrElDM9OvuhBnK+FF39eM9iIBgj9kdfbAm6PhAM7dKbaSDcnX2gOLG2slkasjD0Z8m/YUlzOA==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.16.13': + resolution: {integrity: sha512-qS3bk/ezRrLXGn1b764S/EG3/oKj6uRhnTNRAALpnuu8d9I0YwSiUkQghnX7lOH9X/yRr+qrFI3R0wAruO47Tg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-arm64-musl@1.16.13': + resolution: {integrity: sha512-aeoYoG6fQsOS3mFOuTKvnJo1aaaxYNI0aRRlhdHaGP4q1YQx4XOc5Hkd35IFduWCBG3eZl3fPTjRuYvxpl7eTA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-ppc64-gnu@1.16.13': + resolution: {integrity: sha512-eEFoueZXWZ2S1lsqL8Ve2C8RjhzH9+Ktj8U4IGTUQL6CNG04+VhQiYQWJOf5xfVBU6I+PbH80yvsYegqZOOSZQ==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + + '@swc/core-linux-s390x-gnu@1.16.13': + resolution: {integrity: sha512-K0q1ReVXL3d5bzBgZxS+yJq5q2xRjg4k2XXGj9yiCEpE5ZqJGQ4Gtk1sRrk8W2lo3KY0DCJD3Myt6x2SMvGArQ==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + + '@swc/core-linux-x64-gnu@1.16.13': + resolution: {integrity: sha512-Qvpihw30irhIjy2f91BR1pamcJBCgf8hmr9XWenztAX1YLkneRkkTB/PFGWQubhaBqQhY2uZKViMj/xV61uMBQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-linux-x64-musl@1.16.13': + resolution: {integrity: sha512-iCGxHbh3Cq5vqRvCPlw3XkkDz4RlkPSyQwfFKbOKTMj5tiEuojY/rLmf4BMfI9Z8yPJTf9VB07t3NbKInoC97g==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-win32-arm64-msvc@1.16.13': + resolution: {integrity: sha512-iOabn6cd+hg3cnoKlAC8j3Yufv8nQHBVU5OIDX8dCPA95PjAZ0B0TlDn9sWivk8ezo/cLJux7p9n0B9vRej+1Q==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.16.13': + resolution: {integrity: sha512-+1b/3xBNuVbpnJIZUCAaSdscOPYRWq85eohKx44y/3qbPp56UMiCJWBiWtyr7K5F1nf1JENXm2IBSEJaLgWq6Q==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.16.13': + resolution: {integrity: sha512-6kEJhYshk7OVoVIWryiHqjCNuwZeOlPTS6et+bKV/WhtHs8fLA01wyTLrQGi+4ultgRe/o0M4b25K/KKdvl0dQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.16.13': + resolution: {integrity: sha512-k4uUsIMS0IwOKVsrQs48IZK75O4roV3FnuJEdL7ES+DFESftdgvz73QBqN3VUAbUmUr78We4tzG0kgYj36M22Q==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/types@0.1.28': + resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -1331,6 +1530,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1783,6 +1986,9 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + icu-minify@4.14.9: + resolution: {integrity: sha512-PmO+fNnC3K3mfPKTnm3RrPERrhQZnjm1AlpyZQ0ShbXi8Y/kYZ3YOde8TEjCLz9k3P2DmzkBn8lykGrCAi1Ajw==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1811,6 +2017,9 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + intl-messageformat@11.2.15: + resolution: {integrity: sha512-W4VNytsXyWgW5lvVchmsb9xgA5rSKqoflu3/IQ9UCtB0z4R+Bx9MoEkwFT/+30RCl/KzUoH0QGGe9lppF5SuvQ==} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2086,6 +2295,23 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + next-intl-swc-plugin-extractor@4.14.9: + resolution: {integrity: sha512-8jAkmDwQJo6RDavBHm9QRl1YTn2WXf+rI4SVhMaZxZcxopnwZic6fKTXOoNIo3kzC+a6vJySkOkXmHTVFLAYtg==} + + next-intl@4.14.9: + resolution: {integrity: sha512-uauRtUC+TrYNA1OL/QcwL9irndLrI9jK3Wdaa9W0B5AeKh9cqVdNeT0IWgLnhBqBRrVwppVdI0M+C1+JdGPQ3w==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + next@15.5.18: resolution: {integrity: sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -2107,6 +2333,9 @@ packages: sass: optional: true + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-exports-info@1.6.0: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} @@ -2231,6 +2460,9 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + po-parser@2.2.0: + resolution: {integrity: sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2664,6 +2896,15 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-intl@4.14.9: + resolution: {integrity: sha512-37ktvdCCz+6JY2VFPl/GV69Ds4ahiUT2c2xEZZRZYxdgGLxFsUbdxzjQVRm3YMIOUU0VPs6zvlifb4Yjk4IxIg==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2960,6 +3201,17 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@eloqnt/config@0.1.1': {} + + '@eloqnt/format-json@0.1.0': + dependencies: + '@eloqnt/config': 0.1.1 + + '@eloqnt/format-po@0.1.1': + dependencies: + '@eloqnt/config': 0.1.1 + po-parser: 2.2.0 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -3091,6 +3343,24 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@formatjs/fast-memoize@3.1.7': {} + + '@formatjs/icu-messageformat-parser@3.5.18': + dependencies: + '@formatjs/icu-skeleton-parser': 2.1.11 + + '@formatjs/icu-messageformat-parser@3.5.21': + dependencies: + '@formatjs/icu-skeleton-parser': 2.1.12 + + '@formatjs/icu-skeleton-parser@2.1.11': {} + + '@formatjs/icu-skeleton-parser@2.1.12': {} + + '@formatjs/intl-localematcher@0.8.14': + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -3274,6 +3544,62 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@parcel/watcher-android-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-x64@2.6.0': + optional: true + + '@parcel/watcher-freebsd-x64@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-musl@2.6.0': + optional: true + + '@parcel/watcher-win32-arm64@2.6.0': + optional: true + + '@parcel/watcher-win32-x64@2.6.0': + optional: true + + '@parcel/watcher@2.6.0': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.4 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.6.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-freebsd-x64': 2.6.0 + '@parcel/watcher-linux-arm-glibc': 2.6.0 + '@parcel/watcher-linux-arm-musl': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-arm64': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 + '@redocly/ajv@8.11.2': dependencies: fast-deep-equal: 3.1.3 @@ -3378,10 +3704,72 @@ snapshots: '@rushstack/eslint-patch@1.16.1': {} + '@schummar/icu-type-parser@1.21.5': {} + + '@swc/core-darwin-arm64@1.16.13': + optional: true + + '@swc/core-darwin-x64@1.16.13': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.16.13': + optional: true + + '@swc/core-linux-arm64-gnu@1.16.13': + optional: true + + '@swc/core-linux-arm64-musl@1.16.13': + optional: true + + '@swc/core-linux-ppc64-gnu@1.16.13': + optional: true + + '@swc/core-linux-s390x-gnu@1.16.13': + optional: true + + '@swc/core-linux-x64-gnu@1.16.13': + optional: true + + '@swc/core-linux-x64-musl@1.16.13': + optional: true + + '@swc/core-win32-arm64-msvc@1.16.13': + optional: true + + '@swc/core-win32-ia32-msvc@1.16.13': + optional: true + + '@swc/core-win32-x64-msvc@1.16.13': + optional: true + + '@swc/core@1.16.13': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.28 + optionalDependencies: + '@swc/core-darwin-arm64': 1.16.13 + '@swc/core-darwin-x64': 1.16.13 + '@swc/core-linux-arm-gnueabihf': 1.16.13 + '@swc/core-linux-arm64-gnu': 1.16.13 + '@swc/core-linux-arm64-musl': 1.16.13 + '@swc/core-linux-ppc64-gnu': 1.16.13 + '@swc/core-linux-s390x-gnu': 1.16.13 + '@swc/core-linux-x64-gnu': 1.16.13 + '@swc/core-linux-x64-musl': 1.16.13 + '@swc/core-win32-arm64-msvc': 1.16.13 + '@swc/core-win32-ia32-msvc': 1.16.13 + '@swc/core-win32-x64-msvc': 1.16.13 + + '@swc/counter@0.1.3': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 + '@swc/types@0.1.28': + dependencies: + '@swc/counter': 0.1.3 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -3900,6 +4288,8 @@ snapshots: concat-map@0.0.1: {} + content-type@2.1.0: {} + convert-source-map@2.0.0: {} cross-spawn@7.0.6: @@ -3974,8 +4364,7 @@ snapshots: dequal@2.0.3: {} - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} didyoumean@1.2.2: {} @@ -4507,6 +4896,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + icu-minify@4.14.9: + dependencies: + '@formatjs/icu-messageformat-parser': 3.5.21 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -4528,6 +4921,11 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 + intl-messageformat@11.2.15: + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@formatjs/icu-messageformat-parser': 3.5.18 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -4805,6 +5203,31 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + next-intl-swc-plugin-extractor@4.14.9: {} + + next-intl@4.14.9(@types/react@19.2.14)(next@15.5.18(@babel/core@7.29.7)(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 + '@eloqnt/format-po': 0.1.1 + '@formatjs/intl-localematcher': 0.8.14 + '@parcel/watcher': 2.6.0 + '@swc/core': 1.16.13 + icu-minify: 4.14.9 + negotiator: 1.1.0 + next: 15.5.18(@babel/core@7.29.7)(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) + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - '@swc/helpers' + next@15.5.18(@babel/core@7.29.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 15.5.18 @@ -4828,6 +5251,8 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-addon-api@7.1.1: {} + node-exports-info@1.6.0: dependencies: array.prototype.flatmap: 1.3.3 @@ -4954,6 +5379,8 @@ snapshots: pluralize@8.0.0: {} + po-parser@2.2.0: {} + possible-typed-array-names@1.1.0: {} postcss-import@15.1.0(postcss@8.5.15): @@ -5518,6 +5945,16 @@ snapshots: dependencies: punycode: 2.3.1 + use-intl@4.14.9(@types/react@19.2.14)(react@19.0.0): + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@schummar/icu-type-parser': 1.21.5 + icu-minify: 4.14.9 + intl-messageformat: 11.2.15 + react: 19.0.0 + optionalDependencies: + '@types/react': 19.2.14 + util-deprecate@1.0.2: {} vite-node@2.1.9(@types/node@22.19.19): From 73b9d53a1d15eb2bd8f60e5f7e1b41e04dcd719d Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 00:37:23 +0300 Subject: [PATCH 06/58] docs(roadmap): align P02d-6 foundation delivery status Keep the live packet table consistent with the committed foundation and record the first independent review round before Step 2. ADR: 0027 --- docs/roadmap/phase-02d-walking-skeleton.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 17f82448..547df5d7 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -1,6 +1,6 @@ # Phase 02d: Two-Tenant Walking Skeleton -> **Status (2026-10-09).** Phase 02d **in progress**. The kickoff, `P02d-0`, ships this +> **Status (2026-10-10).** Phase 02d **in progress**. 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,7 +14,7 @@ > | 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 | in progress — decision pass Accepted 2026-10-09; [decision package](#p02d-6-decision-package-2026-10-09); implementation pending | +> | P02d-6 | Public renderer | in progress — decision pass Accepted 2026-10-09; [decision package](#p02d-6-decision-package-2026-10-09); [Step 1 foundation delivered](#p02d-6-step-1-localization-and-document-foundation), reviews in progress; Steps 2–4 pending | > | P02d-7 | Demo, full-stack CI and exit | not started | **Acceptance update — 2026-10-02.** P02d-1 remains merged. The maintainer accepted @@ -4262,8 +4262,21 @@ browser E2E, Lighthouse or manual accessibility pass is claimed here. All five P02d-6 architecture-test rules remain **Registered** until their complete named proof obligations are verified; a foundation test alone does not promote the broader page-state or accessibility rule. Accessibility remains **Adopted**. -Round 1 and Round 2 have not started. Commit, both independent rounds and affected -revalidation precede Step 2 under the accepted implementation loop. + +#### Step 1 review round 1 (2026-10-10) + +Fresh GPT-6-astra xhigh and GPT-6.1-sol xhigh sessions review `c14ecf2..568ecc2` +independently. Runtime/security has no confirmed finding; the proof/corpus review +finds one stale live packet-table cell. It is verified against the delivered code +and corrected to link this foundation record and distinguish the remaining steps. +No production change is required by this round. + +Independent verification includes 216 focused runtime cases and 360 focused proof +cases; the proof reviewer also reruns all 817 guarded frontend cases with zero +skips/todos. Both reviewers approve the Step 1 scope. They inspect the production +fixture but do not rerun its native/Docker execution; the root's validation above +owns that evidence. Product/manual accessibility work remains Steps 2–4. +Round 2 has not started. Both rounds and affected revalidation precede Step 2. ### P02d-1 decision pass (2026-09-14) From 785c8c5d9dab86f9121bebc6057836103024cbb1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 00:41:07 +0300 Subject: [PATCH 07/58] docs(public): close P02d-6 foundation review rounds Align workflow examples and delivery status with the installed i18n foundation so subsequent pages use its checked translation contract. ADR: 0027 --- .claude/skills/add-frontend-route/SKILL.md | 19 +++++++++++-------- .claude/skills/add-i18n-key/SKILL.md | 13 +++++++------ docs/roadmap/phase-02d-walking-skeleton.md | 21 +++++++++++++++++++-- 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/.claude/skills/add-frontend-route/SKILL.md b/.claude/skills/add-frontend-route/SKILL.md index 6bcf3f1b..04ec098d 100644 --- a/.claude/skills/add-frontend-route/SKILL.md +++ b/.claude/skills/add-frontend-route/SKILL.md @@ -187,16 +187,17 @@ Hide, don't disable. The hook reads the entitlement projection. See ### Step 7: Localisation -**Accepted P02d-6 G39 — 2026-10-09; implementation pending.** +**P02d-6 G39 foundation delivered — Step 1, 2026-10-10.** [ADR-0027](../../../docs/decisions/0027-frontend-i18n.md) selects exact `next-intl` -4.14.9. The target async Server Component pattern is: +4.14.9, now installed with complete English/Turkish catalogues. The async Server +Component pattern is: ```tsx -import { getTranslations } from "next-intl/server"; +import { getPublicUi } from "@/server/public-ui"; export default async function CoursesPage() { - const t = await getTranslations("public.catalog"); - return <h1>{t("title")}</h1>; + const { t } = await getPublicUi(); + return <h1>{t("catalog.title")}</h1>; } ``` @@ -215,8 +216,10 @@ authority. Only whole-catalogue fallback is allowed for unauthored UI languages; missing used keys in supported catalogues fail validation. Preserve exact content locale, document language and actual resolved-label language. -Acceptance supplies no installed runtime, catalogue, `lint:i18n` command or -screenshot/axe tooling. Implement and prove the registered contract in P02d-6. +The guarded frontend suite supplies ICU, argument and callsite checks. No +`lint:i18n` command or screenshot/axe tooling exists. The +[Step 1 delivery record](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) +owns validation; product-page and manual accessibility proof remains Steps 2–4. ### Step 8: Public-site SSR caching @@ -229,7 +232,7 @@ is disabled. A new server/document request rechecks eligibility; client history not a revocation guarantee. Use the configured server caller; never derive tenancy from a page header or add hop options to the injected SDK. -**Accepted P02d-6 G40 — 2026-10-09; implementation pending.** Use ordinary +**Accepted P02d-6 G40 — 2026-10-09; foundation delivered in Step 1.** Use ordinary same-host relative anchors for public navigation and pagination, without automatic prefetch or reliance on retained client Router Cache. Request-local metadata, layout and page share verified admission and resource loaders; each page honors diff --git a/.claude/skills/add-i18n-key/SKILL.md b/.claude/skills/add-i18n-key/SKILL.md index 5ad08593..20706ed0 100644 --- a/.claude/skills/add-i18n-key/SKILL.md +++ b/.claude/skills/add-i18n-key/SKILL.md @@ -20,13 +20,14 @@ Manage user-facing translations in `apps/web` consistently per [08-localization.md](../../../docs/standards/08-localization.md) + [ADR-0008 Localization Schema](../../../docs/decisions/0008-localization-schema.md). -> **Accepted G39 — 2026-10-09; implementation pending.** +> **G39 foundation delivered — P02d-6 Step 1, 2026-10-10.** > [ADR-0027](../../../docs/decisions/0027-frontend-i18n.md) selects exact -> `next-intl` 4.14.9 and the catalogue home below for P02d-6. Acceptance installs -> no runtime, catalogue or tooling. No `pnpm lint:i18n`, `no-literal-strings`, -> `_deprecated.json`, screenshot or `axe-core` task exists today. Use the packet's -> registered proof obligations; do not claim these commands or checks run until -> implementation supplies them. +> `next-intl` 4.14.9 and the catalogue home below. Step 1 installs the runtime, +> complete English/Turkish catalogues and guarded ICU/callsite checks. Its +> [delivery record](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) +> owns validation and the remaining product/accessibility proof boundary. No +> `pnpm lint:i18n`, `no-literal-strings`, `_deprecated.json`, screenshot or +> `axe-core` task exists today; do not claim those checks run. ## When to use diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 547df5d7..8799cd5a 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -14,7 +14,7 @@ > | 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 | in progress — decision pass Accepted 2026-10-09; [decision package](#p02d-6-decision-package-2026-10-09); [Step 1 foundation delivered](#p02d-6-step-1-localization-and-document-foundation), reviews in progress; Steps 2–4 pending | +> | P02d-6 | Public renderer | in progress — decision pass Accepted 2026-10-09; [decision package](#p02d-6-decision-package-2026-10-09); [Step 1 foundation delivered](#p02d-6-step-1-localization-and-document-foundation), both review rounds complete; Steps 2–4 pending | > | P02d-7 | Demo, full-stack CI and exit | not started | **Acceptance update — 2026-10-02.** P02d-1 remains merged. The maintainer accepted @@ -4276,7 +4276,24 @@ cases; the proof reviewer also reruns all 817 guarded frontend cases with zero skips/todos. Both reviewers approve the Step 1 scope. They inspect the production fixture but do not rerun its native/Docker execution; the root's validation above owns that evidence. Product/manual accessibility work remains Steps 2–4. -Round 2 has not started. Both rounds and affected revalidation precede Step 2. + +#### Step 1 review round 2 (2026-10-10) + +Fresh GPT-6-astra xhigh and GPT-6.1-sol xhigh sessions independently review +`c14ecf2..73b9d53`. Runtime/security has no confirmed finding. Proof/corpus review +confirms two Minor workflow-document issues: stale installation status in the +i18n/route skills, and a route example whose narrowed translator namespace does +not match the implemented callsite census. Both are corrected against the shipped +foundation; the reviewer verifies the resulting skill diff. No production code +change is needed. + +The runtime reviewer passes 374 focused cases across ten files and verifies the +pinned Next/React CSS ordering with a no-port render. The proof reviewer passes +269 focused cases, five Release corpus cases and seven ADR workflow cases. +Both report zero failures/skips; +neither reruns native/Docker fixtures or claims browser/manual accessibility. +The root checks all 30 relative links/fragments in the two changed skills and +`git diff --check`. Both independent rounds are complete; Step 2 follows. ### P02d-1 decision pass (2026-09-14) From 917f9f86b3b0c51ac2ba51a18fbc23fea496b49b Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 00:57:24 +0300 Subject: [PATCH 08/58] feat(public): render catalog and course pages Serve institution catalogs and course outlines from the shared admitted resource, preserving independent pagination, restricted-content refusal and eligible localized metadata. Prove the actual production routes through the existing guarded API and native ingress fixture. ADR: 0027, 0052, 0053 --- CLAUDE.md | 7 +- .../Database/PublicServerRenderingTests.cs | 131 ++++- docs/architecture/12-localization.md | 10 +- docs/architecture/14-frontend-architecture.md | 11 +- docs/decisions/README.md | 2 +- docs/roadmap/phase-02d-walking-skeleton.md | 42 +- .../roadmap/phase-06-renderer-admin-studio.md | 6 +- docs/standards/03-frontend-coding.md | 13 +- docs/standards/07-frontend-architecture.md | 10 +- docs/standards/08-localization.md | 5 +- docs/standards/README.md | 4 +- .../web/scripts/verify-public-rendering.mjs | 375 ++++++++++++-- .../(public)/[locale]/courses/[slug]/page.tsx | 29 ++ .../app/(public)/[locale]/courses/page.tsx | 29 ++ .../apps/web/src/app/(public)/page.test.tsx | 28 - frontend/apps/web/src/app/(public)/page.tsx | 10 - frontend/apps/web/src/app/globals.css | 38 ++ .../components/public/catalog-course.test.tsx | 488 ++++++++++++++++++ .../web/src/components/public/catalog.tsx | 82 +++ .../apps/web/src/components/public/course.tsx | 96 ++++ .../web/src/server/public-metadata.test.ts | 336 ++++++++++++ .../apps/web/src/server/public-metadata.ts | 104 ++++ .../apps/web/src/server/public-paths.test.ts | 133 +++++ frontend/apps/web/src/server/public-paths.ts | 43 ++ 24 files changed, 1919 insertions(+), 113 deletions(-) create mode 100644 frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/page.tsx create mode 100644 frontend/apps/web/src/app/(public)/[locale]/courses/page.tsx delete mode 100644 frontend/apps/web/src/app/(public)/page.test.tsx delete mode 100644 frontend/apps/web/src/app/(public)/page.tsx create mode 100644 frontend/apps/web/src/components/public/catalog-course.test.tsx create mode 100644 frontend/apps/web/src/components/public/catalog.tsx create mode 100644 frontend/apps/web/src/components/public/course.tsx create mode 100644 frontend/apps/web/src/server/public-metadata.test.ts create mode 100644 frontend/apps/web/src/server/public-metadata.ts create mode 100644 frontend/apps/web/src/server/public-paths.test.ts create mode 100644 frontend/apps/web/src/server/public-paths.ts diff --git a/CLAUDE.md b/CLAUDE.md index cd0b1b33..bdfd191b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,8 +123,11 @@ Step 1 implements the pinned i18n and document foundation, verified request-loca admission/content loaders, atomic color theming, chrome and controlled status/error/ loading components. Its [delivery record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) -owns current validation and review evidence. Catalog/course and lesson pages -remain Steps 2 and 3. P02d-7 owns the browser demo and Lighthouse; Phase 06 retains +owns its validation and review evidence. Step 2 adds actual catalog/course pages, +independent pagination and metadata from the shared resource loader. Its +[delivery record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-2-catalog-and-course-pages) +owns current verification and review status. Lesson presentation remains Step 3. +P02d-7 owns the browser demo and Lighthouse; Phase 06 retains the full renderer/Studio expansion. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs index e84843d7..65f8a4b6 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs @@ -23,8 +23,8 @@ namespace LearnStack.Tests.Integration.Database; /// <summary> /// Production Next/native TLS/SDK/API/PostgreSQL proof. The disposable renderer -/// separates the P5 transport probe from the actual P6 document/status foundation. -/// Its content consumer is test-owned until product routes land; no diagnostic route ships. +/// separates the P5 transport probe from unchanged P6 catalog/course routes. +/// The larger pagination inventory exists only during its own fixture mode. /// </summary> [Collection(PublicReadTestGroup.Name)] [Trait(RequiresDocker.Key, RequiresDocker.Value)] @@ -36,6 +36,7 @@ public sealed class PublicServerRenderingTests(PublicReadFixture fixture) [Theory] [InlineData("transport")] [InlineData("foundation")] + [InlineData("foundation-pagination")] public async Task Production_rendering_is_host_isolated_fresh_and_private_through_the_real_public_API(string mode) { var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); @@ -80,6 +81,7 @@ public async Task Production_rendering_is_host_isolated_fresh_and_private_throug var verified = false; var foundationPosition = 0; var foundationLocales = false; + var paginationRows = false; var tracePosition = 0; var beforeProtocol = -1; var beforeStock = -1; @@ -95,6 +97,11 @@ await fixture.ExecuteAsync(SeedData.English, "INSERT INTO tenant_locales(tenant_id,locale,is_default,is_enabled,sort) VALUES(@tenant,'ar',false,true,8),(@tenant,'tr-TR',false,true,9),(@tenant,'tr',false,true,10)"); foundationLocales = true; } + if (mode == "foundation-pagination") + { + await AddPaginationRowsAsync(firstCourse); + paginationRows = true; + } started = process.Start(); started.Should().BeTrue(); var errors = BoundedErrorsAsync(process.StandardError, deadline.Token); @@ -169,6 +176,40 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new .Locale.Should().Be(locale, "UI configuration cannot change the exact content API locale"); foundationPosition = observed.Requests.Length; break; + case "foundation-catalog-english": + case "foundation-catalog-yoga": + case "foundation-catalog-turkish": + case "foundation-course-yoga": + case "foundation-course-turkish": + case "foundation-restricted": + case "foundation-empty": + case "pagination-catalog-first": + case "pagination-catalog-next": + case "pagination-catalog-restart": + case "pagination-outline-first": + case "pagination-outline-next": + case "pagination-outline-restart": + var productRequests = observed.Requests.Skip(foundationPosition).ToArray(); + productRequests.Should().HaveCount(3, "an unchanged page uses one shared content operation"); + productRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(2); + productRequests.Count(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)).Should().Be(1); + productRequests.Single(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)) + .Locale.Should().Be(checkpoint switch + { + "foundation-catalog-turkish" or "foundation-course-turkish" => + SeedData.Yoga.Curriculum!.Locales.Single(row => row.IsDefault).Locale, + "foundation-empty" => "ar", + _ => locale + }, "only the admitted content locale reaches Education"); + foundationPosition = observed.Requests.Length; + break; + case "foundation-cross-tenant": + var crossTenantRequests = observed.Requests.Skip(foundationPosition).ToArray(); + crossTenantRequests.Should().HaveCount(5, "missing cross-tenant detail and followed status use the approved redirect chain"); + crossTenantRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(4); + crossTenantRequests.Count(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)).Should().Be(1); + foundationPosition = observed.Requests.Length; + break; case "foundation-status": case "foundation-head": case "foundation-refused": @@ -199,8 +240,18 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new process.ExitCode.Should().Be(0, errorOutput); verified.Should().BeTrue("the production fixture must execute every scenario"); if (mode == "transport") checkpoints.Should().Equal("build-complete", "trace-supplied", "trace-missing", "trace-malformed", "protocol-before", "protocol-after", "stock-before", "stock-after", "make-draft", "restore-published", "verified"); - else checkpoints.Should().Equal("build-complete", "foundation-normal", "foundation-repeat", "foundation-missing", + else if (mode == "foundation") checkpoints.Should().Equal("build-complete", "foundation-normal", "foundation-repeat", + "foundation-catalog-english", "foundation-catalog-yoga", "foundation-catalog-turkish", + "foundation-course-yoga", "foundation-course-turkish", + "foundation-restricted", "foundation-empty", "foundation-cross-tenant", "foundation-missing", "foundation-status", "foundation-head", "foundation-refused", "foundation-canonical", "stock-before", "stock-after", "verified"); + else checkpoints.Should().Equal("build-complete", "pagination-catalog-first", "pagination-catalog-next", + "pagination-catalog-restart", "pagination-outline-first", "pagination-outline-next", + "pagination-outline-restart", "verified"); + if (mode == "foundation") observed.Requests.Should().HaveCount(52, + "normal/repeat, both hosts and languages, restricted/empty/cross-tenant, status and refusal checks fit one visitor window"); + if (mode == "foundation-pagination") observed.Requests.Should().HaveCount(18, + "six actual catalog/outline documents each make exactly three API calls"); observed.Requests.Length.Should().BeInRange(1, 59, "the fixture stays within one real anonymous visitor budget"); observed.Requests.Should().OnlyContain(request => request.ValidHop, "the real caller uses the closed authenticated hop"); observed.Logs.Should().BeGreaterThan(0, "API log containment needs a nonempty real logging subject"); @@ -226,6 +277,7 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new if (foundationLocales) await fixture.ExecuteAsync(SeedData.English, "DELETE FROM tenant_locales WHERE tenant_id=@tenant AND locale IN ('ar','tr-TR','tr')"); + if (paginationRows) await RemovePaginationRowsAsync(firstCourse); } } } @@ -330,20 +382,91 @@ private Task SetStatusAsync(SeedCourse course, string status) => fixture.Execute "UPDATE courses SET status=@status WHERE tenant_id=@tenant AND id=@id", new Dictionary<string, object> { ["id"] = course.Id, ["status"] = status.ToLowerInvariant() }); + private Task AddPaginationRowsAsync(SeedCourse course) + { + var lesson = course.Lessons.First(row => row.Status == "Published"); + return fixture.ExecuteOwnerFixtureAsync(SeedData.English, + """ + INSERT INTO courses(id,tenant_id,organization_id,slug_key,content_access,status,created_at,created_by) + SELECT md5('p6-fixture-course-' || n)::uuid,@tenant,NULL,'p6-fixture-course-' || n, + 'public','published',now() + n * interval '1 millisecond',@actor + FROM generate_series(1,21) AS n; + INSERT INTO course_translations(course_id,tenant_id,organization_id,locale,title,summary,slug) + SELECT md5('p6-fixture-course-' || n)::uuid,@tenant,NULL,'en', + 'Fixture course ' || lpad(n::text,2,'0'),'Fixture catalog continuation', + 'p6-fixture-course-' || n + FROM generate_series(1,21) AS n; + INSERT INTO lessons(id,tenant_id,organization_id,course_id,sort,content_type_key, + content_type_schema_version,status,created_at,created_by) + SELECT md5('p6-fixture-lesson-' || n)::uuid,@tenant,NULL,@course,1000+n, + @contentType,@contentVersion,'published',now(),@actor + FROM generate_series(1,21) AS n; + INSERT INTO lesson_translations(lesson_id,tenant_id,organization_id,locale,title,slug,body) + SELECT md5('p6-fixture-lesson-' || n)::uuid,@tenant,NULL,'en', + 'Fixture lesson ' || lpad(n::text,2,'0'),'p6-fixture-lesson-' || n, + CAST(@body AS jsonb) + FROM generate_series(1,21) AS n; + """, + new Dictionary<string, object> + { + ["course"] = course.Id, + ["contentType"] = lesson.ContentTypeKey, + ["contentVersion"] = lesson.ContentTypeSchemaVersion, + ["body"] = lesson.Translations.Single(row => row.Locale == "en").Body + }); + } + + private Task RemovePaginationRowsAsync(SeedCourse course) => fixture.ExecuteOwnerFixtureAsync( + SeedData.English, + """ + DELETE FROM lesson_translations WHERE tenant_id=@tenant AND slug LIKE 'p6-fixture-lesson-%'; + DELETE FROM lessons WHERE tenant_id=@tenant AND course_id=@course + AND id IN (SELECT md5('p6-fixture-lesson-' || n)::uuid FROM generate_series(1,21) AS n); + DELETE FROM course_translations WHERE tenant_id=@tenant AND slug LIKE 'p6-fixture-course-%'; + DELETE FROM courses WHERE tenant_id=@tenant AND slug_key LIKE 'p6-fixture-course-%'; + """, + new Dictionary<string, object> { ["course"] = course.Id }); + private static object TenantInput(SeedTenant tenant, SeedCourse course, string locale) { var lesson = course.Lessons.First(row => row.Status == "Published"); var translation = lesson.Translations.Single(row => row.Locale == locale); + var defaultLocale = tenant.Curriculum!.Locales.Single(row => row.IsDefault).Locale; + var defaultCourse = course.Translations.Single(row => row.Locale == defaultLocale); + var defaultLesson = lesson.Translations.Single(row => row.Locale == defaultLocale); + var visible = tenant.Curriculum.Courses.Where(row => row.Status == "Published" + && (row.OrganizationId is null || (tenant.MapHostToDefaultOrganization + && row.OrganizationId == tenant.DefaultOrganization.OrganizationId))).ToArray(); + var hidden = tenant.Curriculum.Courses.Except(visible).ToArray(); + var restricted = visible.FirstOrDefault(row => row.ContentAccess == "enrollment_required"); + var restrictedTranslation = restricted?.Translations.Single(row => row.Locale == locale); + var catalogEn = visible.Select(row => row.Translations.Single(translation => translation.Locale == locale)) + .Select(row => new { row.Title, row.Slug }).ToArray(); + var catalogDefault = visible.Select(row => row.Translations.Single(translation => translation.Locale == defaultLocale)) + .Select(row => new { row.Title, row.Slug }).ToArray(); using var body = JsonDocument.Parse(translation.Body); return new { host = tenant.Host, name = tenant.DisplayName, courseTitle = course.Translations.Single(row => row.Locale == locale).Title, + courseSummary = course.Translations.Single(row => row.Locale == locale).Summary, lessonSlug = translation.Slug, lessonTitle = translation.Title, lessonText = body.RootElement.EnumerateObject().First().Value.GetString(), - defaultLocale = tenant.Curriculum!.Locales.Single(row => row.IsDefault).Locale + defaultLocale, + defaultCourseSlug = defaultCourse.Slug, + defaultCourseTitle = defaultCourse.Title, + defaultCourseSummary = defaultCourse.Summary, + defaultLessonSlug = defaultLesson.Slug, + defaultLessonTitle = defaultLesson.Title, + restrictedSlug = restrictedTranslation?.Slug, + restrictedTitle = restrictedTranslation?.Title, + restrictedSummary = restrictedTranslation?.Summary, + catalogEn, + catalogDefault, + hiddenEn = hidden.Select(row => row.Translations.Single(translation => translation.Locale == locale).Title).ToArray(), + hiddenDefault = hidden.Select(row => row.Translations.Single(translation => translation.Locale == defaultLocale).Title).ToArray() }; } diff --git a/docs/architecture/12-localization.md b/docs/architecture/12-localization.md index 834056e6..b972ce2d 100644 --- a/docs/architecture/12-localization.md +++ b/docs/architecture/12-localization.md @@ -19,8 +19,9 @@ Initial renderer scope: - Document direction and logical layout properties apply from the first public renderer, including an enabled RTL locale; they are not deferred until a tenant requests a redesign. Accepted P02d-6 G39/G43 selects the mapping and proofs; - Step 1 implements direction selection and fallback chrome; actual content-page - language proofs remain later P02d-6 steps. + Step 1 implements direction selection and fallback chrome; Step 2 adds + catalog/course language attribution. Lesson and full product/manual proof + remain Steps 3–4. - P02d-6 accepts initial English and Turkish platform UI catalogues in [ADR-0027](../decisions/0027-frontend-i18n.md). Additional UI translations are added when authored; their absence must not narrow enabled content locales. @@ -341,8 +342,9 @@ claiming an Arabic catalogue. Pattern A content never gains cross-locale fallbac Step 1 implements the pinned dependency/plugin, catalogues, request runtime and key/ICU/callsite controls. The [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) -owns current verification and review evidence. Actual content-page proofs -remain later P02d-6 steps. Phase 04 inherits the foundation for CMS/Studio; Phase 06 +owns foundation verification and review evidence. Step 2 adds catalog/course +consumers; lesson and full product/manual proof remain Steps 3–4. Phase 04 inherits +the foundation for CMS/Studio; Phase 06 owns the full Studio/portal consumers. API error codes remain stable machine identifiers. Problem Details carries diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index d734b60f..ac573e11 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -27,15 +27,15 @@ native method/upgrade admission and pinned URL controls. The remediation record owns execution and independent review evidence; the ongoing rules live in [Frontend Standards](../standards/07-frontend-architecture.md#native-admission-and-url-identity). -**P02d-6 decisions Accepted — 2026-10-09; Step 1 foundation implemented.** +**P02d-6 decisions Accepted — 2026-10-09; Steps 1–2 implemented.** [ADR-0027](../decisions/0027-frontend-i18n.md) selects the UI foundation; the [decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09) selects pages, status/navigation, safe theme and product/accessibility proofs. Step 1 installs the pinned runtime and supplies shared request-local loaders, document language/direction, atomic theme injection, chrome and controlled -status/error/loading components. Actual catalog/course/lesson pages remain Steps 2 -and 3. The -[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) +status/error/loading components. Step 2 adds actual catalog/course pages, +independent opaque pagination and metadata. Lesson presentation remains Step 3. The +[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-2-catalog-and-course-pages) owns current validation and review evidence. > **P02d-4 Accepted design — 2026-10-03.** Step 2 delivers site bootstrap; @@ -94,7 +94,8 @@ frontend/ > **Phase 02d ownership.** Accepted G41 places synchronous plain-string views in > `apps/web/src/components/public/`; G39 selects the app-local i18n home above. -> Step 1 implements i18n and chrome/state views; content views remain Steps 2/3. +> Step 1 implements i18n and chrome/state views; Step 2 adds catalog/course views. +> Lesson presentation remains Step 3. > P02d-5/G36 delivers Node middleware in > `apps/web/src/middleware.ts`. The ownership and accepted entry rule are recorded in > [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 544f7359..464c5f7f 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -46,7 +46,7 @@ an amendment is not a lifecycle status change. | 0022 | [Custom Domain & TLS](0022-custom-domain-tls.md) | Hub-owned custom domain admin; DNS-01 + HTTP-01 + Let's Encrypt; APISIX hot-reload | | 0023 | [Strongly-Typed ID Source Generator — Vogen](0023-strongly-typed-id-source-generator.md) | Vogen as the source generator for both IDs and value objects; `[ValueObject<Guid>]` annotation; EF + JSON + ASP.NET + OpenAPI emitters out of the box (Amendment 1: Vogen 7.0.0 pin + architecture-test placement, 2026-05-21; Amendment 2: `UserId` / `TenantId` / `OrganizationId` are cross-cutting and live in `LearnStack.SharedKernel`, 2026-08-10; **Amendment 3: `IStronglyTypedId` gains `IsInitialized()` and is no longer a pure marker, 2026-08-10**; Amendments 4–7, 2026-08-27 to 2026-09-01: `idempotency_keys` mints no id, `gen_uuid_v7()` is not a PostgreSQL function, the retroactive disclosure of the 2026-05-21 edit, and an id's own formatting is not a wire format; **Amendment 8, 2026-09-07: a module-local identifier crosses a cross-module contract as `Guid`**) | | 0024 | [API Versioning Policy](0024-api-versioning-policy.md) | URL-based `/v{N}/`; 6-month deprecation window; RFC 8594 `Sunset` + `Deprecation` headers; OpenAPI `deprecated` + `x-sunset` extensions; 410 Gone with RFC 7807 on sunset | -| 0027 | [Frontend UI Localization with next-intl](0027-frontend-i18n.md) | Accepted 2026-10-09: next-intl 4.14.9, server-first app-local ICU catalogues, dotted UI keys and verified request-locale source; P02d-6 Step 1 implements the foundation; actual content pages remain Steps 2 and 3; Phase 04 consumes the foundation | +| 0027 | [Frontend UI Localization with next-intl](0027-frontend-i18n.md) | Accepted 2026-10-09: next-intl 4.14.9, server-first app-local ICU catalogues, dotted UI keys and verified request-locale source; P02d-6 Step 1 implements the foundation and Step 2 adds catalog/course pages; lesson presentation remains Step 3; Phase 04 consumes the foundation | | 0028 | [`audit_log` Partition Management — Hangfire Recurring Job](0028-audit-log-partition-management.md) | Daily `learnstack:audit:partition-management` Hangfire job; create-ahead 2 months; drop only on platform-max retention horizon; row-level purge separate; no `pg_partman` dependency (Amendment 2026-09-07: Packet 9 creates a **plain, unpartitioned** table and executes none of this ADR's DDL; `Partition_Manager_Job_Is_Registered_AtStartup` moves to Phase 11 with the job it guards) | | 0029 | [Object Storage — SeaweedFS](0029-object-storage-seaweedfs.md) | Self-hosted SeaweedFS behind the existing `IStorageProvider` S3 contract; partially supersedes ADR-0002's MinIO row | | 0030 | [Redis-compatible Store — Valkey](0030-redis-compatible-store-valkey.md) | Valkey (Linux Foundation, BSD-3-Clause) for the cache + Dapr state-store backend; RESP-protocol drop-in; partially supersedes ADR-0002's Redis row | diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 8799cd5a..8b9a68ff 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -14,7 +14,7 @@ > | 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 | in progress — decision pass Accepted 2026-10-09; [decision package](#p02d-6-decision-package-2026-10-09); [Step 1 foundation delivered](#p02d-6-step-1-localization-and-document-foundation), both review rounds complete; Steps 2–4 pending | +> | P02d-6 | Public renderer | in progress — decision pass Accepted 2026-10-09; [decision package](#p02d-6-decision-package-2026-10-09); [Step 1](#p02d-6-step-1-localization-and-document-foundation) review rounds complete; [Step 2 catalog/course](#p02d-6-step-2-catalog-and-course-pages) implemented, reviews pending; Steps 3–4 pending | > | P02d-7 | Demo, full-stack CI and exit | not started | **Acceptance update — 2026-10-02.** P02d-1 remains merged. The maintainer accepted @@ -4295,6 +4295,46 @@ neither reruns native/Docker fixtures or claims browser/manual accessibility. The root checks all 30 relative links/fragments in the two changed skills and `git diff --check`. Both independent rounds are complete; Step 2 follows. +### P02d-6 Step 2: Catalog and course pages + +**Implementation — 2026-10-10.** The actual catalog and course routes consume the +shared verified resource loader independently in page and metadata. The obsolete +platform placeholder is removed; live middleware retains default-locale entry. +Synchronous server views expose semantic course/outline lists, distinct empty and +restricted states, actual label language and independent opaque next/restart links. +Restricted marketing never emits lesson counts, links or access-purchase controls. + +Canonical/Open Graph URLs use the verified live host, fixed HTTPS listener and +validated local segments. Course alternates use actual eligible translated slugs; +catalog alternates use enabled locales. Cursor pages are noindex with first-page +canonicals; controlled failures are localized noindex states without resource +alternates. No metadata/page consumer adds another content read or client cache. + +The foundation fixture now copies actual catalog/course routes unchanged and +keeps the isolated P5 transport probe. A separate pagination mode uses test-owned +inventory above 20 items and a fresh unchanged visitor budget; historical seed +declarations stay unchanged. Lesson presentation remains Step 3; full product and +manual accessibility closeout remains Step 4. + +**Validation — 2026-10-10.** The root passes 981 guarded frontend cases (921 web, +60 SDK), zero failures/skips/todos, workspace typecheck/lint and production build. +Both product routes are dynamic; the build reports about 103 kB First Load JS, +which is a local build observation, not a Lighthouse or production performance +claim. All 198 Release architecture cases and seven ADR workflow controls pass, +with zero failures/skips. The solution Release build has zero warnings/errors. +Native ingress verification passes TLS, method/HEAD admission, redirects, +production closure, DEBUG containment and development HMR. + +All 17 focused Release `PublicServerRenderingTests` cases pass, zero failures/skips. +The actual foundation mode observes exactly 52 API calls; the independent default- +pagination mode observes 18. Both stay within a fresh unchanged 60-call visitor +budget. Positive host-scope assertions derive from the seed declaration: Yoga's +mapped host includes tenant-wide and its own organization's published marketing, +while excluding the other organization. These are HTTP/RSC proofs, not browser +or manual accessibility evidence. Formatting, local links/fragments, added prose +and diff checks pass. Both independent review rounds have not started; commit +and review closeout precede Step 3. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/roadmap/phase-06-renderer-admin-studio.md b/docs/roadmap/phase-06-renderer-admin-studio.md index 8b2f942b..5b34c3b2 100644 --- a/docs/roadmap/phase-06-renderer-admin-studio.md +++ b/docs/roadmap/phase-06-renderer-admin-studio.md @@ -26,12 +26,12 @@ Rows identify their delivery packet; planned P02d-6/7 work is not current behavi | Phase 02d foundation | Phase 06 adds | |---|---| | Host-based tenant + organization resolution, end to end | Per-organization branding override on the resolved context | -| Three anonymous `(public)` Server Component pages over the typed SDK, planned P02d-6 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 | +| Anonymous `(public)` catalog/course Server Component pages delivered P02d-6 Step 2; lesson page planned 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 renderer planned P02d-6 | The complete two-tier block registry with safe-render placeholders | -| Four-color public theme projection delivered P02d-4; safe document injection planned P02d-6 | The branding configuration surface, additional tokens and organization override | +| 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 | -**Accepted P02d-6 inheritance — 2026-10-09; implementation pending.** +**P02d-6 inheritance — Accepted 2026-10-09; Step 1 foundation delivered.** [ADR-0027](../decisions/0027-frontend-i18n.md) selects exact `next-intl` 4.14.9, app-local `src/i18n/messages/<locale>/<namespace>.json` and complete `en`/`tr` `public` messages. This phase extends its namespaces for full Studio/portal and diff --git a/docs/standards/03-frontend-coding.md b/docs/standards/03-frontend-coding.md index d077591e..f8954c5f 100644 --- a/docs/standards/03-frontend-coding.md +++ b/docs/standards/03-frontend-coding.md @@ -129,8 +129,9 @@ export function CourseCard({ course, onEnroll }: CourseCardProps) { P02d-2 accepts and implements G16's theme value grammar; P02d-4 delivers its public projection. [Tenant Branding](07-frontend-architecture.md#tenant-branding) owns that -contract. P02d-6's Accepted G42 selects safe server-rendered token injection; -implementation and proof remain pending. +contract. P02d-6 Step 1 implements G42's atomic server-rendered color injection; +the [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) +owns verification and the remaining product/manual accessibility proof boundary. ## Server Actions @@ -177,13 +178,13 @@ Accepted G35 assigns the browser logger, Sentry and web-vitals to Phase 11; P02d-5 provides the bounded server caller and trace propagation, with no browser observability hook. See [Frontend Observability](10-observability.md#frontend-observability). -P02d-6's Accepted G40 selects public error/loading placement and localized states; -implementation remains pending. [Standards 07](07-frontend-architecture.md#routing) +P02d-6 Step 1 implements public error/loading placement and localized state views. +[Standards 07](07-frontend-architecture.md#routing) owns the approved status-route behavior. ## UI Messages -**Accepted P02d-6 G39 — 2026-10-09; implementation pending.** +**P02d-6 G39 foundation delivered — Step 1, 2026-10-10.** [ADR-0027](../decisions/0027-frontend-i18n.md) selects server-first `next-intl` 4.14.9 and `apps/web/src/i18n/messages/{en,tr}/public.json`. General UI identifiers use lowercase dotted feature namespaces with snake_case segments, for example @@ -244,7 +245,7 @@ features/<feature>/ types.ts ``` -**Accepted P02d-6 placement — 2026-10-09; implementation pending.** G39 uses +**P02d-6 placement — Accepted 2026-10-09.** Step 1 implements G39 in `apps/web/src/i18n/request.ts` and the app-local message home above; no `packages/i18n` is created. G41 uses synchronous public views under `apps/web/src/components/public/` for ordered plain-string `default-card` fields diff --git a/docs/standards/07-frontend-architecture.md b/docs/standards/07-frontend-architecture.md index 5fca1082..95a18c64 100644 --- a/docs/standards/07-frontend-architecture.md +++ b/docs/standards/07-frontend-architecture.md @@ -211,8 +211,8 @@ owns the source, pin, diff policy, bootstrap exception and required-check rollou - Each route group has its own `layout.tsx`, `loading.tsx`, `error.tsx`. **Accepted P02d-6 G40 — 2026-10-09.** Step 1 implements shared resource admission, -controlled state views and the fixed status page; actual content pages remain -Steps 2 and 3. Keep the three +controlled state views and the fixed status page. Step 2 implements catalog/course +pages and their pagination/metadata; lesson presentation remains Step 3. Keep the three `/{locale}/courses` list/course/lesson routes and minimal tenant chrome. Use plain same-host relative anchors, including opaque catalog and outline pagination; disable automatic prefetch. A new document request re-reads API state. @@ -240,9 +240,9 @@ routes render dynamically with no-store API transport. No ISR, positive cache is permitted. Request-local reuse is isolated to one incoming request. Freshness is the next new server/document request, not client Router Cache history. Disable local Server Component HMR caching. P02d-6 Step 1 implements document -loaders, chrome, state views and atomic theme injection. Accepted G41 content -components remain Steps 2 and 3. The -[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) +loaders, chrome, state views and atomic theme injection. Step 2 adds catalog/course +views; lesson presentation remains Step 3. The +[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-2-catalog-and-course-pages) owns verification and review status. P5 delivers the dynamic layout, transport and source/runtime proofs. Test-owned production routes exercise the real API; P6 public pages are not delivered by them. diff --git a/docs/standards/08-localization.md b/docs/standards/08-localization.md index b4955859..a16d1ca9 100644 --- a/docs/standards/08-localization.md +++ b/docs/standards/08-localization.md @@ -46,8 +46,9 @@ fallback remains the delivered P02d-3 contract; language attributes are P02d-6. [ADR-0027](../decisions/0027-frontend-i18n.md) supplies next-intl and one app-local catalogue home, with UI-language fallback separate from content admission. The [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) -owns validation; actual content pages remain Steps 2 and 3. Phase 04 inherits this -foundation for CMS/Studio coverage. +owns foundation validation. Step 2 adds catalog/course views with actual resolved +label languages and exact-locale metadata; lesson presentation remains Step 3. +Phase 04 inherits this foundation for CMS/Studio coverage. ## URL Strategy diff --git a/docs/standards/README.md b/docs/standards/README.md index b5135e13..eadd9602 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -94,7 +94,7 @@ included. | 00 | [Principles](00-principles.md) | **Active** | Governs every PR and every ADR; principles 1, 16 and 17 are already deciding live scope questions. | | 01 | [Architecture Standards](01-architecture-standards.md) | **Active** | Module layout shipped; `ModuleDomain_DoesNotDependOn_*` and the planted-violation meta-test are green. | | 02 | [Backend Coding](02-backend-coding.md) | **Active** | MediatR pipeline, `Result<T>`, `IClock`, the `LS0001` analyzer and the pipeline-order test all ship. Packet 6 brought the first `DbContext`, the first aggregates and the ambient unit of work, so its EF Core and domain-modelling clauses became live. Tenancy, Customization, Audit and Education now implement domain and persistence code. | -| 03 | [Frontend Coding](03-frontend-coding.md) | **Active** | ESLint and TypeScript strict mode are configured, the Vitest harness runs, and Packet 10 added the two rules this document's § Forbidden names — the direct-`fetch` ban and `dangerouslySetInnerHTML` outside the sanitised-HTML primitive — with a test that lints fixtures through the app's own configuration, because a configured rule and a missing one look identical from a green `pnpm lint`. P02d-5 delivers trusted entry/caller controls; P02d-6 Step 1 implements complete UI catalogues, ICU/callsite controls and synchronous chrome/state views; actual content views remain Steps 2 and 3. | +| 03 | [Frontend Coding](03-frontend-coding.md) | **Active** | ESLint and TypeScript strict mode are configured, the Vitest harness runs, and Packet 10 added the two rules this document's § Forbidden names — the direct-`fetch` ban and `dangerouslySetInnerHTML` outside the sanitised-HTML primitive — with a test that lints fixtures through the app's own configuration, because a configured rule and a missing one look identical from a green `pnpm lint`. P02d-5 delivers trusted entry/caller controls; P02d-6 Step 1 implements complete UI catalogues, ICU/callsite controls and synchronous chrome/state views; Step 2 adds catalog/course views and metadata; lesson presentation remains Step 3. | | 04 | [API Design](04-api-design.md) | **Active** | Packet 4 shipped the versioned route convention and its startup guards, one Problem Details shape on every error including the framework-minted ones, cursor pagination, the sort grammar, idempotency keys, ETag concurrency, correlation ids, the request-body limit and the tenancy edge — each with tests in the required `backend` check. P02d-4 adds four institution public GET/HEAD pairs, served/snapshot contract controls and compatibility CI; application writers remain unrouted. | | 05 | [Database](05-database.md) | **Active** | Packet 6 applied it — two migration chains, ten tables — and Packets 8 and 9 took it to four chains and seventeen data tables. P02d-1 Step 2 adds Education: five chains and twenty-one data tables (twenty-six including five migration-history tables), with parent-mirror and Pattern A proofs. The four-role model and the canonical RLS template this document owns — `ENABLE` **and** `FORCE`, one `AND`-ed policy per table, an explicit `WITH CHECK` — are asserted against a real PostgreSQL as `learnstack_app`. Its § Concurrency, § Table classes, § Indexes and § GRANT matrix each have a test that fails without them. Partitioning and the retention job are still ahead. | | 06 | [Testing](06-testing.md) | **Active** | Unit, architecture and contract suites, and the Docker-free integration tests, run in the required `backend` job — Packet 4 removed the filter that used to exclude the integration assembly, which by then held the only tests that could catch an unversioned route. The Docker-bound `backend-integration` job activated in Packet 6 with the four-role provisioning suite; the split is by `[Trait("Requires","Docker")]` and the two jobs' filters are exact complements. The `backend-integration` job is required as of P02d-1, 2026-09-14 ([CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main)). | @@ -107,7 +107,7 @@ included. | 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) | **Adopted** | P6 product pages are not delivered. Accepted G43 requires applicable jsx-a11y error rules with actual-config planted controls, actual-page semantic/language proofs and manual keyboard/focus/reflow/contrast/screen-reader evidence. Step 1 supplies chrome/state semantics only; actual content-page enforcement and manual evidence remain pending. Promotion occurs with enforcement. Full Playwright/axe remains [Phase 06](../roadmap/phase-06-renderer-admin-studio.md); Accepted G44 assigns Lighthouse to P02d-7 after P6. | +| 16 | [Accessibility](16-accessibility.md) | **Adopted** | P6 Step 2 delivers catalog/course pages; lesson presentation remains Step 3. Accepted G43 requires applicable jsx-a11y error rules with actual-config planted controls, actual-page semantic/language proofs and manual keyboard/focus/reflow/contrast/screen-reader evidence. Step 1 supplies chrome/state semantics and Step 2 adds catalog/course semantics; full applicable lint enforcement and manual evidence remain pending. Promotion occurs with enforcement. Full Playwright/axe remains [Phase 06](../roadmap/phase-06-renderer-admin-studio.md); Accepted G44 assigns Lighthouse to P02d-7 after P6. | | 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). | diff --git a/frontend/apps/web/scripts/verify-public-rendering.mjs b/frontend/apps/web/scripts/verify-public-rendering.mjs index 1307e1c4..ece3cec3 100644 --- a/frontend/apps/web/scripts/verify-public-rendering.mjs +++ b/frontend/apps/web/scripts/verify-public-rendering.mjs @@ -323,37 +323,6 @@ export default function PublicLayout({ children }: { children: ReactNode }) { } `; -// Step 1 proves the resource-loader foundation with a deliberately test-owned -// consumer. The actual root, public layout and status page stay byte-for-byte -// copied from production. Step 2 replaces this probe with unchanged product pages. -function installFoundationConsumer(coursesDirectory) { - const fixturePage = join(coursesDirectory, '[slug]/page.tsx'); - assert.equal( - existsSync(coursesDirectory), - false, - 'Foundation probe cannot shadow product routes', - ); - mkdirSync(dirname(fixturePage), { recursive: true }); - writeFileSync( - fixturePage, - `import { getTranslations } from 'next-intl/server'; -import { requirePublicResource } from '@/server/public-resource'; - -export async function generateMetadata() { - await getTranslations('public'); - const resource = await requirePublicResource(); - return { title: resource.kind === 'course' ? resource.data.course.title : 'Foundation fixture' }; -} -export default async function FoundationFixturePage() { - const resource = await requirePublicResource(); - const t = await getTranslations('public'); - if (resource.kind !== 'course') throw new Error('Foundation fixture expected a course'); - return <section data-foundation-fixture="shared-resource"><h1>{resource.data.course.title}</h1><p>{t('catalog.title')}</p></section>; -} -`, - ); -} - function document(response) { const dom = new JSDOM(response.body); // Assertions below inspect the rendered document, never strings embedded in @@ -413,24 +382,222 @@ function statusDocument(response, tenant, locale, uiLocale) { return doc; } +function productDocument(response, tenant, locale, heading, path, direction = 'ltr') { + safeResponse(response, 200); + const doc = document(response); + assert.equal(doc.documentElement.lang, locale, 'Admitted document language'); + assert.equal(doc.documentElement.dir, direction, 'Admitted document direction'); + assert.equal(doc.querySelectorAll('main').length, 1, 'One product main landmark'); + assert.equal(doc.querySelectorAll('h1').length, 1, 'One visible product heading'); + assert.equal(doc.querySelector('h1').textContent, heading, 'Visible product heading'); + assert.ok(doc.title.includes(heading), 'Product metadata title'); + assert.equal( + doc.querySelector('link[rel="canonical"]')?.getAttribute('href'), + 'https://' + tenant.host + ':3000' + path, + 'Verified-host canonical', + ); + assert.equal( + doc.querySelector('meta[property="og:url"]')?.getAttribute('content'), + 'https://' + tenant.host + ':3000' + path, + 'Verified-host Open Graph URL', + ); + assert.ok(doc.body.textContent.includes(tenant.name), 'Live tenant chrome'); + const other = configuration.tenants.find((candidate) => candidate.host !== tenant.host); + assert.equal(doc.body.textContent.includes(other.name), false, 'No opposite tenant chrome'); + assert.equal( + doc.body.textContent.includes(other.courseTitle), + false, + 'No opposite tenant course', + ); + return doc; +} + +function paginationLink(doc, label, base, limitName, cursorName) { + const link = [...doc.querySelectorAll('nav.public-pagination a')].find( + (candidate) => candidate.textContent === label, + ); + assert.ok(link, 'Visible pagination anchor'); + const target = new URL(link.getAttribute('href'), 'https://fixture.invalid'); + assert.equal(target.pathname, base, 'Same-locale pagination path'); + assert.deepEqual( + [...target.searchParams.keys()].sort(), + [cursorName, limitName].sort(), + 'Only owned pagination query names', + ); + assert.equal(target.searchParams.get(limitName), '20', 'Default page size preserved'); + assert.match( + target.searchParams.get(cursorName), + /^[A-Za-z0-9_-]+$/, + 'Opaque continuation carried unchanged', + ); + return link.getAttribute('href'); +} + +function seededCatalog(doc, locale, expected, excluded) { + assert.equal( + doc.querySelectorAll('ul.public-course-list > li').length, + expected.length, + 'Only host-visible published courses are listed', + ); + for (const { title, slug } of expected) { + const link = doc.querySelector( + 'ul.public-course-list a[href="/' + locale + '/courses/' + slug + '"]', + ); + assert.equal(link?.textContent, title, 'Visible course uses its exact-locale title and slug'); + } + for (const title of excluded) + assert.equal( + doc.body.textContent.includes(title), + false, + 'Draft or sibling-organization course is absent', + ); + assert.equal( + doc.querySelector('nav.public-pagination'), + null, + 'Seed inventory fits the default page', + ); +} + async function verifyFoundation(native, nextBin) { const [first, second] = configuration.tenants; const course = '/' + configuration.locale + '/courses/' + configuration.courseSlug; + const catalog = '/' + configuration.locale + '/courses'; const request = (tenant, path, method = 'GET') => call(true, 3000, path, { Host: tenant.host + ':3000' }, method); for (const checkpointName of ['foundation-normal', 'foundation-repeat']) { stage = checkpointName; const response = await request(first, course); - safeResponse(response, 200); - const doc = document(response); - assert.equal(doc.documentElement.lang, configuration.locale); - assert.equal(doc.querySelector('h1').textContent, first.courseTitle); - assert.equal(doc.querySelectorAll('[data-foundation-fixture="shared-resource"]').length, 1); - assert.ok(doc.title.includes(first.courseTitle), 'Metadata consumes the same resource'); + const doc = productDocument(response, first, configuration.locale, first.courseTitle, course); + assert.ok(doc.body.textContent.includes(first.courseSummary), 'Actual course summary'); + assert.ok( + doc.querySelector( + 'ol.public-lesson-list a[href="' + course + '/lessons/' + first.lessonSlug + '"]', + ), + 'Actual public outline link', + ); await checkpoint(checkpointName); } + stage = 'foundation catalog English'; + const english = productDocument(await request(first, catalog), first, 'en', 'Courses', catalog); + seededCatalog(english, 'en', first.catalogEn, first.hiddenEn); + await checkpoint('foundation-catalog-english'); + + stage = 'foundation catalog Yoga English'; + const yoga = productDocument(await request(second, catalog), second, 'en', 'Courses', catalog); + seededCatalog(yoga, 'en', second.catalogEn, second.hiddenEn); + await checkpoint('foundation-catalog-yoga'); + + stage = 'foundation catalog Yoga Turkish'; + const turkishPath = '/' + second.defaultLocale + '/courses'; + const turkish = productDocument( + await request(second, turkishPath), + second, + second.defaultLocale, + 'Kurslar', + turkishPath, + ); + seededCatalog(turkish, second.defaultLocale, second.catalogDefault, second.hiddenDefault); + await checkpoint('foundation-catalog-turkish'); + + stage = 'foundation Yoga English course'; + const yogaCourse = productDocument( + await request(second, course), + second, + 'en', + second.courseTitle, + course, + ); + assert.ok(yogaCourse.body.textContent.includes(second.courseSummary)); + assert.equal( + yogaCourse.querySelector( + 'ol.public-lesson-list a[href="' + course + '/lessons/' + second.lessonSlug + '"]', + )?.textContent, + second.lessonTitle, + 'Studio host sees its own English outline', + ); + await checkpoint('foundation-course-yoga'); + + stage = 'foundation Yoga Turkish course'; + const turkishCoursePath = turkishPath + '/' + second.defaultCourseSlug; + const turkishCourse = productDocument( + await request(second, turkishCoursePath), + second, + second.defaultLocale, + second.defaultCourseTitle, + turkishCoursePath, + ); + assert.ok(turkishCourse.body.textContent.includes(second.defaultCourseSummary)); + assert.equal( + turkishCourse.querySelector( + 'ol.public-lesson-list a[href="' + + turkishCoursePath + + '/lessons/' + + second.defaultLessonSlug + + '"]', + )?.textContent, + second.defaultLessonTitle, + 'Studio host sees its own Turkish outline', + ); + await checkpoint('foundation-course-turkish'); + + stage = 'foundation restricted marketing'; + const restrictedPath = catalog + '/' + first.restrictedSlug; + const restricted = productDocument( + await request(first, restrictedPath), + first, + 'en', + first.restrictedTitle, + restrictedPath, + ); + assert.ok(restricted.body.textContent.includes(first.restrictedSummary)); + assert.ok( + restricted.body.textContent.includes('The lessons in this course are not publicly available.'), + ); + assert.equal( + restricted.querySelector('ol.public-lesson-list'), + null, + 'Protected outline is absent', + ); + assert.equal( + restricted.body.textContent.includes('Giving reasons'), + false, + 'Protected lesson title is absent', + ); + await checkpoint('foundation-restricted'); + + stage = 'foundation empty exact-locale catalog'; + const emptyPath = '/ar/courses'; + const empty = productDocument( + await request(first, emptyPath), + first, + 'ar', + 'Courses', + emptyPath, + 'rtl', + ); + assert.equal( + empty.querySelector('ul.public-course-list'), + null, + 'Empty catalog differs from a list', + ); + assert.ok(empty.body.textContent.includes('No courses are available in this language yet.')); + assert.equal( + empty.querySelector('h1').closest('[lang]').getAttribute('lang'), + 'en', + 'English UI fallback is labelled separately from Arabic content locale', + ); + await checkpoint('foundation-empty'); + + stage = 'foundation cross-tenant detail refusal'; + const hidden = await request(first, '/en/courses/shared-practice'); + safeResponse(hidden, 307); + assert.equal(hidden.headers.location, '/en/status/not-found'); + statusDocument(await request(first, '/en/status/not-found'), first, 'en', 'en'); + assert.equal(hidden.body.includes(second.courseTitle), false); + await checkpoint('foundation-cross-tenant'); + stage = 'foundation-missing'; const missing = await request( first, @@ -527,6 +694,129 @@ async function verifyFoundation(native, nextBin) { assert.equal(native.exitCode, null, 'Foundation checks use one native process'); } +async function verifyPagination(native) { + const first = configuration.tenants[0]; + const catalog = '/en/courses'; + const course = catalog + '/' + configuration.courseSlug; + const request = (path) => call(true, 3000, path, { Host: first.host + ':3000' }); + + stage = 'pagination catalog default first page'; + const firstCatalog = productDocument(await request(catalog), first, 'en', 'Courses', catalog); + assert.equal( + firstCatalog.querySelectorAll('ul.public-course-list > li').length, + 20, + 'The default twenty-item catalog page is full', + ); + assert.ok(firstCatalog.querySelector('a[href="' + course + '"]')); + assert.equal( + firstCatalog.querySelectorAll('nav.public-pagination a').length, + 1, + 'First page has next without invented previous cursor', + ); + const nextCatalog = paginationLink(firstCatalog, 'Next courses', catalog, 'limit', 'cursor'); + await checkpoint('pagination-catalog-first'); + + stage = 'pagination catalog continuation'; + const continuedCatalog = productDocument( + await request(nextCatalog), + first, + 'en', + 'Courses', + catalog, + ); + assert.equal(continuedCatalog.querySelectorAll('ul.public-course-list > li').length, 3); + assert.equal( + continuedCatalog.querySelector('a[href="' + course + '"]'), + null, + 'Seek continuation does not repeat the first course', + ); + assert.ok(continuedCatalog.querySelector('meta[name="robots"]')?.content.includes('noindex')); + assert.equal( + continuedCatalog.querySelectorAll('nav.public-pagination a').length, + 1, + 'Last page has only restart', + ); + const catalogRestart = continuedCatalog + .querySelector('nav.public-pagination a') + .getAttribute('href'); + assert.equal(catalogRestart, catalog + '?limit=20'); + await checkpoint('pagination-catalog-next'); + + stage = 'pagination catalog restart'; + const catalogAgain = productDocument( + await request(catalogRestart), + first, + 'en', + 'Courses', + catalog, + ); + assert.equal(catalogAgain.querySelectorAll('ul.public-course-list > li').length, 20); + assert.ok(catalogAgain.querySelector('a[href="' + course + '"]')); + await checkpoint('pagination-catalog-restart'); + + stage = 'pagination outline default first page'; + const firstOutline = productDocument( + await request(course), + first, + 'en', + first.courseTitle, + course, + ); + assert.equal( + firstOutline.querySelectorAll('ol.public-lesson-list > li').length, + 20, + 'The default twenty-item outline page is full', + ); + assert.ok( + firstOutline.querySelector('a[href="' + course + '/lessons/' + first.lessonSlug + '"]'), + ); + const nextOutline = paginationLink( + firstOutline, + 'Next lessons', + course, + 'lessonLimit', + 'lessonCursor', + ); + await checkpoint('pagination-outline-first'); + + stage = 'pagination outline continuation'; + const continuedOutline = productDocument( + await request(nextOutline), + first, + 'en', + first.courseTitle, + course, + ); + assert.equal(continuedOutline.querySelectorAll('ol.public-lesson-list > li').length, 2); + assert.equal( + continuedOutline.querySelector('a[href="' + course + '/lessons/' + first.lessonSlug + '"]'), + null, + 'Seek continuation does not repeat the first lesson', + ); + assert.ok(continuedOutline.querySelector('meta[name="robots"]')?.content.includes('noindex')); + assert.equal(continuedOutline.querySelectorAll('nav.public-pagination a').length, 1); + const outlineRestart = continuedOutline + .querySelector('nav.public-pagination a') + .getAttribute('href'); + assert.equal(outlineRestart, course + '?lessonLimit=20'); + await checkpoint('pagination-outline-next'); + + stage = 'pagination outline restart'; + const outlineAgain = productDocument( + await request(outlineRestart), + first, + 'en', + first.courseTitle, + course, + ); + assert.equal(outlineAgain.querySelectorAll('ol.public-lesson-list > li').length, 20); + assert.ok( + outlineAgain.querySelector('a[href="' + course + '/lessons/' + first.lessonSlug + '"]'), + ); + await checkpoint('pagination-outline-restart'); + assert.equal(native.exitCode, null, 'Pagination checks use one native process'); +} + // The real Next adapter removes Flight inputs before user middleware, then // restores them after its request-header override. Observe that final request // through a disposable admitted route, without exposing any header values. @@ -551,7 +841,9 @@ try { assert.match(configuration.apiOrigin, /^http:\/\/127\.0\.0\.1:\d+$/); assert.match(configuration.secret, /^[A-Za-z0-9_-]{43}$/); assert.equal(configuration.tenants.length, 2); - const foundation = configuration.mode === 'foundation'; + const foundation = + configuration.mode === 'foundation' || configuration.mode === 'foundation-pagination'; + const pagination = configuration.mode === 'foundation-pagination'; if (foundation) { catalogueCanaries = ['en', 'tr'].map( (locale) => @@ -592,7 +884,8 @@ try { const coursesDirectory = join(app, 'src/app/(public)/[locale]/courses'); let fixtureClient; if (foundation) { - installFoundationConsumer(coursesDirectory); + assert.ok(existsSync(join(coursesDirectory, 'page.tsx')), 'Copied product catalog route'); + assert.ok(existsSync(join(coursesDirectory, '[slug]/page.tsx')), 'Copied product course route'); } else { rmSync(join(app, 'src/app/(public)/[locale]'), { recursive: true, force: true }); // The original transport probe predates product Suspense/error boundaries. @@ -699,7 +992,9 @@ try { const catalog = '/' + configuration.locale + '/courses'; const course = catalog + '/' + configuration.courseSlug; - if (foundation) { + if (pagination) { + await verifyPagination(native); + } else if (foundation) { await verifyFoundation(native, nextBin); } else { stage = 'cold and interleaved host representations'; diff --git a/frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/page.tsx b/frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/page.tsx new file mode 100644 index 00000000..b417c44e --- /dev/null +++ b/frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/page.tsx @@ -0,0 +1,29 @@ +import { notFound } from 'next/navigation'; + +import { PublicCourse } from '@/components/public/course'; +import { PublicState } from '@/components/public/state'; +import { publicMetadata } from '@/server/public-metadata'; +import { requirePublicResource } from '@/server/public-resource'; +import { getPublicUi } from '@/server/public-ui'; + +export async function generateMetadata() { + const resource = await requirePublicResource(); + return publicMetadata(resource, await getPublicUi()); +} + +export default async function CoursePage() { + const resource = await requirePublicResource(); + const ui = await getPublicUi(); + if (resource.kind === 'failure') + return ( + <PublicState + state={resource.state} + recoveryPath={resource.request.route.path} + locale={ui.locale} + direction={ui.direction} + t={ui.t} + /> + ); + if (resource.kind !== 'course') notFound(); + return <PublicCourse resource={resource} ui={ui} />; +} diff --git a/frontend/apps/web/src/app/(public)/[locale]/courses/page.tsx b/frontend/apps/web/src/app/(public)/[locale]/courses/page.tsx new file mode 100644 index 00000000..a6da70b8 --- /dev/null +++ b/frontend/apps/web/src/app/(public)/[locale]/courses/page.tsx @@ -0,0 +1,29 @@ +import { notFound } from 'next/navigation'; + +import { PublicCatalog } from '@/components/public/catalog'; +import { PublicState } from '@/components/public/state'; +import { publicMetadata } from '@/server/public-metadata'; +import { requirePublicResource } from '@/server/public-resource'; +import { getPublicUi } from '@/server/public-ui'; + +export async function generateMetadata() { + const resource = await requirePublicResource(); + return publicMetadata(resource, await getPublicUi()); +} + +export default async function CoursesPage() { + const resource = await requirePublicResource(); + const ui = await getPublicUi(); + if (resource.kind === 'failure') + return ( + <PublicState + state={resource.state} + recoveryPath={resource.request.route.path} + locale={ui.locale} + direction={ui.direction} + t={ui.t} + /> + ); + if (resource.kind !== 'catalog') notFound(); + return <PublicCatalog resource={resource} ui={ui} />; +} diff --git a/frontend/apps/web/src/app/(public)/page.test.tsx b/frontend/apps/web/src/app/(public)/page.test.tsx deleted file mode 100644 index 33aefb35..00000000 --- a/frontend/apps/web/src/app/(public)/page.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; - -import PublicHomePage from './page'; - -/** - * The point of this file is that it exists. `apps/web`'s `test` script carried - * `--passWithNoTests` against zero test files, so the required `frontend` CI - * check was green without asserting anything. The tolerance is removed together - * with the first test that satisfies it, so the check never sits red across a - * packet boundary — Phase 02a Packet 3b. - * - * The substantive frontend suite arrives with Phase 02d. This asserts only what - * the placeholder page actually promises today. - */ -describe('PublicHomePage', () => { - it('renders the platform name as the page heading', () => { - render(<PublicHomePage />); - - expect(screen.getByRole('heading', { level: 1, name: 'LearnStack' })).toBeInTheDocument(); - }); - - it('renders the positioning line', () => { - render(<PublicHomePage />); - - expect(screen.getByText(/multi-tenant core platform/i)).toBeInTheDocument(); - }); -}); diff --git a/frontend/apps/web/src/app/(public)/page.tsx b/frontend/apps/web/src/app/(public)/page.tsx deleted file mode 100644 index 439fd99e..00000000 --- a/frontend/apps/web/src/app/(public)/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export default function PublicHomePage() { - return ( - <section className="mx-auto max-w-3xl px-6 py-24"> - <h1 className="text-4xl font-semibold">LearnStack</h1> - <p className="mt-4 text-ls-muted"> - Multi-tenant core platform for building education products. - </p> - </section> - ); -} diff --git a/frontend/apps/web/src/app/globals.css b/frontend/apps/web/src/app/globals.css index 724363c9..d28b6bbb 100644 --- a/frontend/apps/web/src/app/globals.css +++ b/frontend/apps/web/src/app/globals.css @@ -92,3 +92,41 @@ transform: translateY(0); } } + +@layer components { + .public-catalog, + .public-course, + .public-outline { + display: grid; + gap: 1.25rem; + } + .public-course-list { + display: grid; + gap: 1.5rem; + list-style: disc; + padding-inline-start: 1.5rem; + } + .public-course-card { + padding-block: 1rem; + border-block-end: 1px solid var(--ls-muted); + } + .public-course-card > * + * { + margin-block-start: 0.75rem; + } + .public-summary { + max-width: 65ch; + white-space: pre-wrap; + } + .public-lesson-list { + display: grid; + gap: 1rem; + list-style: decimal; + padding-inline-start: 1.75rem; + } + .public-pagination { + display: flex; + flex-wrap: wrap; + gap: 1rem 2rem; + padding-block: 0.75rem; + } +} diff --git a/frontend/apps/web/src/components/public/catalog-course.test.tsx b/frontend/apps/web/src/components/public/catalog-course.test.tsx new file mode 100644 index 00000000..e0cd314c --- /dev/null +++ b/frontend/apps/web/src/components/public/catalog-course.test.tsx @@ -0,0 +1,488 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { createPublicTranslator } from '@/i18n/catalogues'; +import type { ConfiguredPublicClient } from '@/server/configured-public-client'; +import type { PublicRequest } from '@/server/public-request'; +import type { PublicResource } from '@/server/public-resource'; +import { publicPagination, publicRoute } from '@/server/public-route'; + +import { PublicCatalog } from './catalog'; +import { PublicCourse } from './course'; + +type CatalogResource = Extract<PublicResource, { kind: 'catalog' }>; +type CourseResource = Extract<PublicResource, { kind: 'course' }>; + +const firstPage = { + hasNext: false, + hasPrevious: false, + nextCursor: null, + previousCursor: null, +}; +const courseSummary: CourseResource['data']['course'] = { + slug: 'foundation', + title: 'Authored course title', + summary: 'Authored course summary', + level: null, + contentAccess: 'public', +}; +const client = { + getSite: vi.fn<ConfiguredPublicClient['getSite']>(), + getCourses: vi.fn<ConfiguredPublicClient['getCourses']>(), + getCourse: vi.fn<ConfiguredPublicClient['getCourse']>(), + getLesson: vi.fn<ConfiguredPublicClient['getLesson']>(), +}; + +function request(locale: string, target: string): PublicRequest { + return { + context: { host: 'institution.example:3000', peer: '203.0.113.7', method: 'GET', target }, + client, + site: { + displayName: 'Institution', + defaultLocale: locale, + enabledLocales: [locale], + showPlatformAttribution: false, + theme: null, + }, + locale, + route: publicRoute(target), + }; +} + +function catalog( + overrides: Partial<CatalogResource['data']> = {}, + target?: string, +): CatalogResource { + const data = { locale: 'tr-TR', items: [courseSummary], pageInfo: firstPage, ...overrides }; + const signedTarget = target ?? `/${data.locale}/courses`; + const pagination = publicPagination(signedTarget, false); + if (!pagination) throw new Error('Invalid test pagination'); + return { kind: 'catalog', data, request: request(data.locale, signedTarget), pagination }; +} + +function course(overrides: Partial<CourseResource['data']> = {}, target?: string): CourseResource { + const data: CourseResource['data'] = { + locale: 'tr-TR', + course: courseSummary, + alternates: [], + lessons: { + items: [ + { slug: 'first', title: 'First authored lesson', sort: 7 }, + { slug: 'second', title: 'Second authored lesson', sort: 12 }, + ], + pageInfo: firstPage, + }, + ...overrides, + }; + const signedTarget = target ?? `/${data.locale}/courses/foundation`; + const pagination = publicPagination(signedTarget, true); + if (!pagination) throw new Error('Invalid test pagination'); + return { kind: 'course', data, request: request(data.locale, signedTarget), pagination }; +} + +describe('public catalog and course semantics', () => { + it('renders a semantic catalog with one h1, ordered h2 cards and ordinary course anchors', () => { + const resource = catalog({ + items: [courseSummary, { ...courseSummary, slug: 'another', title: 'Another course' }], + }); + const { container } = render( + <PublicCatalog resource={resource} ui={createPublicTranslator('tr-TR')} />, + ); + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Kurslar'); + expect(screen.getByText('2 kurs')).toHaveAttribute('lang', 'tr'); + const list = screen.getByRole('list'); + expect(list.tagName).toBe('UL'); + expect( + within(list) + .getAllByRole('heading', { level: 2 }) + .map((node) => node.textContent), + ).toEqual(['Authored course title', 'Another course']); + expect(within(list).getAllByRole('listitem')).toHaveLength(2); + expect(screen.getByRole('link', { name: 'Authored course title' })).toHaveAttribute( + 'href', + '/tr-TR/courses/foundation', + ); + expect(screen.getByRole('link', { name: 'Another course' })).toHaveAttribute( + 'href', + '/tr-TR/courses/another', + ); + expect(container.querySelector('[data-prefetch], [data-nextjs-router]')).toBeNull(); + }); + + it('renders an ordered outline in API order without using sort values as URLs or numbering', () => { + const resource = course({ + lessons: { + items: [ + { slug: 'first', title: 'First authored lesson', sort: 90 }, + { slug: 'second', title: 'Second authored lesson', sort: 3 }, + ], + pageInfo: firstPage, + }, + }); + render(<PublicCourse resource={resource} ui={createPublicTranslator('tr-TR')} />); + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(courseSummary.title); + expect(screen.getAllByRole('heading', { level: 2 })).toHaveLength(1); + expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('Kurs içeriği'); + expect(screen.getByText('2 ders')).toHaveAttribute('lang', 'tr'); + const list = screen.getByRole('list'); + expect(list.tagName).toBe('OL'); + expect( + within(list) + .getAllByRole('listitem') + .map((node) => node.textContent), + ).toEqual(['First authored lesson', 'Second authored lesson']); + expect(screen.getAllByRole('link').map((node) => node.getAttribute('href'))).toEqual([ + '/tr-TR/courses/foundation/lessons/first', + '/tr-TR/courses/foundation/lessons/second', + ]); + }); + + it.each(['catalog', 'course'] as const)( + 'keeps %s authored strings escaped and inert, with no summary linkification', + (view) => { + const title = '<script>alert("title")</script>'; + const summary = + '<img src=x onerror=alert(1)> javascript:alert(2) data:text/html,secret https://example.invalid'; + const authored = { ...courseSummary, title, summary }; + const ui = createPublicTranslator('tr-TR'); + const { container } = render( + view === 'catalog' ? ( + <PublicCatalog resource={catalog({ items: [authored] })} ui={ui} /> + ) : ( + <PublicCourse resource={course({ course: authored, lessons: null })} ui={ui} /> + ), + ); + expect(screen.getByText(title)).toBeInTheDocument(); + const summaryNode = screen.getByText(summary); + expect(summaryNode.tagName).toBe('P'); + expect(summaryNode.querySelector('a')).toBeNull(); + expect(container.querySelector('script, img, iframe, [onerror], [onclick]')).toBeNull(); + expect( + container.querySelector('a[href^="javascript:"], a[href^="data:"], a[href^="https:"]'), + ).toBeNull(); + }, + ); + + it.each([ + ['catalog', null], + ['catalog', ''], + ['catalog', ' \n\t '], + ['course', null], + ['course', ''], + ['course', ' \n\t '], + ] as const)('omits empty optional summary and null level in the %s view: %j', (view, summary) => { + const authored = { ...courseSummary, summary, level: null }; + const ui = createPublicTranslator('en'); + const { container } = render( + view === 'catalog' ? ( + <PublicCatalog resource={catalog({ items: [authored] })} ui={ui} /> + ) : ( + <PublicCourse resource={course({ course: authored })} ui={ui} /> + ), + ); + expect(container.querySelector('.public-summary')).toBeNull(); + expect(screen.queryByText('Level unavailable')).toBeNull(); + expect(container.querySelectorAll('p')).toHaveLength(1); + }); + + it('distinguishes the empty catalog from an empty public outline and restricted outline', () => { + const ui = createPublicTranslator('en'); + const { rerender } = render(<PublicCatalog resource={catalog({ items: [] })} ui={ui} />); + expect(screen.getByText('No courses are available in this language yet.')).toBeInTheDocument(); + expect(screen.queryByRole('list')).toBeNull(); + rerender( + <PublicCourse resource={course({ lessons: { items: [], pageInfo: firstPage } })} ui={ui} />, + ); + expect( + screen.getByText('No public lessons are available in this course yet.'), + ).toBeInTheDocument(); + expect(screen.queryByText('No courses are available in this language yet.')).toBeNull(); + expect(screen.queryByText('The lessons in this course are not publicly available.')).toBeNull(); + expect(screen.queryByRole('list')).toBeNull(); + expect(screen.queryByText('0 lessons')).toBeNull(); + }); + + it('renders restricted marketing and a translated notice without inspecting a supplied outline', () => { + const resource = course( + { course: { ...courseSummary, contentAccess: 'enrollment_required' } }, + '/tr-TR/courses/foundation?lessonCursor=opaque&lessonLimit=20', + ); + const readLessons = vi.fn(() => { + throw new Error('Restricted outline was inspected'); + }); + Object.defineProperty(resource.data, 'lessons', { get: readLessons }); + const { container } = render( + <PublicCourse resource={resource} ui={createPublicTranslator('tr-TR')} />, + ); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(courseSummary.title); + expect(screen.getByText('Authored course summary')).toBeInTheDocument(); + expect(screen.getByText('Bu kursun dersleri herkese açık değildir.')).toHaveAttribute( + 'lang', + 'tr', + ); + expect(readLessons).not.toHaveBeenCalled(); + expect(screen.queryByRole('heading', { level: 2 })).toBeNull(); + expect(screen.queryByRole('list')).toBeNull(); + expect(screen.queryByRole('link')).toBeNull(); + expect(screen.queryByRole('button')).toBeNull(); + expect(container.textContent).not.toMatch( + /First authored|Second authored|2 ders|body|purchase|login|enroll/i, + ); + }); +}); + +describe('public UI, authored content and resolved label languages', () => { + it.each(['catalog', 'course'] as const)( + 'uses the admitted locale for %s navigation even when response data names another locale', + (view) => { + const ui = createPublicTranslator('tr-TR'); + const pageInfo = { ...firstPage, hasNext: true, nextCursor: 'opaque_next' }; + const responseResource = + view === 'catalog' + ? catalog({ locale: 'ar', pageInfo }) + : course({ + locale: 'ar', + lessons: { items: [{ slug: 'first', title: 'Lesson', sort: 0 }], pageInfo }, + }); + const path = view === 'catalog' ? '/tr-TR/courses' : '/tr-TR/courses/foundation'; + const resource = { ...responseResource, request: request('tr-TR', path) }; + const { container } = render( + resource.kind === 'catalog' ? ( + <PublicCatalog resource={resource} ui={ui} /> + ) : ( + <PublicCourse resource={resource} ui={ui} /> + ), + ); + expect(container.firstElementChild).toHaveAttribute('lang', 'ar'); + for (const link of screen.getAllByRole('link')) { + expect(link.getAttribute('href')).toMatch(/^\/tr-TR\/courses/); + } + }, + ); + + it.each(['catalog', 'course'] as const)( + 'keeps RTL %s content separate from English UI fallback and the API label locale', + (view) => { + const authored = { + ...courseSummary, + title: 'عنوان الدورة', + summary: 'وصف الدورة', + level: { state: 'ready' as const, label: { locale: 'fa', value: 'سطح مقدماتی' } }, + }; + const ui = createPublicTranslator('ar'); + const { container } = render( + view === 'catalog' ? ( + <PublicCatalog resource={catalog({ locale: 'ar', items: [authored] })} ui={ui} /> + ) : ( + <PublicCourse resource={course({ locale: 'ar', course: authored })} ui={ui} /> + ), + ); + const root = container.firstElementChild; + expect(root).toHaveAttribute('lang', 'ar'); + expect(root).toHaveAttribute('dir', 'rtl'); + expect(screen.getByText(authored.summary).closest('[lang]')).toBe(root); + const uiHeading = screen.getByRole('heading', { level: view === 'catalog' ? 1 : 2 }); + expect(uiHeading).toHaveAttribute('lang', 'en'); + expect(uiHeading).toHaveAttribute('dir', 'ltr'); + expect(screen.getByText('سطح مقدماتی')).toHaveAttribute('lang', 'fa'); + expect(screen.getByText('سطح مقدماتی')).toHaveAttribute('dir', 'rtl'); + expect(screen.getAllByRole('link')[0]?.getAttribute('href')).toMatch(/^\/ar\/courses\//); + }, + ); + + it.each(['catalog', 'course'] as const)( + 'uses translated unavailable-level UI copy for %s without exposing a stale label', + (view) => { + const authored = { ...courseSummary, level: { state: 'unavailable' as const, label: null } }; + const ui = createPublicTranslator('tr-TR'); + render( + view === 'catalog' ? ( + <PublicCatalog resource={catalog({ items: [authored] })} ui={ui} /> + ) : ( + <PublicCourse resource={course({ course: authored })} ui={ui} /> + ), + ); + const label = screen.getByText('Seviye bilgisi mevcut değil'); + expect(label).toHaveAttribute('lang', 'tr'); + expect(label).toHaveAttribute('dir', 'ltr'); + }, + ); +}); + +describe('opaque public pagination and invalid navigation targets', () => { + it.each([ + ['catalog', undefined, '20'], + ['catalog', '900', '100'], + ['course', undefined, '20'], + ['course', '900', '100'], + ] as const)( + 'renders only owned %s pagination with requested limit %s normalized to %s', + (view, limit, expected) => { + const token = 'eyJvcGFxdWUiOiJ1bmRlY29kZWQifQ_-'; + // Inconsistent previous data cannot cause fabricated backward navigation. + const pageInfo = { + hasNext: true, + nextCursor: token, + hasPrevious: true, + previousCursor: 'do_not_render', + }; + const query = + view === 'catalog' + ? `cursor=previous_opaque&lessonCursor=unowned&lessonLimit=1${limit ? `&limit=${limit}` : ''}` + : `lessonCursor=previous_opaque&cursor=unowned&limit=1${limit ? `&lessonLimit=${limit}` : ''}`; + const ui = createPublicTranslator('en'); + render( + view === 'catalog' ? ( + <PublicCatalog resource={catalog({ pageInfo }, `/tr-TR/courses?${query}`)} ui={ui} /> + ) : ( + <PublicCourse + resource={course( + { lessons: { items: [], pageInfo } }, + `/tr-TR/courses/foundation?${query}`, + )} + ui={ui} + /> + ), + ); + const next = screen.getByRole('link', { + name: view === 'catalog' ? 'Next courses' : 'Next lessons', + }); + const restart = screen.getByRole('link', { + name: view === 'catalog' ? 'Back to the first courses' : 'Back to the first lessons', + }); + const base = view === 'catalog' ? '/tr-TR/courses' : '/tr-TR/courses/foundation'; + const limitName = view === 'catalog' ? 'limit' : 'lessonLimit'; + const cursorName = view === 'catalog' ? 'cursor' : 'lessonCursor'; + expect(next).toHaveAttribute( + 'href', + `${base}?${limitName}=${expected}&${cursorName}=${token}`, + ); + expect(restart).toHaveAttribute('href', `${base}?${limitName}=${expected}`); + expect(screen.queryByRole('link', { name: /previous/i })).toBeNull(); + expect(screen.getByRole('navigation')).toHaveAttribute('lang', 'en'); + expect(screen.getByRole('navigation')).toHaveAttribute('dir', 'ltr'); + for (const node of [next, restart]) { + expect(node.getAttribute('href')).not.toMatch(/do_not_render|unowned|previous_opaque/); + } + }, + ); + + it.each(['catalog', 'course'] as const)( + 'omits %s restart on the first page and next without hasNext', + (view) => { + const pageInfo = { ...firstPage, nextCursor: 'opaque_but_not_next' }; + const ui = createPublicTranslator('en'); + render( + view === 'catalog' ? ( + <PublicCatalog resource={catalog({ items: [], pageInfo })} ui={ui} /> + ) : ( + <PublicCourse resource={course({ lessons: { items: [], pageInfo } })} ui={ui} /> + ), + ); + expect(screen.queryByRole('navigation')).toBeNull(); + expect(screen.queryByRole('link')).toBeNull(); + }, + ); + + it.each(['catalog', 'course'] as const)( + 'keeps only the %s restart link on a final continuation page', + (view) => { + const ui = createPublicTranslator('en'); + render( + view === 'catalog' ? ( + <PublicCatalog + resource={catalog({ items: [] }, '/tr-TR/courses?cursor=opaque_final')} + ui={ui} + /> + ) : ( + <PublicCourse + resource={course( + { lessons: { items: [], pageInfo: firstPage } }, + '/tr-TR/courses/foundation?lessonCursor=opaque_final', + )} + ui={ui} + /> + ), + ); + const links = screen.getAllByRole('link'); + expect(links).toHaveLength(1); + expect(links[0]).toHaveTextContent( + view === 'catalog' ? 'Back to the first courses' : 'Back to the first lessons', + ); + expect(links[0]).toHaveAttribute( + 'href', + view === 'catalog' ? '/tr-TR/courses?limit=20' : '/tr-TR/courses/foundation?lessonLimit=20', + ); + }, + ); + + it.each([ + 'javascript:alert(1)', + 'data:text/html,secret', + 'https://example.invalid', + 'bad/slug', + '%66oundation', + '', + ])('keeps invalid course and lesson slug %j inert', (slug) => { + const ui = createPublicTranslator('en'); + const { rerender } = render( + <PublicCatalog resource={catalog({ items: [{ ...courseSummary, slug }] })} ui={ui} />, + ); + expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(courseSummary.title); + expect(screen.queryByRole('link')).toBeNull(); + rerender( + <PublicCourse + resource={course({ + lessons: { items: [{ slug, title: 'Inert lesson label', sort: 0 }], pageInfo: firstPage }, + })} + ui={ui} + />, + ); + expect(screen.getByText('Inert lesson label')).toBeInTheDocument(); + expect(screen.queryByRole('link')).toBeNull(); + }); + + it.each(['catalog', 'course'] as const)( + 'omits an invalid opaque %s next target without echoing it', + (view) => { + const pageInfo = { + ...firstPage, + hasNext: true, + nextCursor: 'https://example.invalid/private', + }; + const ui = createPublicTranslator('en'); + const { container } = render( + view === 'catalog' ? ( + <PublicCatalog resource={catalog({ items: [], pageInfo })} ui={ui} /> + ) : ( + <PublicCourse resource={course({ lessons: { items: [], pageInfo } })} ui={ui} /> + ), + ); + expect(screen.queryByRole('link')).toBeNull(); + expect(container.textContent).not.toContain('https://example.invalid/private'); + }, + ); + + it('keeps outline labels and pagination inert when the returned course slug is invalid', () => { + const title = '<script>alert(1)</script> javascript:alert(2)'; + const resource = course( + { + course: { ...courseSummary, slug: 'invalid/course' }, + lessons: { + items: [{ slug: 'first', title, sort: 0 }], + pageInfo: { ...firstPage, hasNext: true, nextCursor: 'opaque_next' }, + }, + }, + '/tr-TR/courses/foundation?lessonCursor=opaque_current', + ); + const { container } = render( + <PublicCourse resource={resource} ui={createPublicTranslator('en')} />, + ); + expect(screen.getByText(title).tagName).toBe('LI'); + expect(screen.queryByRole('link')).toBeNull(); + expect(screen.queryByRole('navigation')).toBeNull(); + expect(container.querySelector('script, [onclick]')).toBeNull(); + }); +}); diff --git a/frontend/apps/web/src/components/public/catalog.tsx b/frontend/apps/web/src/components/public/catalog.tsx new file mode 100644 index 00000000..9ae0c5d6 --- /dev/null +++ b/frontend/apps/web/src/components/public/catalog.tsx @@ -0,0 +1,82 @@ +import type { PublicTranslate } from '@/i18n/catalogues'; +import { textDirection } from '@/i18n/locale'; +import type { TextDirection } from '@/i18n/locale'; +import { catalogPath, coursePath, paginationPath } from '@/server/public-paths'; +import type { PublicResource } from '@/server/public-resource'; + +type CatalogResource = Extract<PublicResource, { kind: 'catalog' }>; +type PublicCatalogProps = { + readonly resource: CatalogResource; + readonly ui: { + readonly locale: string; + readonly direction: TextDirection; + readonly t: PublicTranslate; + }; +}; + +export function PublicCatalog({ resource, ui }: PublicCatalogProps) { + const { data, pagination } = resource; + const routeLocale = resource.request.locale ?? ''; + const basePath = catalogPath(routeLocale); + const nextPath = + basePath && data.pageInfo.hasNext && data.pageInfo.nextCursor !== null + ? paginationPath(basePath, pagination, data.pageInfo.nextCursor, false) + : null; + const restartPath = + basePath && pagination.isPaginated ? paginationPath(basePath, pagination, null, false) : null; + + return ( + <section className="public-catalog" lang={data.locale} dir={textDirection(data.locale)}> + <h1 lang={ui.locale} dir={ui.direction}> + {ui.t('catalog.title')} + </h1> + {data.items.length === 0 ? ( + <p className="public-muted" lang={ui.locale} dir={ui.direction}> + {ui.t('catalog.empty')} + </p> + ) : ( + <> + <p className="public-muted" lang={ui.locale} dir={ui.direction}> + {ui.t('catalog.course_count', { count: data.items.length })} + </p> + <ul className="public-course-list"> + {data.items.map((course) => { + const path = coursePath(routeLocale, course.slug); + return ( + <li key={course.slug} className="public-course-card"> + <h2>{path ? <a href={path}>{course.title}</a> : course.title}</h2> + {course.summary?.trim() && <p className="public-summary">{course.summary}</p>} + {course.level && + (course.level.state === 'ready' && course.level.label ? ( + <p + className="public-muted" + lang={course.level.label.locale} + dir={textDirection(course.level.label.locale)} + > + {course.level.label.value} + </p> + ) : ( + <p className="public-muted" lang={ui.locale} dir={ui.direction}> + {ui.t('catalog.level_unavailable')} + </p> + ))} + </li> + ); + })} + </ul> + </> + )} + {(restartPath || nextPath) && ( + <nav + className="public-pagination" + lang={ui.locale} + dir={ui.direction} + aria-label={ui.t('catalog.title')} + > + {restartPath && <a href={restartPath}>{ui.t('catalog.restart')}</a>} + {nextPath && <a href={nextPath}>{ui.t('catalog.next')}</a>} + </nav> + )} + </section> + ); +} diff --git a/frontend/apps/web/src/components/public/course.tsx b/frontend/apps/web/src/components/public/course.tsx new file mode 100644 index 00000000..a21216a9 --- /dev/null +++ b/frontend/apps/web/src/components/public/course.tsx @@ -0,0 +1,96 @@ +import type { PublicTranslate } from '@/i18n/catalogues'; +import { textDirection } from '@/i18n/locale'; +import type { TextDirection } from '@/i18n/locale'; +import { coursePath, lessonPath, paginationPath } from '@/server/public-paths'; +import type { PublicResource } from '@/server/public-resource'; + +type CourseResource = Extract<PublicResource, { kind: 'course' }>; +type PublicCourseProps = { + readonly resource: CourseResource; + readonly ui: { + readonly locale: string; + readonly direction: TextDirection; + readonly t: PublicTranslate; + }; +}; + +export function PublicCourse({ resource, ui }: PublicCourseProps) { + const { data, pagination } = resource; + const { course } = data; + const routeLocale = resource.request.locale ?? ''; + // Access policy wins even when an inconsistent response supplies an outline. + const lessons = course.contentAccess === 'public' ? data.lessons : null; + const basePath = coursePath(routeLocale, course.slug); + const nextPath = + basePath && lessons?.pageInfo.hasNext && lessons.pageInfo.nextCursor !== null + ? paginationPath(basePath, pagination, lessons.pageInfo.nextCursor, true) + : null; + const restartPath = + basePath && course.contentAccess === 'public' && pagination.isPaginated + ? paginationPath(basePath, pagination, null, true) + : null; + + return ( + <article className="public-course" lang={data.locale} dir={textDirection(data.locale)}> + <h1>{course.title}</h1> + {course.summary?.trim() && <p className="public-summary">{course.summary}</p>} + {course.level && + (course.level.state === 'ready' && course.level.label ? ( + <p + className="public-muted" + lang={course.level.label.locale} + dir={textDirection(course.level.label.locale)} + > + {course.level.label.value} + </p> + ) : ( + <p className="public-muted" lang={ui.locale} dir={ui.direction}> + {ui.t('course.level_unavailable')} + </p> + ))} + {course.contentAccess !== 'public' ? ( + <p className="public-muted" lang={ui.locale} dir={ui.direction}> + {ui.t('course.restricted_outline')} + </p> + ) : ( + <section className="public-outline"> + <h2 lang={ui.locale} dir={ui.direction}> + {ui.t('course.outline')} + </h2> + {!lessons || lessons.items.length === 0 ? ( + <p className="public-muted" lang={ui.locale} dir={ui.direction}> + {ui.t('course.empty_outline')} + </p> + ) : ( + <> + <p className="public-muted" lang={ui.locale} dir={ui.direction}> + {ui.t('course.lesson_count', { count: lessons.items.length })} + </p> + <ol className="public-lesson-list"> + {lessons.items.map((lesson) => { + const path = lessonPath(routeLocale, course.slug, lesson.slug); + return ( + <li key={lesson.slug}> + {path ? <a href={path}>{lesson.title}</a> : lesson.title} + </li> + ); + })} + </ol> + </> + )} + {(restartPath || nextPath) && ( + <nav + className="public-pagination" + lang={ui.locale} + dir={ui.direction} + aria-label={ui.t('course.outline')} + > + {restartPath && <a href={restartPath}>{ui.t('course.restart')}</a>} + {nextPath && <a href={nextPath}>{ui.t('course.next')}</a>} + </nav> + )} + </section> + )} + </article> + ); +} diff --git a/frontend/apps/web/src/server/public-metadata.test.ts b/frontend/apps/web/src/server/public-metadata.test.ts new file mode 100644 index 00000000..c88e9af5 --- /dev/null +++ b/frontend/apps/web/src/server/public-metadata.test.ts @@ -0,0 +1,336 @@ +// @vitest-environment node +import { describe, expect, it, vi } from 'vitest'; + +import { createPublicTranslator } from '@/i18n/catalogues'; + +import type { ConfiguredPublicClient } from './configured-public-client'; +import { publicMetadata } from './public-metadata'; +import type { PublicRequest } from './public-request'; +import type { requirePublicResource } from './public-resource'; +import { publicRoute } from './public-route'; + +type Resource = Awaited<ReturnType<typeof requirePublicResource>>; + +function request(target = '/tr/courses', locale: string | null = 'tr'): PublicRequest { + return { + context: { host: 'school.example:9443', peer: '203.0.113.7', method: 'GET', target }, + client: { + getSite: vi.fn<ConfiguredPublicClient['getSite']>(), + getCourses: vi.fn<ConfiguredPublicClient['getCourses']>(), + getCourse: vi.fn<ConfiguredPublicClient['getCourse']>(), + getLesson: vi.fn<ConfiguredPublicClient['getLesson']>(), + }, + site: { + displayName: 'School', + enabledLocales: ['tr', 'en', 'ar', 'zh-Hans-CN'], + defaultLocale: 'tr', + showPlatformAttribution: true, + theme: null, + }, + locale, + route: publicRoute(target), + }; +} + +function catalog(): Extract<Resource, { kind: 'catalog' }> { + return { + kind: 'catalog', + request: request(), + pagination: { limit: '20', isPaginated: false }, + data: { + locale: 'tr', + items: [], + pageInfo: { hasNext: false, hasPrevious: false, nextCursor: null, previousCursor: null }, + }, + }; +} + +function course(): Extract<Resource, { kind: 'course' }> { + return { + kind: 'course', + request: request('/tr/courses/temel'), + pagination: { limit: '20', isPaginated: false }, + data: { + locale: 'tr', + course: { + slug: 'temel', + title: 'Temel kursu', + summary: 'Derslere giriş.', + contentAccess: 'public', + level: null, + }, + lessons: null, + alternates: [ + { locale: 'tr', slug: 'temel' }, + { locale: 'en', slug: 'foundation' }, + ], + }, + }; +} + +const ui = createPublicTranslator('tr'); + +describe('public metadata projection', () => { + it('gives an empty first catalog an indexable localized title and every enabled locale', () => { + expect(publicMetadata(catalog(), ui)).toEqual({ + title: ui.t('catalog.title'), + robots: { index: true, follow: true }, + alternates: { + canonical: 'https://school.example:3000/tr/courses', + languages: { + tr: 'https://school.example:3000/tr/courses', + en: 'https://school.example:3000/en/courses', + ar: 'https://school.example:3000/ar/courses', + 'zh-Hans-CN': 'https://school.example:3000/zh-Hans-CN/courses', + }, + }, + openGraph: { + type: 'website', + title: ui.t('catalog.title'), + siteName: 'School', + url: 'https://school.example:3000/tr/courses', + locale: 'tr', + alternateLocale: ['en', 'ar', 'zh-Hans-CN'], + }, + }); + }); + + it('uses actual eligible translated slugs and does not invent enabled resource translations', () => { + const metadata = publicMetadata(course(), ui); + expect(metadata.title).toBe('Temel kursu'); + expect(metadata.description).toBe('Derslere giriş.'); + expect(metadata.alternates).toEqual({ + canonical: 'https://school.example:3000/tr/courses/temel', + languages: { + tr: 'https://school.example:3000/tr/courses/temel', + en: 'https://school.example:3000/en/courses/foundation', + }, + }); + expect(metadata.openGraph).toMatchObject({ locale: 'tr', alternateLocale: ['en'] }); + expect(JSON.stringify(metadata)).not.toContain('/en/courses/temel'); + expect(JSON.stringify(metadata)).not.toContain('/ar/'); + }); + + it.each([catalog, course])( + 'uses the cursor-free first canonical and noindex for pagination', + (create) => { + const resource = create(); + const paginated = { + ...resource, + pagination: { cursor: 'private_cursor', limit: '7', isPaginated: true }, + request: { + ...resource.request, + context: { + ...resource.request.context, + target: `${resource.request.context.target}?cursor=private_cursor&lessonCursor=private_cursor&limit=7&locale=en&host=evil.example`, + }, + }, + }; + const metadata = publicMetadata(paginated, ui); + expect(metadata.robots).toEqual({ index: false, follow: true }); + expect(metadata.alternates?.canonical).toEqual( + publicMetadata(resource, ui).alternates?.canonical, + ); + expect(JSON.stringify(metadata)).not.toMatch(/private_cursor|evil|\?/); + }, + ); + + it('keeps restricted course marketing indexable without an outline', () => { + const resource = course(); + resource.data.course.contentAccess = 'enrollment_required'; + expect(publicMetadata(resource, ui)).toMatchObject({ + title: 'Temel kursu', + robots: { index: true }, + }); + }); + + it.each([null, '', ' '])( + 'omits absent optional summary %s in both metadata surfaces', + (summary) => { + const resource = course(); + resource.data.course.summary = summary; + const metadata = publicMetadata(resource, ui); + expect(metadata).not.toHaveProperty('description'); + expect(metadata.openGraph).not.toHaveProperty('description'); + }, + ); + + it('retains authored strings as data without treating their contents as URLs', () => { + const resource = course(); + resource.data.course.title = '<script>alert(1)</script>'; + resource.data.course.summary = 'https://foreign.example/<b>summary</b>'; + const metadata = publicMetadata(resource, ui); + expect(metadata.title).toBe(resource.data.course.title); + expect(metadata.description).toBe(resource.data.course.summary); + expect(metadata.alternates?.canonical).toBe('https://school.example:3000/tr/courses/temel'); + }); + + it('uses admitted content locale, independently of UI fallback and payload locale', () => { + const resource = catalog(); + const metadata = publicMetadata( + { ...resource, request: request('/ar/courses?locale=en', 'ar') }, + createPublicTranslator('ar'), + ); + expect(metadata.title).toBe('Courses'); + expect(metadata.openGraph).toMatchObject({ locale: 'ar' }); + expect(metadata.alternates?.canonical).toBe('https://school.example:3000/ar/courses'); + }); + + it('emits no extra API reads, even on repeated metadata projections', () => { + const resource = course(); + publicMetadata(resource, ui); + publicMetadata(resource, ui); + for (const operation of Object.values(resource.request.client)) + expect(operation).not.toHaveBeenCalled(); + }); +}); + +describe('metadata rejects unsafe URL inputs', () => { + it.each([ + 'https://evil.example', + '//evil.example', + 'school.example@evil.example', + 'school.example/path', + 'school.example?host=evil', + 'school.example\n', + 'School.example', + 'school.example.', + ])('omits every URL for unsafe or noncanonical verified-host value %s', (host) => { + const resource = course(); + const metadata = publicMetadata( + { + ...resource, + request: { ...resource.request, context: { ...resource.request.context, host } }, + }, + ui, + ); + expect(metadata.robots).toEqual({ index: false, follow: false }); + expect(metadata).not.toHaveProperty('alternates'); + expect(metadata).not.toHaveProperty('openGraph'); + }); + + it.each(['school.example', 'school.example:443', '[::1]:9443'])( + 'uses the fixed HTTPS listener on canonical host %s', + (host) => { + const resource = catalog(); + const metadata = publicMetadata( + { + ...resource, + request: { ...resource.request, context: { ...resource.request.context, host } }, + }, + ui, + ); + expect(metadata.alternates?.canonical).toBe( + `https://${host.startsWith('[') ? '[::1]' : 'school.example'}:3000/tr/courses`, + ); + }, + ); + + it.each([ + '../private', + '//evil.example', + 'https://evil.example', + '%74emel', + 'temel?secret=1', + 'temel#secret', + '1234567890abcdef1234567890abcdef', + ])('fails closed when current API slug %s is not admitted route data', (slug) => { + const resource = course(); + resource.data.course.slug = slug; + const metadata = publicMetadata(resource, ui); + expect(metadata).not.toHaveProperty('alternates'); + expect(metadata).not.toHaveProperty('openGraph'); + expect(metadata.robots).toEqual({ index: false, follow: false }); + }); + + it.each([null, 'TR', '../tr', 'fr'])( + 'fails closed for noncanonical or disabled content locale %s', + (locale) => { + const resource = catalog(); + const metadata = publicMetadata( + { ...resource, request: { ...resource.request, locale } }, + ui, + ); + expect(metadata).not.toHaveProperty('alternates'); + expect(metadata.robots).toEqual({ index: false, follow: false }); + }, + ); + + it('omits unsafe, noncanonical, disabled and unroutable alternate entries', () => { + const resource = course(); + resource.data.alternates.push( + { locale: 'ar', slug: '../private' }, + { locale: 'EN', slug: 'foundation' }, + { locale: 'fr', slug: 'fondation' }, + { locale: '//evil.example', slug: 'course' }, + { locale: 'zh-Hans-CN', slug: '%66oundation' }, + ); + expect(publicMetadata(resource, ui).alternates).toEqual( + publicMetadata(course(), ui).alternates, + ); + const initial = catalog(); + const corrupted = { + ...initial, + request: { + ...initial.request, + site: { ...initial.request.site, enabledLocales: ['tr', 'EN', '../en', 'en?x=1'] }, + }, + }; + expect(publicMetadata(corrupted, ui).alternates?.languages).toEqual({ + tr: 'https://school.example:3000/tr/courses', + }); + }); +}); + +describe('controlled noindex metadata states', () => { + it.each(['invalid_cursor', 'rate_limited', 'unavailable'] as const)( + 'localizes %s without original route details or resource alternates', + (state) => { + const resource: Resource = { + kind: 'failure', + request: request('/tr/courses/private-slug?cursor=private_token'), + state, + }; + const metadata = publicMetadata(resource, ui); + expect(metadata).toEqual({ + title: ui.t(`page.${state}.title`), + description: ui.t(`page.${state}.description`), + robots: { index: false, follow: false }, + }); + expect(JSON.stringify(metadata)).not.toMatch(/private|lockey_|https:/); + }, + ); + + it('localizes the fixed missing state without resource alternates', () => { + expect( + publicMetadata({ kind: 'status', request: request('/tr/status/not-found') }, ui), + ).toEqual({ + title: ui.t('page.missing.title'), + description: ui.t('page.missing.description'), + robots: { index: false, follow: false }, + }); + }); + + it('keeps scaffold and pending lesson implementation noindex', () => { + const resources: Resource[] = [ + { kind: 'scaffold', request: request('/studio', null) }, + { + kind: 'lesson', + request: request('/tr/courses/temel/lessons/giris'), + data: { + locale: 'tr', + course: { slug: 'temel', title: 'Temel' }, + lesson: { slug: 'giris', title: 'Giriş' }, + content: { state: 'unavailable' }, + alternates: [], + }, + }, + ]; + for (const resource of resources) { + expect(publicMetadata(resource, ui)).toEqual({ + title: 'School', + robots: { index: false, follow: false }, + }); + } + }); +}); diff --git a/frontend/apps/web/src/server/public-metadata.ts b/frontend/apps/web/src/server/public-metadata.ts new file mode 100644 index 00000000..0455d440 --- /dev/null +++ b/frontend/apps/web/src/server/public-metadata.ts @@ -0,0 +1,104 @@ +import type { Metadata } from 'next'; + +import type { PublicTranslate } from '@/i18n/catalogues'; +import type { TextDirection } from '@/i18n/locale'; + +import { publicRedirect } from './public-entry'; +import { catalogPath, coursePath } from './public-paths'; +import type { requirePublicResource } from './public-resource'; + +type Resource = Awaited<ReturnType<typeof requirePublicResource>>; +type PublicUi = { + readonly locale: string; + readonly direction: TextDirection; + readonly t: PublicTranslate; +}; + +function unavailable(t: PublicTranslate): Metadata { + return { + title: t('page.unavailable.title'), + description: t('page.unavailable.description'), + robots: { index: false, follow: false }, + }; +} + +function publicUrl(host: string, path: string | null): string | null { + if (path === null) return null; + try { + return publicRedirect(host, path); + } catch { + return null; + } +} + +/** Pure projection of the shared resource read; UI fallback never supplies URL locale. */ +export function publicMetadata(resource: Resource, { t }: PublicUi): Metadata { + if (resource.kind === 'status') { + return { + title: t('page.missing.title'), + description: t('page.missing.description'), + robots: { index: false, follow: false }, + }; + } + if (resource.kind === 'failure') { + if (resource.state === 'invalid_cursor') { + return { + title: t('page.invalid_cursor.title'), + description: t('page.invalid_cursor.description'), + robots: { index: false, follow: false }, + }; + } + if (resource.state === 'rate_limited') { + return { + title: t('page.rate_limited.title'), + description: t('page.rate_limited.description'), + robots: { index: false, follow: false }, + }; + } + return unavailable(t); + } + // Lesson metadata is extended with the actual lesson page in P02d-6 Step 3. + if (resource.kind === 'lesson' || resource.kind === 'scaffold') { + return { title: resource.request.site.displayName, robots: { index: false, follow: false } }; + } + const { request, pagination } = resource; + const { locale, site, context } = request; + if (locale === null || !site.enabledLocales.includes(locale)) return unavailable(t); + const path = + resource.kind === 'catalog' + ? catalogPath(locale) + : coursePath(locale, resource.data.course.slug); + const canonical = publicUrl(context.host, path); + if (canonical === null) return unavailable(t); + + const title = resource.kind === 'catalog' ? t('catalog.title') : resource.data.course.title; + const summary = resource.kind === 'course' ? resource.data.course.summary : null; + const description = summary?.trim() ? summary : undefined; + const candidates = + resource.kind === 'catalog' + ? site.enabledLocales.map((language) => [language, catalogPath(language)] as const) + : resource.data.alternates.map( + ({ locale: language, slug }) => [language, coursePath(language, slug)] as const, + ); + const languages: Record<string, string> = {}; + for (const [language, alternatePath] of candidates) { + if (!site.enabledLocales.includes(language)) continue; + const alternate = publicUrl(context.host, alternatePath); + if (alternate !== null) languages[language] = alternate; + } + return { + title, + ...(description === undefined ? {} : { description }), + robots: { index: !pagination.isPaginated, follow: true }, + alternates: { canonical, ...(Object.keys(languages).length ? { languages } : {}) }, + openGraph: { + type: 'website', + title, + ...(description === undefined ? {} : { description }), + url: canonical, + siteName: site.displayName, + locale, + alternateLocale: Object.keys(languages).filter((language) => language !== locale), + }, + }; +} diff --git a/frontend/apps/web/src/server/public-paths.test.ts b/frontend/apps/web/src/server/public-paths.test.ts new file mode 100644 index 00000000..a898ef81 --- /dev/null +++ b/frontend/apps/web/src/server/public-paths.test.ts @@ -0,0 +1,133 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +import { catalogPath, coursePath, lessonPath, paginationPath } from './public-paths'; +import { publicPagination } from './public-route'; + +describe('safe public local paths', () => { + it.each(['en', 'tr-TR', 'zh-Hans-CN', 'ar', 'courses'])( + 'preserves admitted canonical locale %s without a UI language registry', + (locale) => { + expect(catalogPath(locale)).toBe(`/${locale}/courses`); + expect(coursePath(locale, 'foundation-2')).toBe(`/${locale}/courses/foundation-2`); + expect(lessonPath(locale, 'foundation-2', 'intro-1')).toBe( + `/${locale}/courses/foundation-2/lessons/intro-1`, + ); + }, + ); + + it.each(['', 'EN', 'tr-tr', 'en_US', '../en', '//evil.example', 'en?x=1', 'en#x', '%65n'])( + 'refuses unsafe or noncanonical locale %s', + (locale) => { + expect(catalogPath(locale)).toBeNull(); + expect(coursePath(locale, 'foundation')).toBeNull(); + expect(lessonPath(locale, 'foundation', 'intro')).toBeNull(); + }, + ); + + it.each([ + '', + '.', + '..', + '../other', + 'one/two', + 'one\\two', + '//evil.example', + 'https://evil.example', + 'javascript:alert(1)', + 'data:text/html,evil', + 'item?cursor=private', + 'item#private', + 'Foundation', + 'temel-kursu ', + 'temel--kursu', + '-foundation', + 'foundation-', + 'öğrenme', + '%66oundation', + '%2e%2e', + '%252f', + 'line\nbreak', + 'a'.repeat(161), + '1234567890abcdef1234567890abcdef', + '12345678-90ab-cdef-1234-567890abcdef', + ])('refuses API slug %s at every link position', (slug) => { + expect(coursePath('en', slug)).toBeNull(); + expect(lessonPath('en', slug, 'intro')).toBeNull(); + expect(lessonPath('en', 'foundation', slug)).toBeNull(); + }); + + it('accepts the full storage width for a non-UUID slug', () => { + const slug = 'z'.repeat(160); + expect(coursePath('en', slug)).toBe(`/en/courses/${slug}`); + }); +}); + +describe.each([ + { base: '/tr/courses', outline: false, cursor: 'cursor', limit: 'limit' }, + { + base: '/tr/courses/temel', + outline: true, + cursor: 'lessonCursor', + limit: 'lessonLimit', + }, +])('pagination links for $base', ({ base, outline, cursor, limit }) => { + it('preserves normalized limit and replaces the current cursor without carrying foreign data', () => { + const pagination = publicPagination( + `${base}?${cursor}=previous_private&${limit}=0007&locale=en&next=https://evil.example&other=1`, + outline, + ); + expect(pagination).not.toBeNull(); + const next = paginationPath(base, pagination!, 'opaque_NEXT-1', outline); + expect(next).toBe(`${base}?${limit}=7&${cursor}=opaque_NEXT-1`); + expect(next).not.toMatch(/previous|private|locale|evil|other/); + expect(paginationPath(base, pagination!, null, outline)).toBe(`${base}?${limit}=7`); + }); + + it('is independent of input percent encoding and never decodes cursor contents', () => { + const encoded = publicPagination(`${base}?${cursor}=%41%5F%2D&${limit}=%30%30%32`, outline); + const plain = publicPagination(`${base}?${cursor}=A_-&${limit}=2`, outline); + expect(encoded).toEqual(plain); + // A is not a serialized API cursor, but the frontend treats it as opaque. + expect(paginationPath(base, encoded!, 'A', outline)).toBe(`${base}?${limit}=2&${cursor}=A`); + expect(paginationPath(base, plain!, 'A', outline)).toBe(`${base}?${limit}=2&${cursor}=A`); + }); + + it.each(['', '%41', 'a/b', 'a+b', 'a=', 'a.b', 'a?b=1', 'a#b', 'a\nb', 'a'.repeat(1025)])( + 'refuses an invalid API next cursor %s', + (token) => { + expect(paginationPath(base, { limit: '20', isPaginated: false }, token, outline)).toBeNull(); + }, + ); + + it.each(['0', '01', '101', '1.5', '20&next=https://evil.example', ' 20'])( + 'refuses an unnormalized limit %s', + (value) => { + expect(paginationPath(base, { limit: value, isPaginated: false }, null, outline)).toBeNull(); + }, + ); + + it.each([ + 'https://evil.example', + '//evil.example', + '/en/courses/../other', + '/en/status/not-found', + '/en/courses?foreign=1', + '/en/courses#fragment', + '/EN/courses', + ])('refuses an unsafe or non-content base %s', (path) => { + expect(paginationPath(path, { limit: '20', isPaginated: true }, null, outline)).toBeNull(); + }); + + it('refuses the other pagination surface and lesson paths', () => { + expect(paginationPath(base, { limit: '20', isPaginated: false }, null, !outline)).toBeNull(); + expect( + paginationPath( + '/en/courses/foundation/lessons/intro', + { limit: '20', isPaginated: false }, + null, + outline, + ), + ).toBeNull(); + }); +}); diff --git a/frontend/apps/web/src/server/public-paths.ts b/frontend/apps/web/src/server/public-paths.ts new file mode 100644 index 00000000..92efb45a --- /dev/null +++ b/frontend/apps/web/src/server/public-paths.ts @@ -0,0 +1,43 @@ +import { canonicalRouteLocale } from './public-entry'; +import type { PublicPagination } from './public-route'; + +/** API segments are literal identifiers, never pre-encoded paths or URL values. */ +function contentPath(locale: string, segments: readonly string[]): string | null { + // Reject percent escapes instead of turning an API alias into a valid slug. + if (segments.some((segment) => /[%/?#\\]/.test(segment))) return null; + const path = `/${locale}/${segments.join('/')}`; + return canonicalRouteLocale(path) === locale ? path : null; +} + +export function catalogPath(locale: string): string | null { + return contentPath(locale, ['courses']); +} + +export function coursePath(locale: string, slug: string): string | null { + return contentPath(locale, ['courses', slug]); +} + +export function lessonPath(locale: string, courseSlug: string, lessonSlug: string): string | null { + return contentPath(locale, ['courses', courseSlug, 'lessons', lessonSlug]); +} + +/** Keep only this surface's normalized limit and opaque next token; null restarts. */ +export function paginationPath( + basePath: string, + pagination: PublicPagination, + nextCursor: string | null, + outline: boolean, +): string | null { + if ( + basePath.includes('?') || + canonicalRouteLocale(basePath) === null || + basePath.split('/').length !== (outline ? 4 : 3) || + !/^(?:[1-9][0-9]?|100)$/.test(pagination.limit) || + (nextCursor !== null && (nextCursor.length > 1024 || !/^[A-Za-z0-9_-]+$/.test(nextCursor))) + ) + return null; + const query = new URLSearchParams(); + query.set(outline ? 'lessonLimit' : 'limit', pagination.limit); + if (nextCursor !== null) query.set(outline ? 'lessonCursor' : 'cursor', nextCursor); + return `${basePath}?${query.toString()}`; +} From 86f8ea19a050ead86b2d18b61437d4ba1f8b0a5b Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 01:00:36 +0300 Subject: [PATCH 09/58] docs(roadmap): record P02d-6 catalog review round one Keep the independent runtime and proof reviews tied to the exact implementation range before the second round and lesson work. ADR: 0027, 0052, 0053 --- docs/roadmap/phase-02d-walking-skeleton.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 8b9a68ff..d56b184f 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4332,8 +4332,16 @@ budget. Positive host-scope assertions derive from the seed declaration: Yoga's mapped host includes tenant-wide and its own organization's published marketing, while excluding the other organization. These are HTTP/RSC proofs, not browser or manual accessibility evidence. Formatting, local links/fragments, added prose -and diff checks pass. Both independent review rounds have not started; commit -and review closeout precede Step 3. +and diff checks pass. Review closeout precedes Step 3. + +**Step 2 review round 1 — 2026-10-10.** Fresh GPT-6-astra xhigh runtime/security +and GPT-6.1-sol xhigh proof/corpus agents independently reviewed +`785c8c5..917f9f8`. Both approve with no actionable findings. The runtime reviewer +ran 283 focused frontend cases; the proof reviewer ran the 166 new view/metadata/ +path cases, fixture syntax and diff checks. Both traced adjacent admission and +resource loaders, approved metadata/pagination boundaries and real fixture +accounting. Neither started a build, server or Docker fixture; their pure checks +supplement the implementation validation above. Round 2 remains pending. ### P02d-1 decision pass (2026-09-14) From c250677cc0b76a4362ff87263e2f133b89dd33d5 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 01:05:51 +0300 Subject: [PATCH 10/58] fix(public): include eligible metadata self references Match the real API alternate contract instead of assuming it includes the current locale. Prove current and translated URLs through actual HTML, derive protected-value canaries from seed data and close both independent catalog review rounds. ADR: 0027, 0052, 0053 --- .../Database/PublicServerRenderingTests.cs | 7 ++++ docs/architecture/14-frontend-architecture.md | 2 +- docs/roadmap/phase-02d-walking-skeleton.md | 21 ++++++++++- docs/standards/07-frontend-architecture.md | 2 +- .../web/scripts/verify-public-rendering.mjs | 37 ++++++++++++++++--- .../web/src/server/public-metadata.test.ts | 14 +++++-- .../apps/web/src/server/public-metadata.ts | 5 ++- 7 files changed, 73 insertions(+), 15 deletions(-) diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs index 65f8a4b6..8f518fc6 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs @@ -463,6 +463,13 @@ private static object TenantInput(SeedTenant tenant, SeedCourse course, string l restrictedSlug = restrictedTranslation?.Slug, restrictedTitle = restrictedTranslation?.Title, restrictedSummary = restrictedTranslation?.Summary, + restrictedCanaries = restricted?.Lessons.SelectMany(row => row.Translations.Where(item => item.Locale == locale)) + .SelectMany(item => + { + using var content = JsonDocument.Parse(item.Body); + return new[] { item.Title, item.Slug }.Concat(content.RootElement.EnumerateObject() + .Select(field => field.Value.GetString()!)).ToArray(); + }).Distinct(StringComparer.Ordinal).ToArray() ?? [], catalogEn, catalogDefault, hiddenEn = hidden.Select(row => row.Translations.Single(translation => translation.Locale == locale).Title).ToArray(), diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index ac573e11..00ed9bb4 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -72,7 +72,7 @@ frontend/ sessions/ api/ # only thin BFF proxies, see "Data Fetching" components/ - public/ # P6 chrome/states; content views pending + public/ # P6 chrome/states and catalog/course views blocks/ # built-in primitive page blocks ui/ # design-system primitives lib/ diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index d56b184f..4022f8c2 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -14,7 +14,7 @@ > | 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 | in progress — decision pass Accepted 2026-10-09; [decision package](#p02d-6-decision-package-2026-10-09); [Step 1](#p02d-6-step-1-localization-and-document-foundation) review rounds complete; [Step 2 catalog/course](#p02d-6-step-2-catalog-and-course-pages) implemented, reviews pending; Steps 3–4 pending | +> | P02d-6 | Public renderer | in progress — decision pass Accepted 2026-10-09; [decision package](#p02d-6-decision-package-2026-10-09); [Step 1](#p02d-6-step-1-localization-and-document-foundation) and [Step 2 catalog/course](#p02d-6-step-2-catalog-and-course-pages) review rounds complete; Steps 3–4 pending | > | P02d-7 | Demo, full-stack CI and exit | not started | **Acceptance update — 2026-10-02.** P02d-1 remains merged. The maintainer accepted @@ -4343,6 +4343,25 @@ resource loaders, approved metadata/pagination boundaries and real fixture accounting. Neither started a build, server or Docker fixture; their pure checks supplement the implementation validation above. Round 2 remains pending. +**Step 2 review round 2 — 2026-10-10.** Fresh GPT-6-astra xhigh runtime/security +and GPT-6-sol xhigh proof/corpus agents reviewed `785c8c5..86f8ea1`. Both +independently confirmed a missing course hreflang self-reference: the real API +excludes the current locale from its alternate list, unlike the unit fixture. +The fix explicitly uses the current validated canonical, preserves other eligible +API slugs and refuses a supplied self alias. The corrected DTO fixture and actual +production HTML assert self/translated/reciprocal URLs. Protected-content absence +canaries now derive from seed lesson titles, slugs and values, with a nonempty +control and full HTML/Flight checks. Two current architecture-tree comments now +acknowledge delivered catalog/course views. + +Both reviewers inspected and approve the focused fixes, with no remaining +concrete finding. The root passes 284 focused frontend cases, workspace typecheck, +targeted lint, fixture syntax, scoped formatting and diff checks. All 17 focused +Release production-rendering cases pass again, zero failures/skips, with the +unchanged 52/18 call accounting and new real metadata/containment assertions. +Local links/fragments and added prose checks pass. Step 2 is complete after both +independent review rounds; Step 3 follows. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/standards/07-frontend-architecture.md b/docs/standards/07-frontend-architecture.md index 95a18c64..642b65f3 100644 --- a/docs/standards/07-frontend-architecture.md +++ b/docs/standards/07-frontend-architecture.md @@ -44,7 +44,7 @@ frontend/ layout.tsx # root layout middleware.ts # ingress verification and live locale/path admission components/ - public/ # P6 Step 1 chrome/states; content views pending + public/ # P6 chrome/states and catalog/course views i18n/ # ADR-0027 foundation implemented in P6 Step 1 request.ts messages/ diff --git a/frontend/apps/web/scripts/verify-public-rendering.mjs b/frontend/apps/web/scripts/verify-public-rendering.mjs index ece3cec3..226bb5b4 100644 --- a/frontend/apps/web/scripts/verify-public-rendering.mjs +++ b/frontend/apps/web/scripts/verify-public-rendering.mjs @@ -401,6 +401,11 @@ function productDocument(response, tenant, locale, heading, path, direction = 'l 'https://' + tenant.host + ':3000' + path, 'Verified-host Open Graph URL', ); + assert.equal( + doc.querySelector('link[rel="alternate"][hreflang="' + locale + '"]')?.getAttribute('href'), + 'https://' + tenant.host + ':3000' + path, + 'Eligible current locale has a canonical hreflang self-reference', + ); assert.ok(doc.body.textContent.includes(tenant.name), 'Live tenant chrome'); const other = configuration.tenants.find((candidate) => candidate.host !== tenant.host); assert.equal(doc.body.textContent.includes(other.name), false, 'No opposite tenant chrome'); @@ -510,6 +515,18 @@ async function verifyFoundation(native, nextBin) { course, ); assert.ok(yogaCourse.body.textContent.includes(second.courseSummary)); + assert.equal( + yogaCourse + .querySelector('link[rel="alternate"][hreflang="' + second.defaultLocale + '"]') + ?.getAttribute('href'), + 'https://' + + second.host + + ':3000/' + + second.defaultLocale + + '/courses/' + + second.defaultCourseSlug, + 'Other-locale hreflang uses its actual translated slug', + ); assert.equal( yogaCourse.querySelector( 'ol.public-lesson-list a[href="' + course + '/lessons/' + second.lessonSlug + '"]', @@ -529,6 +546,11 @@ async function verifyFoundation(native, nextBin) { turkishCoursePath, ); assert.ok(turkishCourse.body.textContent.includes(second.defaultCourseSummary)); + assert.equal( + turkishCourse.querySelector('link[rel="alternate"][hreflang="en"]')?.getAttribute('href'), + 'https://' + second.host + ':3000' + course, + 'Translated course retains the eligible English reciprocal hreflang', + ); assert.equal( turkishCourse.querySelector( 'ol.public-lesson-list a[href="' + @@ -544,8 +566,9 @@ async function verifyFoundation(native, nextBin) { stage = 'foundation restricted marketing'; const restrictedPath = catalog + '/' + first.restrictedSlug; + const restrictedResponse = await request(first, restrictedPath); const restricted = productDocument( - await request(first, restrictedPath), + restrictedResponse, first, 'en', first.restrictedTitle, @@ -560,11 +583,13 @@ async function verifyFoundation(native, nextBin) { null, 'Protected outline is absent', ); - assert.equal( - restricted.body.textContent.includes('Giving reasons'), - false, - 'Protected lesson title is absent', - ); + assert.ok(first.restrictedCanaries.length > 0, 'Nonempty protected seed controls'); + for (const canary of first.restrictedCanaries) + assert.equal( + restrictedResponse.body.includes(canary), + false, + 'Protected value absent from HTML and Flight', + ); await checkpoint('foundation-restricted'); stage = 'foundation empty exact-locale catalog'; diff --git a/frontend/apps/web/src/server/public-metadata.test.ts b/frontend/apps/web/src/server/public-metadata.test.ts index c88e9af5..cc9bd3eb 100644 --- a/frontend/apps/web/src/server/public-metadata.test.ts +++ b/frontend/apps/web/src/server/public-metadata.test.ts @@ -60,10 +60,7 @@ function course(): Extract<Resource, { kind: 'course' }> { level: null, }, lessons: null, - alternates: [ - { locale: 'tr', slug: 'temel' }, - { locale: 'en', slug: 'foundation' }, - ], + alternates: [{ locale: 'en', slug: 'foundation' }], }, }; } @@ -111,6 +108,15 @@ describe('public metadata projection', () => { expect(JSON.stringify(metadata)).not.toContain('/ar/'); }); + it('retains the eligible self-reference without API alternates or a supplied self alias', () => { + const resource = course(); + resource.data.alternates = []; + const expected = { tr: 'https://school.example:3000/tr/courses/temel' }; + expect(publicMetadata(resource, ui).alternates?.languages).toEqual(expected); + resource.data.alternates = [{ locale: 'tr', slug: 'wrong-current-slug' }]; + expect(publicMetadata(resource, ui).alternates?.languages).toEqual(expected); + }); + it.each([catalog, course])( 'uses the cursor-free first canonical and noindex for pagination', (create) => { diff --git a/frontend/apps/web/src/server/public-metadata.ts b/frontend/apps/web/src/server/public-metadata.ts index 0455d440..2704afa8 100644 --- a/frontend/apps/web/src/server/public-metadata.ts +++ b/frontend/apps/web/src/server/public-metadata.ts @@ -80,9 +80,10 @@ export function publicMetadata(resource: Resource, { t }: PublicUi): Metadata { : resource.data.alternates.map( ({ locale: language, slug }) => [language, coursePath(language, slug)] as const, ); - const languages: Record<string, string> = {}; + // Resource alternates intentionally exclude the current API locale. + const languages: Record<string, string> = { [locale]: canonical }; for (const [language, alternatePath] of candidates) { - if (!site.enabledLocales.includes(language)) continue; + if (language === locale || !site.enabledLocales.includes(language)) continue; const alternate = publicUrl(context.host, alternatePath); if (alternate !== null) languages[language] = alternate; } From 0be38e8cf5e242edccb3ce0192ebd987f3040c0b Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 01:16:00 +0300 Subject: [PATCH 11/58] feat(public): render ordered lesson presentation Display only exact API text descriptors and eligible lesson metadata. Prove schema pin changes and inert values through unchanged product routes, and make every active recommended JSX accessibility rule fail on a planted violation in the actual app configuration. ADR: 0027, 0051, 0052, 0053, 0054 --- .claude/skills/add-page-block/SKILL.md | 8 +- CLAUDE.md | 6 +- .../Database/PublicServerRenderingTests.cs | 210 ++++++++- docs/architecture/12-localization.md | 7 +- docs/architecture/14-frontend-architecture.md | 11 +- .../32-tenant-customization-model.md | 7 +- docs/decisions/README.md | 2 +- docs/modules/customization/README.md | 3 +- docs/roadmap/phase-02d-walking-skeleton.md | 52 ++- .../roadmap/phase-06-renderer-admin-studio.md | 4 +- docs/standards/06-testing.md | 6 +- docs/standards/07-frontend-architecture.md | 9 +- docs/standards/08-localization.md | 9 +- docs/standards/09-error-handling.md | 2 +- docs/standards/16-accessibility.md | 11 +- docs/standards/README.md | 9 +- frontend/apps/web/.eslintrc.cjs | 20 + .../web/scripts/verify-public-rendering.mjs | 337 ++++++++++++++- .../[slug]/lessons/[lessonSlug]/page.tsx | 29 ++ frontend/apps/web/src/app/globals.css | 25 ++ .../web/src/components/public/lesson.test.tsx | 406 ++++++++++++++++++ .../apps/web/src/components/public/lesson.tsx | 56 +++ .../web/src/server/public-metadata.test.ts | 134 +++++- .../apps/web/src/server/public-metadata.ts | 38 +- .../web/src/server/public-resource.test.ts | 65 ++- .../apps/web/src/server/public-resource.ts | 13 +- .../web/src/test/accessibility-lint.test.ts | 149 +++++++ 27 files changed, 1542 insertions(+), 86 deletions(-) create mode 100644 frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/lessons/[lessonSlug]/page.tsx create mode 100644 frontend/apps/web/src/components/public/lesson.test.tsx create mode 100644 frontend/apps/web/src/components/public/lesson.tsx create mode 100644 frontend/apps/web/src/test/accessibility-lint.test.ts diff --git a/.claude/skills/add-page-block/SKILL.md b/.claude/skills/add-page-block/SKILL.md index b5f69c38..96b3e262 100644 --- a/.claude/skills/add-page-block/SKILL.md +++ b/.claude/skills/add-page-block/SKILL.md @@ -60,7 +60,7 @@ and [17-page-builder.md](../../../docs/architecture/17-page-builder.md). | **Composite renderer** | C# composite registry + React renderer (`default-card`, `content-list`, `card-grid`). | LearnStack engineering. | Tenants compose this in `TenantPageBlock` rows. | | **Tenant block** | `tenant_page_blocks` row (data only). | Tenant admin via Studio editor. | Tenant-specific shape (`vocabulary-list` for English, `asana-card` for yoga). | -> **Accepted P02d-6 G41 — 2026-10-09; implementation pending.** The first +> **P02d-6 G41 subset implemented — Step 3, 2026-10-10.** The first > ordered plain-string `default-card` views live in > `frontend/apps/web/src/components/public/`, with bounded unavailable fallbacks > and no active URL sink. The wider registry and component sketches below remain @@ -209,8 +209,10 @@ the page. [Testing Standards](../../../docs/standards/06-testing.md#end-to-end-tests). Accepted P02d-6/G43 requires applicable jsx-a11y error rules with actual-config planted controls, localized product-page semantics and real manual keyboard, - focus, reflow, contrast and screen-reader evidence. Implementation and evidence - remain pending; acceptance alone supplies no passing accessibility check. + focus, reflow, contrast and screen-reader evidence. Step 3 implements the + applicable lint controls and synchronous lesson semantics; full product/manual + evidence remains Step 4. See the + [delivery record](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-3-ordered-lesson-presentation). - [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. diff --git a/CLAUDE.md b/CLAUDE.md index bdfd191b..f34b7d95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,7 +126,11 @@ loading components. Its owns its validation and review evidence. Step 2 adds actual catalog/course pages, independent pagination and metadata from the shared resource loader. Its [delivery record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-2-catalog-and-course-pages) -owns current verification and review status. Lesson presentation remains Step 3. +owns current verification and review status. Step 3 adds the ordered plain-text +lesson page, eligible lesson metadata and 31 blocking JSX accessibility rules +with actual-config planted controls. Its +[delivery record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-3-ordered-lesson-presentation) +owns verification and review status; full product/manual closeout remains Step 4. P02d-7 owns the browser demo and Lighthouse; Phase 06 retains the full renderer/Studio expansion. diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs index 8f518fc6..f6263284 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs @@ -23,8 +23,8 @@ namespace LearnStack.Tests.Integration.Database; /// <summary> /// Production Next/native TLS/SDK/API/PostgreSQL proof. The disposable renderer -/// separates the P5 transport probe from unchanged P6 catalog/course routes. -/// The larger pagination inventory exists only during its own fixture mode. +/// separates the P5 transport probe from unchanged P6 product routes. +/// Pagination inventory and presentation revisions belong to their own modes. /// </summary> [Collection(PublicReadTestGroup.Name)] [Trait(RequiresDocker.Key, RequiresDocker.Value)] @@ -32,11 +32,22 @@ public sealed class PublicServerRenderingTests(PublicReadFixture fixture) { private const string SuppliedTrace = "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01"; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private static readonly Guid PresentationRevisionId = Guid.Parse("01930000-0000-7000-8000-000000006003"); + private static readonly PresentationField[] PresentationFields = + [ + new("fixture_script", new("Script text", "en"), "<script>globalThis.fixturePresentationExecuted=true</script>"), + new("fixture_markup", new("İşaretleme metni", "tr-TR"), "<img src=\"https://fixture-presentation.invalid/image\" onerror=\"alert('fixture')\"><strong>Authored markup</strong>"), + new("fixture_javascript", new("نص البرمجية", "ar"), "javascript:alert('fixture-presentation')"), + new("fixture_data", new("Data address", "en"), "data:text/html,<script>alert('fixture-presentation')</script>"), + new("fixture_http", new("HTTP address", "en"), "http://fixture-presentation.invalid/content") + ]; + private static readonly PresentationLabel PresentationTypeLabel = new("Deneme kartı", "tr-TR"); [Theory] [InlineData("transport")] [InlineData("foundation")] [InlineData("foundation-pagination")] + [InlineData("presentation")] public async Task Production_rendering_is_host_isolated_fresh_and_private_through_the_real_public_API(string mode) { var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); @@ -82,6 +93,7 @@ public async Task Production_rendering_is_host_isolated_fresh_and_private_throug var foundationPosition = 0; var foundationLocales = false; var paginationRows = false; + var presentationRevision = false; var tracePosition = 0; var beforeProtocol = -1; var beforeStock = -1; @@ -113,7 +125,8 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new locale, courseSlug = sharedSlug, traceparent = SuppliedTrace, - tenants + tenants, + presentation = new { label = PresentationTypeLabel, fields = PresentationFields } }, JsonOptions)); while (await process.StandardOutput.ReadLineAsync(deadline.Token) is { } checkpoint) { @@ -226,6 +239,61 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new "status pages and admission refusals must never query Education"); foundationPosition = observed.Requests.Length; break; + case "presentation-english": + case "presentation-yoga-en": + case "presentation-yoga-tr": + case "presentation-rsc-english": + case "presentation-rsc-yoga-tr": + case "presentation-exact-pin": + case "presentation-swapped": + case "presentation-rsc-swapped": + case "presentation-yoga-unchanged": + case "presentation-empty": + case "presentation-unavailable": + case "presentation-restored": + var presentationRequests = observed.Requests.Skip(foundationPosition).ToArray(); + presentationRequests.Should().HaveCount(3, + "the unchanged lesson route shares one exact content read across metadata, document and page"); + presentationRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(2); + var lessonRequest = presentationRequests.Single(request => request.Path != "/api/v1/public/site"); + lessonRequest.Path.Should().Contain("/lessons/"); + lessonRequest.Locale.Should().Be(checkpoint is "presentation-yoga-tr" or "presentation-rsc-yoga-tr" or "presentation-yoga-unchanged" + ? SeedData.Yoga.Curriculum!.Locales.Single(row => row.IsDefault).Locale : locale); + foundationPosition = observed.Requests.Length; + break; + case "presentation-cross-host": + case "presentation-protected": + var hiddenLessonRequests = observed.Requests.Skip(foundationPosition).ToArray(); + hiddenLessonRequests.Should().HaveCount(5, + "one refused lesson document and its localized status redirect use the real bootstrap chain"); + hiddenLessonRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(4); + hiddenLessonRequests.Single(request => request.Path != "/api/v1/public/site").Locale.Should() + .Be(checkpoint == "presentation-protected" ? SeedData.Yoga.Curriculum!.Locales.Single(row => row.IsDefault).Locale : locale); + foundationPosition = observed.Requests.Length; + break; + case "presentation-publish-revision": + observed.Requests.Length.Should().Be(foundationPosition, "fixture setup makes no public API calls"); + await AddPresentationRevisionAsync(); + presentationRevision = true; + break; + case "presentation-pin-revision": + observed.Requests.Length.Should().Be(foundationPosition); + await SetPresentationLessonAsync(firstCourse, SeedData.English.Curriculum!.ContentType.SchemaVersion + 1, + JsonSerializer.Serialize(PresentationFields.ToDictionary(field => field.Name, field => field.Value), JsonOptions)); + break; + case "presentation-make-empty": + observed.Requests.Length.Should().Be(foundationPosition); + await SetPresentationLessonAsync(firstCourse, SeedData.English.Curriculum!.ContentType.SchemaVersion + 1, "{}"); + break; + case "presentation-make-unavailable": + observed.Requests.Length.Should().Be(foundationPosition); + await SetPresentationLessonAsync(firstCourse, SeedData.English.Curriculum!.ContentType.SchemaVersion + 1, + "{\"fixture_script\":42,\"fixture_hidden\":\"unavailable-private-body-canary\"}"); + break; + case "presentation-restore-lesson": + observed.Requests.Length.Should().Be(foundationPosition); + await RestorePresentationLessonAsync(firstCourse); + break; case "verified": verified = true; break; @@ -245,13 +313,20 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new "foundation-course-yoga", "foundation-course-turkish", "foundation-restricted", "foundation-empty", "foundation-cross-tenant", "foundation-missing", "foundation-status", "foundation-head", "foundation-refused", "foundation-canonical", "stock-before", "stock-after", "verified"); - else checkpoints.Should().Equal("build-complete", "pagination-catalog-first", "pagination-catalog-next", + else if (mode == "foundation-pagination") checkpoints.Should().Equal("build-complete", "pagination-catalog-first", "pagination-catalog-next", "pagination-catalog-restart", "pagination-outline-first", "pagination-outline-next", "pagination-outline-restart", "verified"); + else checkpoints.Should().Equal("build-complete", "presentation-english", "presentation-yoga-en", "presentation-yoga-tr", + "presentation-rsc-english", "presentation-rsc-yoga-tr", "presentation-publish-revision", "presentation-exact-pin", + "presentation-pin-revision", "presentation-swapped", "presentation-rsc-swapped", "presentation-yoga-unchanged", + "presentation-make-empty", "presentation-empty", "presentation-make-unavailable", "presentation-unavailable", + "presentation-restore-lesson", "presentation-restored", "presentation-cross-host", "presentation-protected", "verified"); if (mode == "foundation") observed.Requests.Should().HaveCount(52, "normal/repeat, both hosts and languages, restricted/empty/cross-tenant, status and refusal checks fit one visitor window"); if (mode == "foundation-pagination") observed.Requests.Should().HaveCount(18, "six actual catalog/outline documents each make exactly three API calls"); + if (mode == "presentation") observed.Requests.Should().HaveCount(46, + "twelve actual lesson HTML/RSC representations and two followed hidden lessons fit the unchanged visitor budget"); observed.Requests.Length.Should().BeInRange(1, 59, "the fixture stays within one real anonymous visitor budget"); observed.Requests.Should().OnlyContain(request => request.ValidHop, "the real caller uses the closed authenticated hop"); observed.Logs.Should().BeGreaterThan(0, "API log containment needs a nonempty real logging subject"); @@ -278,6 +353,7 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new await fixture.ExecuteAsync(SeedData.English, "DELETE FROM tenant_locales WHERE tenant_id=@tenant AND locale IN ('ar','tr-TR','tr')"); if (paginationRows) await RemovePaginationRowsAsync(firstCourse); + if (presentationRevision) await RemovePresentationRevisionAsync(firstCourse); } } } @@ -382,6 +458,87 @@ private Task SetStatusAsync(SeedCourse course, string status) => fixture.Execute "UPDATE courses SET status=@status WHERE tenant_id=@tenant AND id=@id", new Dictionary<string, object> { ["id"] = course.Id, ["status"] = status.ToLowerInvariant() }); + private Task AddPresentationRevisionAsync() + { + var type = SeedData.English.Curriculum!.ContentType; + var fields = PresentationFields.Select(field => new + { + name = field.Name, + label = new Dictionary<string, string> { [field.Label.Locale] = field.Label.Value } + }).Append(new { name = "fixture_optional", label = new Dictionary<string, string> { ["en"] = "Omitted optional field" } }).ToArray(); + // This new optional-string profile satisfies ADR-0051. Its order differs + // from JSONB key order; the immutable seeded revision is never rewritten. + var schema = JsonSerializer.Serialize(new Dictionary<string, object> + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = "object", + ["properties"] = fields.ToDictionary(field => field.name, _ => new { type = "string" }), + ["additionalProperties"] = false, + ["x-fields"] = fields + }, JsonOptions); + return fixture.ExecuteOwnerFixtureAsync(SeedData.English, + """ + UPDATE tenant_content_types SET status='Deprecated' + WHERE tenant_id=@tenant AND id=@original AND status='Active'; + INSERT INTO tenant_content_types(id,tenant_id,key,schema_version,status,display_name, + json_schema,renderer_key,created_at,created_by) + VALUES(@revision,@tenant,@key,@version,'Active',CAST(@label AS jsonb), + CAST(@schema AS jsonb),'default-card',now(),@actor); + UPDATE customization_generations SET generation=generation+1 WHERE tenant_id=@tenant; + """, + new Dictionary<string, object> + { + ["original"] = type.Id, + ["revision"] = PresentationRevisionId, + ["key"] = type.Key, + ["version"] = type.SchemaVersion + 1, + ["label"] = JsonSerializer.Serialize(new Dictionary<string, string> + { + [PresentationTypeLabel.Locale] = PresentationTypeLabel.Value + }, JsonOptions), + ["schema"] = schema + }); + } + + private Task SetPresentationLessonAsync(SeedCourse course, int version, string body) => fixture.ExecuteOwnerFixtureAsync( + SeedData.English, + """ + UPDATE lessons SET content_type_schema_version=@version WHERE tenant_id=@tenant AND id=@lesson; + UPDATE lesson_translations SET body=CAST(@body AS jsonb) + WHERE tenant_id=@tenant AND lesson_id=@lesson AND locale='en'; + """, + new Dictionary<string, object> + { + ["lesson"] = course.Lessons.First(row => row.Status == "Published").Id, + ["version"] = version, + ["body"] = body + }); + + private Task RestorePresentationLessonAsync(SeedCourse course) + { + var lesson = course.Lessons.First(row => row.Status == "Published"); + return SetPresentationLessonAsync(course, lesson.ContentTypeSchemaVersion, + lesson.Translations.Single(row => row.Locale == "en").Body); + } + + private async Task RemovePresentationRevisionAsync(SeedCourse course) + { + // Also runs after any failed checkpoint; no test-owned pin/body, lifecycle + // or generation increment survives into the shared fixture's next case. + await RestorePresentationLessonAsync(course); + await fixture.ExecuteOwnerFixtureAsync(SeedData.English, + """ + DELETE FROM tenant_content_types WHERE tenant_id=@tenant AND id=@revision; + UPDATE tenant_content_types SET status='Active' WHERE tenant_id=@tenant AND id=@original; + UPDATE customization_generations SET generation=generation-1 WHERE tenant_id=@tenant; + """, + new Dictionary<string, object> + { + ["revision"] = PresentationRevisionId, + ["original"] = SeedData.English.Curriculum!.ContentType.Id + }); + } + private Task AddPaginationRowsAsync(SeedCourse course) { var lesson = course.Lessons.First(row => row.Status == "Published"); @@ -440,6 +597,7 @@ private static object TenantInput(SeedTenant tenant, SeedCourse course, string l var hidden = tenant.Curriculum.Courses.Except(visible).ToArray(); var restricted = visible.FirstOrDefault(row => row.ContentAccess == "enrollment_required"); var restrictedTranslation = restricted?.Translations.Single(row => row.Locale == locale); + var restrictedDefault = restricted?.Translations.Single(row => row.Locale == defaultLocale); var catalogEn = visible.Select(row => row.Translations.Single(translation => translation.Locale == locale)) .Select(row => new { row.Title, row.Slug }).ToArray(); var catalogDefault = visible.Select(row => row.Translations.Single(translation => translation.Locale == defaultLocale)) @@ -460,16 +618,16 @@ private static object TenantInput(SeedTenant tenant, SeedCourse course, string l defaultCourseSummary = defaultCourse.Summary, defaultLessonSlug = defaultLesson.Slug, defaultLessonTitle = defaultLesson.Title, + content = SeedPresentation(tenant, translation, locale), + defaultContent = SeedPresentation(tenant, defaultLesson, defaultLocale), restrictedSlug = restrictedTranslation?.Slug, + restrictedDefaultSlug = restrictedDefault?.Slug, + restrictedDefaultLessonSlug = restricted?.Lessons.First(row => row.Status == "Published") + .Translations.Single(row => row.Locale == defaultLocale).Slug, restrictedTitle = restrictedTranslation?.Title, restrictedSummary = restrictedTranslation?.Summary, - restrictedCanaries = restricted?.Lessons.SelectMany(row => row.Translations.Where(item => item.Locale == locale)) - .SelectMany(item => - { - using var content = JsonDocument.Parse(item.Body); - return new[] { item.Title, item.Slug }.Concat(content.RootElement.EnumerateObject() - .Select(field => field.Value.GetString()!)).ToArray(); - }).Distinct(StringComparer.Ordinal).ToArray() ?? [], + restrictedCanaries = LessonCanaries(restricted, locale), + restrictedDefaultCanaries = LessonCanaries(restricted, defaultLocale), catalogEn, catalogDefault, hiddenEn = hidden.Select(row => row.Translations.Single(translation => translation.Locale == locale).Title).ToArray(), @@ -477,6 +635,36 @@ private static object TenantInput(SeedTenant tenant, SeedCourse course, string l }; } + private static object SeedPresentation(SeedTenant tenant, SeedLessonTranslation translation, string locale) + { + var type = tenant.Curriculum!.ContentType; + using var schema = JsonDocument.Parse(type.JsonSchema); + using var body = JsonDocument.Parse(translation.Body); + return new + { + label = new PresentationLabel(type.DisplayName[locale], locale), + fields = schema.RootElement.GetProperty("x-fields").EnumerateArray().Select(field => + { + var name = field.GetProperty("name").GetString()!; + return new PresentationField(name, + new PresentationLabel(field.GetProperty("label").GetProperty(locale).GetString()!, locale), + body.RootElement.GetProperty(name).GetString()!); + }).ToArray() + }; + } + + private static string[] LessonCanaries(SeedCourse? course, string locale) => course?.Lessons + .SelectMany(row => row.Translations.Where(item => item.Locale == locale)) + .SelectMany(item => + { + using var content = JsonDocument.Parse(item.Body); + return new[] { item.Title, item.Slug }.Concat(content.RootElement.EnumerateObject() + .Select(field => field.Value.GetString()!)).ToArray(); + }).Distinct(StringComparer.Ordinal).ToArray() ?? []; + + private sealed record PresentationLabel(string Value, string Locale); + private sealed record PresentationField(string Name, PresentationLabel Label, string Value); + private static Process RendererProcess(string root, string? temporaryRoot = null) { var start = new ProcessStartInfo("node") diff --git a/docs/architecture/12-localization.md b/docs/architecture/12-localization.md index b972ce2d..4c3f072e 100644 --- a/docs/architecture/12-localization.md +++ b/docs/architecture/12-localization.md @@ -20,8 +20,8 @@ Initial renderer scope: renderer, including an enabled RTL locale; they are not deferred until a tenant requests a redesign. Accepted P02d-6 G39/G43 selects the mapping and proofs; Step 1 implements direction selection and fallback chrome; Step 2 adds - catalog/course language attribution. Lesson and full product/manual proof - remain Steps 3–4. + catalog/course language attribution; Step 3 adds ordered lesson field labels. + Full product/manual proof remains Step 4. - P02d-6 accepts initial English and Turkish platform UI catalogues in [ADR-0027](../decisions/0027-frontend-i18n.md). Additional UI translations are added when authored; their absence must not narrow enabled content locales. @@ -343,7 +343,8 @@ Step 1 implements the pinned dependency/plugin, catalogues, request runtime and key/ICU/callsite controls. The [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) owns foundation verification and review evidence. Step 2 adds catalog/course -consumers; lesson and full product/manual proof remain Steps 3–4. Phase 04 inherits +consumers; Step 3 adds ordered lesson labels and metadata. Full product/manual +proof remains Step 4. Phase 04 inherits the foundation for CMS/Studio; Phase 06 owns the full Studio/portal consumers. diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index 00ed9bb4..ea953752 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -27,15 +27,16 @@ native method/upgrade admission and pinned URL controls. The remediation record owns execution and independent review evidence; the ongoing rules live in [Frontend Standards](../standards/07-frontend-architecture.md#native-admission-and-url-identity). -**P02d-6 decisions Accepted — 2026-10-09; Steps 1–2 implemented.** +**P02d-6 decisions Accepted — 2026-10-09; Steps 1–3 implemented.** [ADR-0027](../decisions/0027-frontend-i18n.md) selects the UI foundation; the [decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09) selects pages, status/navigation, safe theme and product/accessibility proofs. Step 1 installs the pinned runtime and supplies shared request-local loaders, document language/direction, atomic theme injection, chrome and controlled status/error/loading components. Step 2 adds actual catalog/course pages, -independent opaque pagination and metadata. Lesson presentation remains Step 3. The -[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-2-catalog-and-course-pages) +independent opaque pagination and metadata. Step 3 adds ordered lesson fields, +language attribution and eligible metadata. The +[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-3-ordered-lesson-presentation) owns current validation and review evidence. > **P02d-4 Accepted design — 2026-10-03.** Step 2 delivers site bootstrap; @@ -72,7 +73,7 @@ frontend/ sessions/ api/ # only thin BFF proxies, see "Data Fetching" components/ - public/ # P6 chrome/states and catalog/course views + public/ # P6 public views and states blocks/ # built-in primitive page blocks ui/ # design-system primitives lib/ @@ -95,7 +96,7 @@ frontend/ > **Phase 02d ownership.** Accepted G41 places synchronous plain-string views in > `apps/web/src/components/public/`; G39 selects the app-local i18n home above. > Step 1 implements i18n and chrome/state views; Step 2 adds catalog/course views. -> Lesson presentation remains Step 3. +> Step 3 adds ordered plain-text lesson views and metadata. > P02d-5/G36 delivers Node middleware in > `apps/web/src/middleware.ts`. The ownership and accepted entry rule are recorded in > [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). diff --git a/docs/architecture/32-tenant-customization-model.md b/docs/architecture/32-tenant-customization-model.md index f01f9b14..3db3de3a 100644 --- a/docs/architecture/32-tenant-customization-model.md +++ b/docs/architecture/32-tenant-customization-model.md @@ -49,10 +49,11 @@ organization-scoped where it makes sense. [ADR-0051](../decisions/0051-ordered-text-card-presentation.md) adds optional root ordered `x-fields` metadata and a plain-string `default-card` profile. The two seed types opt in; the parser and exact reader validate metadata, and legacy schemas remain -valid. Browser rendering belongs to P02d-6. The wider renderer set below is a -target, not implemented Phase 02d coverage. P02d-6 implements only the approved subset. +valid. P02d-6 Step 3 implements the bounded lesson view. The wider renderer set +below is a target, not implemented Phase 02d coverage. P02d-6 implements only the +approved subset. -> **P02d-6 G41 Accepted — 2026-10-09; implementation pending.** ADR-0051 selects +> **P02d-6 G41 subset implemented — Step 3, 2026-10-10.** ADR-0051 selects > ordered plain-string `default-card` fields with authored language-bearing labels. > Synchronous views live in `frontend/apps/web/src/components/public/`. Unsupported > renderers/unresolved presentation use bounded placeholders, with no raw JSON, diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 464c5f7f..e14ba0d8 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -46,7 +46,7 @@ an amendment is not a lifecycle status change. | 0022 | [Custom Domain & TLS](0022-custom-domain-tls.md) | Hub-owned custom domain admin; DNS-01 + HTTP-01 + Let's Encrypt; APISIX hot-reload | | 0023 | [Strongly-Typed ID Source Generator — Vogen](0023-strongly-typed-id-source-generator.md) | Vogen as the source generator for both IDs and value objects; `[ValueObject<Guid>]` annotation; EF + JSON + ASP.NET + OpenAPI emitters out of the box (Amendment 1: Vogen 7.0.0 pin + architecture-test placement, 2026-05-21; Amendment 2: `UserId` / `TenantId` / `OrganizationId` are cross-cutting and live in `LearnStack.SharedKernel`, 2026-08-10; **Amendment 3: `IStronglyTypedId` gains `IsInitialized()` and is no longer a pure marker, 2026-08-10**; Amendments 4–7, 2026-08-27 to 2026-09-01: `idempotency_keys` mints no id, `gen_uuid_v7()` is not a PostgreSQL function, the retroactive disclosure of the 2026-05-21 edit, and an id's own formatting is not a wire format; **Amendment 8, 2026-09-07: a module-local identifier crosses a cross-module contract as `Guid`**) | | 0024 | [API Versioning Policy](0024-api-versioning-policy.md) | URL-based `/v{N}/`; 6-month deprecation window; RFC 8594 `Sunset` + `Deprecation` headers; OpenAPI `deprecated` + `x-sunset` extensions; 410 Gone with RFC 7807 on sunset | -| 0027 | [Frontend UI Localization with next-intl](0027-frontend-i18n.md) | Accepted 2026-10-09: next-intl 4.14.9, server-first app-local ICU catalogues, dotted UI keys and verified request-locale source; P02d-6 Step 1 implements the foundation and Step 2 adds catalog/course pages; lesson presentation remains Step 3; Phase 04 consumes the foundation | +| 0027 | [Frontend UI Localization with next-intl](0027-frontend-i18n.md) | Accepted 2026-10-09: next-intl 4.14.9, server-first app-local ICU catalogues, dotted UI keys and verified request-locale source; P02d-6 Step 1 implements the foundation Step 2 adds catalog/course pages and Step 3 adds ordered lesson presentation; Phase 04 consumes the foundation | | 0028 | [`audit_log` Partition Management — Hangfire Recurring Job](0028-audit-log-partition-management.md) | Daily `learnstack:audit:partition-management` Hangfire job; create-ahead 2 months; drop only on platform-max retention horizon; row-level purge separate; no `pg_partman` dependency (Amendment 2026-09-07: Packet 9 creates a **plain, unpartitioned** table and executes none of this ADR's DDL; `Partition_Manager_Job_Is_Registered_AtStartup` moves to Phase 11 with the job it guards) | | 0029 | [Object Storage — SeaweedFS](0029-object-storage-seaweedfs.md) | Self-hosted SeaweedFS behind the existing `IStorageProvider` S3 contract; partially supersedes ADR-0002's MinIO row | | 0030 | [Redis-compatible Store — Valkey](0030-redis-compatible-store-valkey.md) | Valkey (Linux Foundation, BSD-3-Clause) for the cache + Dapr state-store backend; RESP-protocol drop-in; partially supersedes ADR-0002's Redis row | diff --git a/docs/modules/customization/README.md b/docs/modules/customization/README.md index edcccc51..73dd9c9d 100644 --- a/docs/modules/customization/README.md +++ b/docs/modules/customization/README.md @@ -235,7 +235,8 @@ binding purpose (`NewBinding` or `ExistingPin`), never an inferred live version. if it is concurrently deprecated. Strict commit-time eligibility is not selected. - Body validation uses `IJsonSchemaValidator` against that returned exact schema. Generation-keyed read caching is delivered in P02d-3; P02d-4 public shape is - delivered by Step 3. Renderer fallback remains P02d-6. These are + delivered by Step 3. P02d-6 Step 3 implements the bounded ordered-text renderer + and unavailable fallback. These are separate from the exact write contract. - Module-owned contextual verification queries give the seeder exact IDs, revision data, labels/bands and state. They are audit Off and introduce no setter exception. diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 4022f8c2..fd156b3e 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -14,7 +14,7 @@ > | 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 | in progress — decision pass Accepted 2026-10-09; [decision package](#p02d-6-decision-package-2026-10-09); [Step 1](#p02d-6-step-1-localization-and-document-foundation) and [Step 2 catalog/course](#p02d-6-step-2-catalog-and-course-pages) review rounds complete; Steps 3–4 pending | +> | P02d-6 | Public renderer | in progress — decision pass Accepted 2026-10-09; [decision package](#p02d-6-decision-package-2026-10-09); [Step 1](#p02d-6-step-1-localization-and-document-foundation) and [Step 2 catalog/course](#p02d-6-step-2-catalog-and-course-pages) review rounds complete; [Step 3 lesson](#p02d-6-step-3-ordered-lesson-presentation) implemented, review pending; Step 4 pending | > | P02d-7 | Demo, full-stack CI and exit | not started | **Acceptance update — 2026-10-02.** P02d-1 remains merged. The maintainer accepted @@ -4362,6 +4362,56 @@ unchanged 52/18 call accounting and new real metadata/containment assertions. Local links/fragments and added prose checks pass. Step 2 is complete after both independent review rounds; Step 3 follows. +### P02d-6 Step 3: Ordered lesson presentation + +**Implementation — 2026-10-10; independent review rounds pending.** The actual +lesson route consumes the shared verified resource loader in page and metadata, +without a separate content read. Its synchronous view renders the API's ordered +plain-string fields as definition pairs, preserves present empty strings, and +attributes each resolved label's language/direction independently of the content +and UI locales. The course backlink uses the admitted request locale. No field +name, raw body/schema, active URL, HTML or richer primitive becomes visible. + +Empty ready content differs from unavailable/unknown presentation. Unsupported +renderers short-circuit before reading their descriptors and emit at most one +request-local bounded count/state warning; API-reported unavailable definitions +retain their own existing diagnostic. Lesson canonical/Open Graph and eligible +hreflang URLs use validated current and actual translated course/lesson slugs. +Unavailable/unknown presentation is noindex with its eligible canonical preserved. + +All 31 active recommended jsx-a11y rules are blocking through the actual app +configuration. A literal census and per-rule dirty/clean controls detect missing +rules, downgraded severity and vacuous subjects. Synchronous view and page-dispatch +units supplement the production fixture; they do not claim async Server Component +DOM support. Full product/manual accessibility closeout remains Step 4, and the +Accessibility standard stays Adopted. + +**Validation — 2026-10-10.** The guarded frontend suite passes 1,059 tests +(999 web and 60 SDK), zero failures/skips/todos. Workspace typecheck and full web +lint pass. All 18 Release production-rendering cases pass, zero failures/skips. +The new presentation mode observes exactly 46 API calls; foundation and pagination +retain 52 and 18 respectively, each in a fresh unchanged visitor budget. + +Actual English and Yoga lesson HTML/RSC proves ordered text, resolved label +languages, eligible translated metadata and tenant absence. A test-owned successor +revision first leaves the deprecated exact pin unchanged, then changes descriptors +only after switching the lesson pin. Inert script/markup/URL strings, absent +optional fields, empty ready and unavailable content are exercised in one running +process. Setup restores the original body, pin, lifecycle and generation. Both +cross-host and protected lesson requests follow the localized 307→404 chain: +original path segments may occur in Next's redirect routing tree, while private +content stays absent and the final status document omits even the original slug. +Native ports, fixture directories and containers are clean after completion. + +Workspace production build passes with all three content routes dynamic; its +about 103 kB First Load JS is a local observation, not a Lighthouse result. +All 198 Release architecture cases and seven ADR workflow cases pass, zero +failures/skips. Local relative links/fragments, added-prose and formatting checks +pass. Native verification passes TLS, method/HEAD admission, redirects, production +closure, DEBUG containment and development HMR. The solution Release build passes +with zero warnings/errors. No browser or manual +accessibility pass is claimed; full product/manual proof remains Step 4. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/roadmap/phase-06-renderer-admin-studio.md b/docs/roadmap/phase-06-renderer-admin-studio.md index 5b34c3b2..6294175c 100644 --- a/docs/roadmap/phase-06-renderer-admin-studio.md +++ b/docs/roadmap/phase-06-renderer-admin-studio.md @@ -26,8 +26,8 @@ Rows identify their delivery packet; planned P02d-6/7 work is not current behavi | Phase 02d foundation | Phase 06 adds | |---|---| | Host-based tenant + organization resolution, end to end | Per-organization branding override on the resolved context | -| Anonymous `(public)` catalog/course Server Component pages delivered P02d-6 Step 2; lesson page planned 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 renderer planned P02d-6 | The complete two-tier block registry with safe-render placeholders | +| 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 | diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index f4fa0f38..aa0ebb77 100644 --- a/docs/standards/06-testing.md +++ b/docs/standards/06-testing.md @@ -191,8 +191,10 @@ Rules: - Playwright covers the same golden flows the backend E2E covers, from the user's perspective. - Visual regression covers the public renderer and page-builder block output. -**Accepted P02d-6 G38(b,c)/G43 — 2026-10-09; implementation pending.** Vitest -covers synchronous views and pure mappings. Async pages require production +**P02d-6 G38(b,c)/G43 — Steps 1–3 implemented, 2026-10-10.** Vitest +covers synchronous views and pure mappings; all 31 active recommended JSX +accessibility rules have blocking actual-config dirty/clean controls. Async pages +require production HTML/RSC proof against unchanged product routes in the existing required real API/Next Docker fixture; keep P5's synthetic transport probe in a separate mode so its catch-all cannot shadow product routes. Preserve `learnstack_app`, RLS, diff --git a/docs/standards/07-frontend-architecture.md b/docs/standards/07-frontend-architecture.md index 642b65f3..bed020b9 100644 --- a/docs/standards/07-frontend-architecture.md +++ b/docs/standards/07-frontend-architecture.md @@ -44,7 +44,7 @@ frontend/ layout.tsx # root layout middleware.ts # ingress verification and live locale/path admission components/ - public/ # P6 chrome/states and catalog/course views + public/ # P6 chrome/states and catalog/course/lesson views i18n/ # ADR-0027 foundation implemented in P6 Step 1 request.ts messages/ @@ -212,7 +212,8 @@ owns the source, pin, diff policy, bootstrap exception and required-check rollou **Accepted P02d-6 G40 — 2026-10-09.** Step 1 implements shared resource admission, controlled state views and the fixed status page. Step 2 implements catalog/course -pages and their pagination/metadata; lesson presentation remains Step 3. Keep the three +pages and their pagination/metadata; Step 3 adds lesson presentation/metadata. +Keep the three `/{locale}/courses` list/course/lesson routes and minimal tenant chrome. Use plain same-host relative anchors, including opaque catalog and outline pagination; disable automatic prefetch. A new document request re-reads API state. @@ -241,8 +242,8 @@ cache is permitted. Request-local reuse is isolated to one incoming request. Freshness is the next new server/document request, not client Router Cache history. Disable local Server Component HMR caching. P02d-6 Step 1 implements document loaders, chrome, state views and atomic theme injection. Step 2 adds catalog/course -views; lesson presentation remains Step 3. The -[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-2-catalog-and-course-pages) +views; Step 3 adds ordered lesson views. The +[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-3-ordered-lesson-presentation) owns verification and review status. P5 delivers the dynamic layout, transport and source/runtime proofs. Test-owned production routes exercise the real API; P6 public pages are not delivered by them. diff --git a/docs/standards/08-localization.md b/docs/standards/08-localization.md index a16d1ca9..715c3f04 100644 --- a/docs/standards/08-localization.md +++ b/docs/standards/08-localization.md @@ -47,7 +47,8 @@ fallback remains the delivered P02d-3 contract; language attributes are P02d-6. catalogue home, with UI-language fallback separate from content admission. The [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) owns foundation validation. Step 2 adds catalog/course views with actual resolved -label languages and exact-locale metadata; lesson presentation remains Step 3. +label languages and exact-locale metadata; Step 3 adds ordered lesson fields and +lesson metadata with eligible translated course/lesson slugs. Phase 04 inherits this foundation for CMS/Studio coverage. ## URL Strategy @@ -310,8 +311,10 @@ runtime internationalization data; an undescribed admitted tag falls back to `ltr`, never refusal. UI fallback groups retain their own direction. Test-owned enabled `ar` content proves `lang="ar"`/`dir="rtl"` and labelled English fallback UI independently; it neither rewrites the seed nor claims an Arabic UI catalogue. -Step 1 implements direction selection and labelled fallback chrome; actual -content-page and production RTL evidence remains a later P02d-6 obligation. +Step 1 implements direction selection and labelled fallback chrome; Step 2 +proves an empty admitted Arabic catalog in production. Step 3 adds per-field +label direction. Eligible authored RTL content and full manual proof remain +Step 4 obligations. ## Admin Studio UI diff --git a/docs/standards/09-error-handling.md b/docs/standards/09-error-handling.md index 5453bc6c..598e2441 100644 --- a/docs/standards/09-error-handling.md +++ b/docs/standards/09-error-handling.md @@ -435,7 +435,7 @@ implementation remains pending. ### Public Page Status and Recovery -**Accepted P02d-6 G40 — 2026-10-09; implementation pending.** A missing/hidden +**P02d-6 G40 composition implemented — Steps 1–3, 2026-10-10.** A missing/hidden content resource redirects locally with **307** to the same host's fixed `/{locale}/status/not-found`, whose admitted branded document returns **404**. The browser URL changes. The page rechecks live host/locale admission, never calls diff --git a/docs/standards/16-accessibility.md b/docs/standards/16-accessibility.md index 601eb52b..f6855928 100644 --- a/docs/standards/16-accessibility.md +++ b/docs/standards/16-accessibility.md @@ -102,13 +102,16 @@ LearnStack is an education platform; learners with disabilities are a first-clas routes; the CI placeholder remains disabled. - Manual keyboard walkthroughs for new screens in the PR review. -**Accepted P02d-6 G43 — 2026-10-09; implementation pending.** Applicable -jsx-a11y rules run at error severity with planted controls through the actual app -configuration. Actual product-page HTML/DOM proves titles, languages, one main, +**P02d-6 G43 enforcement — Step 3, 2026-10-10.** All 31 active recommended +jsx-a11y rules run at error severity with a literal rule census and per-rule dirty/ +clean controls through the actual app configuration in +`src/test/accessibility-lint.test.ts`. Synchronous catalog/course/lesson and state +views have semantic/language cases. Actual product-page HTML/DOM must prove +titles, languages, one main, one descriptive h1, sequential headings, semantic lists/definition lists, skip target and visible focus, including all controlled states. Logical CSS and wrapping must support RTL and long unbroken strings. Acceptance does not promote this -standard: it stays **Adopted** until its enforcement lands. +standard: it stays **Adopted** while full product/manual proof is pending. Catalog → course → lesson is a critical flow requiring manual keyboard, focus, 320 CSS px reflow/zoom, long-string, contrast and real screen-reader smoke evidence. diff --git a/docs/standards/README.md b/docs/standards/README.md index eadd9602..d09c9e10 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -78,8 +78,9 @@ and [ADR-0027](../decisions/0027-frontend-i18n.md) are Accepted — 2026-10-09; P6 Step 1 implements the pinned i18n/document foundation, shared request-local loaders, chrome/states and atomic theme injection. Its [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) -owns current validation and review evidence. Actual catalog/course/lesson -pages and product/accessibility proofs remain later P6 steps. P7 owns +owns foundation validation and review evidence. Steps 2–3 implement actual +catalog/course/lesson views, pagination/metadata and blocking JSX accessibility +controls. Full product/manual proof remains Step 4. P7 owns demo/browser/Lighthouse. Acceptance alone promotes no standard or test status. **The documents and this table say the same thing, and a test holds them to it.** @@ -94,7 +95,7 @@ included. | 00 | [Principles](00-principles.md) | **Active** | Governs every PR and every ADR; principles 1, 16 and 17 are already deciding live scope questions. | | 01 | [Architecture Standards](01-architecture-standards.md) | **Active** | Module layout shipped; `ModuleDomain_DoesNotDependOn_*` and the planted-violation meta-test are green. | | 02 | [Backend Coding](02-backend-coding.md) | **Active** | MediatR pipeline, `Result<T>`, `IClock`, the `LS0001` analyzer and the pipeline-order test all ship. Packet 6 brought the first `DbContext`, the first aggregates and the ambient unit of work, so its EF Core and domain-modelling clauses became live. Tenancy, Customization, Audit and Education now implement domain and persistence code. | -| 03 | [Frontend Coding](03-frontend-coding.md) | **Active** | ESLint and TypeScript strict mode are configured, the Vitest harness runs, and Packet 10 added the two rules this document's § Forbidden names — the direct-`fetch` ban and `dangerouslySetInnerHTML` outside the sanitised-HTML primitive — with a test that lints fixtures through the app's own configuration, because a configured rule and a missing one look identical from a green `pnpm lint`. P02d-5 delivers trusted entry/caller controls; P02d-6 Step 1 implements complete UI catalogues, ICU/callsite controls and synchronous chrome/state views; Step 2 adds catalog/course views and metadata; lesson presentation remains Step 3. | +| 03 | [Frontend Coding](03-frontend-coding.md) | **Active** | ESLint and TypeScript strict mode are configured, the Vitest harness runs, and Packet 10 added the two rules this document's § Forbidden names — the direct-`fetch` ban and `dangerouslySetInnerHTML` outside the sanitised-HTML primitive — with a test that lints fixtures through the app's own configuration, because a configured rule and a missing one look identical from a green `pnpm lint`. P02d-5 delivers trusted entry/caller controls; P02d-6 Step 1 implements complete UI catalogues, ICU/callsite controls and synchronous chrome/state views; Step 2 adds catalog/course views and metadata; Step 3 adds ordered lesson presentation and blocking JSX accessibility controls. | | 04 | [API Design](04-api-design.md) | **Active** | Packet 4 shipped the versioned route convention and its startup guards, one Problem Details shape on every error including the framework-minted ones, cursor pagination, the sort grammar, idempotency keys, ETag concurrency, correlation ids, the request-body limit and the tenancy edge — each with tests in the required `backend` check. P02d-4 adds four institution public GET/HEAD pairs, served/snapshot contract controls and compatibility CI; application writers remain unrouted. | | 05 | [Database](05-database.md) | **Active** | Packet 6 applied it — two migration chains, ten tables — and Packets 8 and 9 took it to four chains and seventeen data tables. P02d-1 Step 2 adds Education: five chains and twenty-one data tables (twenty-six including five migration-history tables), with parent-mirror and Pattern A proofs. The four-role model and the canonical RLS template this document owns — `ENABLE` **and** `FORCE`, one `AND`-ed policy per table, an explicit `WITH CHECK` — are asserted against a real PostgreSQL as `learnstack_app`. Its § Concurrency, § Table classes, § Indexes and § GRANT matrix each have a test that fails without them. Partitioning and the retention job are still ahead. | | 06 | [Testing](06-testing.md) | **Active** | Unit, architecture and contract suites, and the Docker-free integration tests, run in the required `backend` job — Packet 4 removed the filter that used to exclude the integration assembly, which by then held the only tests that could catch an unversioned route. The Docker-bound `backend-integration` job activated in Packet 6 with the four-role provisioning suite; the split is by `[Trait("Requires","Docker")]` and the two jobs' filters are exact complements. The `backend-integration` job is required as of P02d-1, 2026-09-14 ([CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main)). | @@ -107,7 +108,7 @@ included. | 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) | **Adopted** | P6 Step 2 delivers catalog/course pages; lesson presentation remains Step 3. Accepted G43 requires applicable jsx-a11y error rules with actual-config planted controls, actual-page semantic/language proofs and manual keyboard/focus/reflow/contrast/screen-reader evidence. Step 1 supplies chrome/state semantics and Step 2 adds catalog/course semantics; full applicable lint enforcement and manual evidence remain pending. Promotion occurs with enforcement. Full Playwright/axe remains [Phase 06](../roadmap/phase-06-renderer-admin-studio.md); Accepted G44 assigns Lighthouse to P02d-7 after P6. | +| 16 | [Accessibility](16-accessibility.md) | **Adopted** | P6 Steps 2–3 deliver catalog/course/lesson pages. Accepted G43 requires applicable jsx-a11y error rules with actual-config planted controls, actual-page semantic/language proofs and manual keyboard/focus/reflow/contrast/screen-reader evidence. Step 1 supplies chrome/state semantics; Steps 2–3 add catalog/course/lesson semantics. Step 3 blocks all 31 active recommended jsx-a11y rules with per-rule actual-config dirty/clean controls; full product/manual evidence remains pending. Promotion occurs with enforcement. Full Playwright/axe remains [Phase 06](../roadmap/phase-06-renderer-admin-studio.md); Accepted G44 assigns Lighthouse to P02d-7 after P6. | | 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). | diff --git a/frontend/apps/web/.eslintrc.cjs b/frontend/apps/web/.eslintrc.cjs index ae2e9323..28da56c7 100644 --- a/frontend/apps/web/.eslintrc.cjs +++ b/frontend/apps/web/.eslintrc.cjs @@ -7,9 +7,29 @@ // only handles bare package names + the `eslint-config-*` convention; subpath // exports from a workspace package (`@learnstack/config/eslint`) do not // resolve through ESLint's built-in resolver under pnpm-isolated layouts. +// Next already installs this plugin. Resolve it beside eslint-config-next so +// pnpm's isolated dependency layout does not require a duplicate app dependency. +// eslint-disable-next-line @typescript-eslint/no-require-imports -- This legacy CommonJS config must load the plugin's rule map. +const jsxA11yRecommended = require( + require.resolve('eslint-plugin-jsx-a11y', { paths: [require.resolve('eslint-config-next')] }), +).configs.recommended.rules; + +// Next enables a small JSX accessibility subset as warnings. Apply every active +// recommended rule at blocking severity while retaining the preset's options. +const jsxA11yErrors = Object.fromEntries( + Object.entries(jsxA11yRecommended) + .filter(([, setting]) => (Array.isArray(setting) ? setting[0] : setting) !== 'off') + .filter(([, setting]) => (Array.isArray(setting) ? setting[0] : setting) !== 0) + .map(([name, setting]) => [ + name, + Array.isArray(setting) ? ['error', ...setting.slice(1)] : 'error', + ]), +); + module.exports = { root: true, extends: [require.resolve('@learnstack/config/eslint'), 'next/core-web-vitals'], + rules: jsxA11yErrors, parserOptions: { project: ['./tsconfig.json'], tsconfigRootDir: __dirname, diff --git a/frontend/apps/web/scripts/verify-public-rendering.mjs b/frontend/apps/web/scripts/verify-public-rendering.mjs index 226bb5b4..547bf49b 100644 --- a/frontend/apps/web/scripts/verify-public-rendering.mjs +++ b/frontend/apps/web/scripts/verify-public-rendering.mjs @@ -842,6 +842,329 @@ async function verifyPagination(native) { assert.equal(native.exitCode, null, 'Pagination checks use one native process'); } +function lessonContainment(response, tenant) { + const other = configuration.tenants.find((candidate) => candidate.host !== tenant.host); + for (const value of [ + other.name, + other.lessonTitle, + ...other.content.fields.map((field) => field.value), + ]) + assert.equal(response.body.includes(value), false, 'Opposite tenant lesson is absent'); + assert.equal( + catalogueCanaries.some((value) => response.body.includes(value)), + false, + 'Complete UI catalogue cannot enter the lesson Flight payload', + ); +} + +function lessonDocument(response, tenant, locale, title, path, content) { + lessonContainment(response, tenant); + // Inspect the original DOM as well: the visible-document helper deliberately + // removes Next scripts and must not hide an authored active element. + const original = new JSDOM(response.body).window.document.querySelector('article.public-lesson'); + assert.ok(original, 'Actual lesson article is rendered'); + assert.equal( + original.querySelector( + 'script, template, style, img, svg, math, iframe, object, embed, audio, video, form', + ), + null, + 'Authored values create no active DOM element', + ); + for (const node of original.querySelectorAll('*')) + for (const attribute of node.attributes) + assert.equal( + /^on|^(?:src|srcdoc|action|formaction)$/i.test(attribute.name), + false, + 'No authored active attribute', + ); + const doc = productDocument(response, tenant, locale, title, path); + const article = doc.querySelector('article.public-lesson'); + assert.equal(article.lang, locale, 'Lesson text retains the exact content locale'); + assert.equal(article.dir, 'ltr'); + const back = article.querySelector('a'); + assert.equal(article.querySelectorAll('a').length, 1, 'URL-looking content is never linkified'); + assert.equal( + back.getAttribute('href'), + path.split('/lessons/')[0], + 'Back link uses the actual same-locale course', + ); + assert.equal( + back.textContent, + locale === tenant.defaultLocale ? tenant.defaultCourseTitle : tenant.courseTitle, + ); + assert.ok(doc.querySelector('a[href="#main-content"]'), 'Lesson has the product skip link'); + assert.ok(doc.querySelector('main#main-content'), 'Lesson has the product skip target'); + assert.equal( + doc.querySelectorAll('h3, h4, h5, h6').length, + 0, + 'Lesson heading outline stays sequential', + ); + if (content === null) { + assert.equal( + article.querySelector('h2, dl, dt, dd'), + null, + 'Unavailable presentation has no authored card', + ); + assert.ok(article.textContent.includes('This lesson content is currently unavailable.')); + assert.equal(article.textContent.includes('This lesson has no content to display yet.'), false); + assert.ok(doc.querySelector('meta[name="robots"]')?.content.includes('noindex')); + assert.equal( + response.body.includes('unavailable-private-body-canary'), + false, + 'Unavailable payload stays absent from HTML and Flight', + ); + return doc; + } + assert.equal( + article.querySelectorAll('h2').length, + 1, + 'One card heading follows the lesson heading', + ); + const label = article.querySelector('h2'); + assert.equal(label.textContent, content.label.value, 'Card heading uses the API label'); + assert.equal(label.lang, content.label.locale, 'Card label attributes its resolved locale'); + assert.equal(label.dir, content.label.locale === 'ar' ? 'rtl' : 'ltr'); + assert.equal( + doc.querySelector('meta[name="robots"]')?.content.includes('noindex') ?? false, + 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'); + } + assert.equal( + article.textContent.includes('Omitted optional field'), + false, + 'Absent optional field has no label or value', + ); + assert.equal( + article.textContent.includes('This lesson has no content to display yet.'), + content.fields.length === 0, + 'Ready empty is distinct from unavailable', + ); + assert.equal( + article.textContent.includes('This lesson content is currently unavailable.'), + false, + ); + return doc; +} + +async function verifyPresentation(native) { + const [first, second] = configuration.tenants; + const course = '/' + configuration.locale + '/courses/' + configuration.courseSlug; + const englishPath = course + '/lessons/' + first.lessonSlug; + const yogaPath = course + '/lessons/' + second.lessonSlug; + const turkishPath = + '/' + + second.defaultLocale + + '/courses/' + + second.defaultCourseSlug + + '/lessons/' + + second.defaultLessonSlug; + const request = (tenant, path, rsc = false) => + call(true, 3000, path, { Host: tenant.host + ':3000', ...(rsc ? { RSC: '1' } : {}) }); + const html = async (name, tenant, locale, title, path, content) => { + stage = name; + const doc = lessonDocument(await request(tenant, path), tenant, locale, title, path, content); + await checkpoint(name); + return doc; + }; + const rsc = async (name, tenant, path, title, content) => { + stage = name; + const response = await request(tenant, path, true); + safeResponse(response, 200, true); + lessonContainment(response, tenant); + for (const value of [ + title, + content.label.value, + ...content.fields.flatMap((field) => [field.label.value, field.value]), + ]) + assert.ok( + response.body.includes(value) || response.body.includes(JSON.stringify(value).slice(1, -1)), + 'Actual lesson Flight contains the projected text', + ); + await checkpoint(name); + }; + await html('presentation-english', first, 'en', first.lessonTitle, englishPath, first.content); + const englishYoga = await html( + 'presentation-yoga-en', + second, + 'en', + second.lessonTitle, + yogaPath, + second.content, + ); + assert.equal( + englishYoga + .querySelector('link[rel="alternate"][hreflang="' + second.defaultLocale + '"]') + ?.getAttribute('href'), + 'https://' + second.host + ':3000' + turkishPath, + 'Lesson alternate uses both actual translated slugs', + ); + const turkishYoga = await html( + 'presentation-yoga-tr', + second, + second.defaultLocale, + second.defaultLessonTitle, + turkishPath, + second.defaultContent, + ); + assert.equal( + turkishYoga.querySelector('link[rel="alternate"][hreflang="en"]')?.getAttribute('href'), + 'https://' + second.host + ':3000' + yogaPath, + 'Reverse alternate uses the actual English course and lesson slugs', + ); + await rsc('presentation-rsc-english', first, englishPath, first.lessonTitle, first.content); + await rsc( + 'presentation-rsc-yoga-tr', + second, + turkishPath, + second.defaultLessonTitle, + second.defaultContent, + ); + + // One running app first warms the old pin, then observes a new active revision + // and generation. The old deprecated pin must still render its old fields. + stage = 'presentation publish revision'; + await checkpoint('presentation-publish-revision'); + const pinned = await html( + 'presentation-exact-pin', + first, + 'en', + first.lessonTitle, + englishPath, + first.content, + ); + assert.equal( + pinned.body.textContent.includes(configuration.presentation.label.value), + false, + 'A current revision cannot replace an exact pin', + ); + await checkpoint('presentation-pin-revision'); + const swapped = await html( + 'presentation-swapped', + first, + 'en', + first.lessonTitle, + englishPath, + configuration.presentation, + ); + for (const field of first.content.fields) + assert.equal( + swapped.body.textContent.includes(field.value), + false, + 'New exact pin replaces the old fields', + ); + await rsc( + 'presentation-rsc-swapped', + first, + englishPath, + first.lessonTitle, + configuration.presentation, + ); + await html( + 'presentation-yoga-unchanged', + second, + second.defaultLocale, + second.defaultLessonTitle, + turkishPath, + second.defaultContent, + ); + + await checkpoint('presentation-make-empty'); + await html('presentation-empty', first, 'en', first.lessonTitle, englishPath, { + ...configuration.presentation, + fields: [], + }); + await checkpoint('presentation-make-unavailable'); + await html('presentation-unavailable', first, 'en', first.lessonTitle, englishPath, null); + await checkpoint('presentation-restore-lesson'); + await html('presentation-restored', first, 'en', first.lessonTitle, englishPath, first.content); + + for (const [name, path, locale, uiLocale] of [ + ['presentation-cross-host', englishPath, 'en', 'en'], + [ + 'presentation-protected', + '/' + + second.defaultLocale + + '/courses/' + + second.restrictedDefaultSlug + + '/lessons/' + + second.restrictedDefaultLessonSlug, + second.defaultLocale, + 'tr', + ], + ]) { + stage = name; + const refusal = await request(second, path); + safeResponse(refusal, 307); + const location = refusal.headers.location; + const target = new URL(location, 'https://' + second.host + ':3000'); + assert.equal(target.origin, 'https://' + second.host + ':3000'); + assert.equal(target.pathname, '/' + locale + '/status/not-found'); + assert.equal(target.search, ''); + const response = await request(second, target.pathname); + statusDocument(response, second, locale, uiLocale); + for (const [index, value] of [ + first.lessonTitle, + ...first.content.fields.map((field) => field.value), + ...second.restrictedDefaultCanaries, + ].entries()) { + stage = name + ' redirect containment ' + index; + // Next's redirect Flight tree can contain the request's own route segments. + // They reveal no new content; titles/body values must still be absent, and + // the destination below must omit even the original requested slug. + if (!path.split('/').includes(value)) + assert.equal( + refusal.body.includes(value), + false, + 'Refused lesson has no content Flight leak', + ); + stage = name + ' status containment ' + index; + assert.equal( + response.body.includes(value), + false, + 'Localized missing page has no lesson leak', + ); + } + await checkpoint(name); + } + assert.equal(native.exitCode, null, 'All schema and content changes use the same native process'); +} + // The real Next adapter removes Flight inputs before user middleware, then // restores them after its request-header override. Observe that final request // through a disposable admitted route, without exposing any header values. @@ -866,8 +1189,11 @@ try { assert.match(configuration.apiOrigin, /^http:\/\/127\.0\.0\.1:\d+$/); assert.match(configuration.secret, /^[A-Za-z0-9_-]{43}$/); assert.equal(configuration.tenants.length, 2); + const presentation = configuration.mode === 'presentation'; const foundation = - configuration.mode === 'foundation' || configuration.mode === 'foundation-pagination'; + configuration.mode === 'foundation' || + configuration.mode === 'foundation-pagination' || + presentation; const pagination = configuration.mode === 'foundation-pagination'; if (foundation) { catalogueCanaries = ['en', 'tr'].map( @@ -911,6 +1237,11 @@ try { if (foundation) { assert.ok(existsSync(join(coursesDirectory, 'page.tsx')), 'Copied product catalog route'); assert.ok(existsSync(join(coursesDirectory, '[slug]/page.tsx')), 'Copied product course route'); + if (presentation) + assert.ok( + existsSync(join(coursesDirectory, '[slug]/lessons/[lessonSlug]/page.tsx')), + 'Copied product lesson route', + ); } else { rmSync(join(app, 'src/app/(public)/[locale]'), { recursive: true, force: true }); // The original transport probe predates product Suspense/error boundaries. @@ -1017,7 +1348,9 @@ try { const catalog = '/' + configuration.locale + '/courses'; const course = catalog + '/' + configuration.courseSlug; - if (pagination) { + if (presentation) { + await verifyPresentation(native); + } else if (pagination) { await verifyPagination(native); } else if (foundation) { await verifyFoundation(native, nextBin); diff --git a/frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/lessons/[lessonSlug]/page.tsx b/frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/lessons/[lessonSlug]/page.tsx new file mode 100644 index 00000000..8db59c5d --- /dev/null +++ b/frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/lessons/[lessonSlug]/page.tsx @@ -0,0 +1,29 @@ +import { notFound } from 'next/navigation'; + +import { PublicLesson } from '@/components/public/lesson'; +import { PublicState } from '@/components/public/state'; +import { publicMetadata } from '@/server/public-metadata'; +import { requirePublicResource } from '@/server/public-resource'; +import { getPublicUi } from '@/server/public-ui'; + +export async function generateMetadata() { + const resource = await requirePublicResource(); + return publicMetadata(resource, await getPublicUi()); +} + +export default async function LessonPage() { + const resource = await requirePublicResource(); + const ui = await getPublicUi(); + if (resource.kind === 'failure') + return ( + <PublicState + state={resource.state} + recoveryPath={resource.request.route.path} + locale={ui.locale} + direction={ui.direction} + t={ui.t} + /> + ); + if (resource.kind !== 'lesson') notFound(); + return <PublicLesson resource={resource} ui={ui} />; +} diff --git a/frontend/apps/web/src/app/globals.css b/frontend/apps/web/src/app/globals.css index d28b6bbb..fdfc2de1 100644 --- a/frontend/apps/web/src/app/globals.css +++ b/frontend/apps/web/src/app/globals.css @@ -130,3 +130,28 @@ padding-block: 0.75rem; } } + +@layer components { + .public-lesson, + .public-lesson-content, + .public-lesson-fields { + display: grid; + gap: 1.25rem; + min-inline-size: 0; + } + .public-lesson-field { + padding-block: 1rem; + border-block-end: 1px solid var(--ls-muted); + min-inline-size: 0; + } + .public-lesson-field dt { + font-weight: 600; + } + .public-lesson-field dd { + margin-block-start: 0.5rem; + margin-inline-start: 0; + max-inline-size: 65ch; + white-space: pre-wrap; + overflow-wrap: anywhere; + } +} diff --git a/frontend/apps/web/src/components/public/lesson.test.tsx b/frontend/apps/web/src/components/public/lesson.test.tsx new file mode 100644 index 00000000..1287afef --- /dev/null +++ b/frontend/apps/web/src/components/public/lesson.test.tsx @@ -0,0 +1,406 @@ +import { render, screen, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import LessonPage, { + generateMetadata, +} from '@/app/(public)/[locale]/courses/[slug]/lessons/[lessonSlug]/page'; +import { createPublicTranslator } from '@/i18n/catalogues'; +import type { ConfiguredPublicClient } from '@/server/configured-public-client'; +import type { PublicRequest } from '@/server/public-request'; +import type { PublicResource } from '@/server/public-resource'; +import { publicRoute } from '@/server/public-route'; + +import { PublicChrome } from './chrome'; +import { PublicLesson } from './lesson'; +import { PublicState } from './state'; + +const dependencies = vi.hoisted(() => ({ + resource: vi.fn(), + ui: vi.fn(), + metadata: vi.fn(), + notFound: vi.fn(() => { + throw new Error('Not found'); + }), +})); +vi.mock('@/server/public-resource', () => ({ requirePublicResource: dependencies.resource })); +vi.mock('@/server/public-ui', () => ({ getPublicUi: dependencies.ui })); +vi.mock('@/server/public-metadata', () => ({ publicMetadata: dependencies.metadata })); +vi.mock('next/navigation', () => ({ notFound: dependencies.notFound })); + +type LessonResource = Extract<PublicResource, { kind: 'lesson' }>; +type ReadyContent = Extract<LessonResource['data']['content'], { state: 'ready' }>; +const client = { + getSite: vi.fn<ConfiguredPublicClient['getSite']>(), + getCourses: vi.fn<ConfiguredPublicClient['getCourses']>(), + getCourse: vi.fn<ConfiguredPublicClient['getCourse']>(), + getLesson: vi.fn<ConfiguredPublicClient['getLesson']>(), +}; +const fields: ReadyContent['fields'] = [ + { name: 'z_internal', label: { locale: 'en', value: 'First label' }, value: 'First value' }, + { name: 'a_internal', label: { locale: 'tr', value: 'İkinci etiket' }, value: 'İkinci değer' }, +]; + +function lesson(overrides: Partial<LessonResource['data']> = {}): LessonResource { + const locale = overrides.locale ?? 'tr-TR'; + const target = `/${locale}/courses/foundation/lessons/first`; + const request: PublicRequest = { + context: { host: 'institution.example:3000', peer: '203.0.113.7', method: 'GET', target }, + client, + site: { + displayName: 'Institution', + defaultLocale: locale, + enabledLocales: [locale], + showPlatformAttribution: false, + theme: null, + }, + locale, + route: publicRoute(target), + }; + return { + kind: 'lesson', + request, + data: { + locale, + lesson: { slug: 'first', title: 'Authored lesson title' }, + course: { slug: 'foundation', title: 'Authored course title' }, + content: { + state: 'ready', + rendererKey: 'default-card', + label: { locale: 'tr-TR', value: 'Authored content label' }, + fields, + }, + alternates: [], + ...overrides, + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('public lesson presentation', () => { + it('renders authored headings and ordered definition pairs without exposing field names', () => { + const resource = lesson(); + const ui = createPublicTranslator('tr-TR'); + const { container } = render(<PublicLesson resource={resource} ui={ui} />); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Authored lesson title'); + expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('Authored content label'); + expect(screen.getAllByRole('heading')).toHaveLength(2); + const list = container.querySelector('dl'); + expect(list).not.toBeNull(); + expect(Array.from(list!.querySelectorAll('dt, dd'), (node) => node.textContent)).toEqual([ + 'First label', + 'First value', + 'İkinci etiket', + 'İkinci değer', + ]); + expect(screen.getByText('First label')).toHaveAttribute('lang', 'en'); + expect(screen.getByText('İkinci etiket')).toHaveAttribute('lang', 'tr'); + expect(container.textContent).not.toMatch(/z_internal|a_internal/); + expect(screen.getByRole('link')).toHaveAttribute('href', '/tr-TR/courses/foundation'); + expect(screen.getByRole('link')).toHaveTextContent('Authored course title'); + expect(container.querySelector('main')).toBeNull(); + for (const operation of Object.values(client)) expect(operation).not.toHaveBeenCalled(); + }); + + it('preserves a present empty string while displaying only API-supplied optional fields', () => { + const resource = lesson({ + content: { + state: 'ready', + rendererKey: 'default-card', + label: { locale: 'en', value: 'Content' }, + fields: [ + { name: 'present_empty', label: { locale: 'en', value: 'Empty field' }, value: '' }, + ], + }, + }); + const { container } = render( + <PublicLesson resource={resource} ui={createPublicTranslator('en')} />, + ); + expect(container.querySelectorAll('dt')).toHaveLength(1); + expect(container.querySelectorAll('dd')).toHaveLength(1); + expect(container.querySelector('dd')).toBeEmptyDOMElement(); + expect(screen.queryByText('This lesson has no content to display yet.')).toBeNull(); + expect(container.textContent).not.toContain('present_empty'); + }); + + it.each([ + [ + 'en', + 'This lesson has no content to display yet.', + 'This lesson content is currently unavailable.', + ], + [ + 'tr-TR', + 'Bu derste henüz görüntülenecek içerik yok.', + 'Bu dersin içeriği şu anda kullanılamıyor.', + ], + ])( + 'distinguishes empty ready content from unavailable content in %s UI', + (locale, empty, unavailable) => { + const ui = createPublicTranslator(locale); + const resource = lesson({ + content: { + state: 'ready', + rendererKey: 'default-card', + label: { locale: 'en', value: 'Content' }, + fields: [], + }, + }); + const { container, rerender } = render(<PublicLesson resource={resource} ui={ui} />); + expect(screen.getByText(empty)).toHaveAttribute('lang', ui.locale); + expect(screen.getByText(empty)).toHaveAttribute('dir', ui.direction); + expect(screen.queryByText(unavailable)).toBeNull(); + expect(container.querySelector('dl')).toBeNull(); + expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('Content'); + rerender(<PublicLesson resource={lesson({ content: { state: 'unavailable' } })} ui={ui} />); + expect(screen.getByText(unavailable)).toHaveAttribute('lang', ui.locale); + expect(screen.queryByText(empty)).toBeNull(); + expect(screen.queryByRole('heading', { level: 2 })).toBeNull(); + expect(container.querySelector('dl')).toBeNull(); + }, + ); + + it.each(['unavailable', 'unknown'] as const)( + 'short-circuits %s content before reading labels or fields and emits no renderer diagnostics', + (state) => { + const content = { state: 'ready', rendererKey: 'future-private-renderer' } as ReadyContent; + const readLabel = vi.fn(() => { + throw new Error('Unsupported label was read'); + }); + const readFields = vi.fn(() => { + throw new Error('Unsupported fields were read'); + }); + Object.defineProperty(content, 'label', { get: readLabel }); + Object.defineProperty(content, 'fields', { get: readFields }); + const unsupported = + state === 'unavailable' ? { ...content, state: 'unavailable' as const } : content; + Object.defineProperty(unsupported, 'label', { get: readLabel }); + Object.defineProperty(unsupported, 'fields', { get: readFields }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { container } = render( + <PublicLesson + resource={lesson({ content: unsupported })} + ui={createPublicTranslator('en')} + />, + ); + expect(screen.getByText('This lesson content is currently unavailable.')).toBeInTheDocument(); + expect(container.textContent).not.toContain('future-private-renderer'); + expect(container.querySelector('dl')).toBeNull(); + expect(readLabel).not.toHaveBeenCalled(); + expect(readFields).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }, + ); + + it('keeps RTL content, resolved labels, and English UI fallback languages independent', () => { + const resource = lesson({ + locale: 'ar', + lesson: { slug: 'first', title: 'عنوان الدرس' }, + content: { + state: 'ready', + rendererKey: 'default-card', + label: { locale: 'tr-TR', value: 'İçerik başlığı' }, + fields: [ + { name: 'internal', label: { locale: 'fa', value: 'برچسب' }, value: 'قيمة الدرس' }, + ], + }, + }); + const ui = createPublicTranslator('ar'); + const { container, rerender } = render(<PublicLesson resource={resource} ui={ui} />); + const article = screen.getByRole('article'); + expect(article).toHaveAttribute('lang', 'ar'); + expect(article).toHaveAttribute('dir', 'rtl'); + expect(screen.getByRole('heading', { level: 1 }).closest('[lang]')).toBe(article); + expect(screen.getByRole('heading', { level: 2 })).toHaveAttribute('lang', 'tr-TR'); + expect(screen.getByRole('heading', { level: 2 })).toHaveAttribute('dir', 'ltr'); + expect(screen.getByText('برچسب')).toHaveAttribute('lang', 'fa'); + expect(screen.getByText('برچسب')).toHaveAttribute('dir', 'rtl'); + expect(container.querySelector('dd')?.closest('[lang]')).toBe(article); + rerender( + <PublicLesson + resource={{ ...resource, data: { ...resource.data, content: { state: 'unavailable' } } }} + ui={ui} + />, + ); + const fallback = screen.getByText('This lesson content is currently unavailable.'); + expect(fallback).toHaveAttribute('lang', 'en'); + expect(fallback).toHaveAttribute('dir', 'ltr'); + expect(screen.getByRole('link')).toHaveAttribute('href', '/ar/courses/foundation'); + }); + + it('uses request locale for the backlink independently of payload and UI locales', () => { + const resource = lesson({ locale: 'ar' }); + render( + <PublicLesson + resource={{ ...resource, request: { ...resource.request, locale: 'tr-TR' } }} + ui={createPublicTranslator('en')} + />, + ); + expect(screen.getByRole('article')).toHaveAttribute('lang', 'ar'); + expect(screen.getByRole('link')).toHaveAttribute('href', '/tr-TR/courses/foundation'); + }); + + it.each([ + 'javascript:alert(1)', + 'data:text/html,secret', + 'https://example.invalid', + 'bad/slug', + '%66oundation', + '', + '..', + ])('keeps an unsafe course slug %j out of navigation', (slug) => { + const { container } = render( + <PublicLesson + resource={lesson({ course: { slug, title: 'Course' } })} + ui={createPublicTranslator('en')} + />, + ); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Authored lesson title'); + expect(screen.queryByRole('link')).toBeNull(); + expect(container.textContent).not.toContain(slug || 'Course'); + }); + + it.each([ + '<script>alert("private")</script>', + '<img src="https://example.invalid/private" onerror="alert(1)">', + 'javascript:alert(1)', + 'data:text/html,<script>alert(1)</script>', + 'http://example.invalid/private', + 'https://example.invalid/private', + 'long'.repeat(600), + ])('renders authored text %j without HTML, linkification or media attributes', (text) => { + const { container } = render( + <PublicLesson + resource={lesson({ + lesson: { slug: 'first', title: text }, + course: { slug: 'foundation', title: text }, + content: { + state: 'ready', + rendererKey: 'default-card', + label: { locale: 'en', value: text }, + fields: [{ name: 'internal', label: { locale: 'en', value: text }, value: text }], + }, + })} + ui={createPublicTranslator('en')} + />, + ); + expect(container.querySelector('dd')).toHaveTextContent(text); + expect(container.querySelector('dt')).toHaveTextContent(text); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(text); + expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(text); + expect(screen.getAllByRole('link')).toHaveLength(1); + expect(screen.getByRole('link')).toHaveAttribute('href', '/tr-TR/courses/foundation'); + expect( + container.querySelector( + 'script, img, iframe, audio, video, source, object, embed, [src], [onerror], [onclick]', + ), + ).toBeNull(); + expect( + container.querySelector( + 'a[href^="javascript:"], a[href^="data:"], a[href^="http:"] , a[href^="https:"]', + ), + ).toBeNull(); + }); + + it.each(['ready', 'empty', 'unavailable', 'unknown'] as const)( + 'retains one chrome-owned main and sequential headings for %s content', + (state) => { + const resource = lesson(); + if (resource.data.content.state !== 'ready') throw new Error('Invalid fixture'); + const content = + state === 'unavailable' + ? { state: 'unavailable' as const } + : { + ...resource.data.content, + fields: state === 'empty' ? [] : fields, + rendererKey: state === 'unknown' ? 'future' : 'default-card', + }; + const ui = createPublicTranslator('tr-TR'); + render( + <PublicChrome + site={resource.request.site} + contentLocale="tr-TR" + uiLocale={ui.locale} + uiDirection={ui.direction} + t={ui.t} + > + <PublicLesson resource={{ ...resource, data: { ...resource.data, content } }} ui={ui} /> + </PublicChrome>, + ); + const main = screen.getByRole('main'); + expect(main).toHaveAttribute('id', 'main-content'); + expect(main).toHaveAttribute('tabindex', '-1'); + expect(screen.getByRole('link', { name: 'İçeriğe geç' })).toHaveAttribute( + 'href', + '#main-content', + ); + expect( + within(main) + .getAllByRole('heading') + .map((node) => node.tagName), + ).toEqual(state === 'ready' || state === 'empty' ? ['H1', 'H2'] : ['H1']); + }, + ); +}); + +// Dispatch/admission units only; production HTML/RSC is covered by the real fixture. +describe('lesson route dispatch', () => { + it('passes the admitted resource and UI to the synchronous view and metadata projection', async () => { + const resource = lesson(); + const ui = createPublicTranslator('en'); + dependencies.resource.mockResolvedValue(resource); + dependencies.ui.mockResolvedValue(ui); + dependencies.metadata.mockReturnValue({ title: 'Projected lesson' }); + expect(await LessonPage()).toMatchObject({ type: PublicLesson, props: { resource, ui } }); + expect(await generateMetadata()).toEqual({ title: 'Projected lesson' }); + expect(dependencies.metadata).toHaveBeenCalledOnce(); + expect(dependencies.metadata).toHaveBeenCalledWith(resource, ui); + for (const operation of Object.values(client)) expect(operation).not.toHaveBeenCalled(); + }); + + it.each(['invalid_cursor', 'rate_limited', 'unavailable'] as const)( + 'selects the controlled %s state before lesson markup', + async (state) => { + const request = lesson().request; + const ui = createPublicTranslator('tr-TR'); + dependencies.resource.mockResolvedValue({ kind: 'failure', request, state }); + dependencies.ui.mockResolvedValue(ui); + expect(await LessonPage()).toMatchObject({ + type: PublicState, + props: { + state, + recoveryPath: request.route.path, + locale: ui.locale, + direction: ui.direction, + t: ui.t, + }, + }); + expect(dependencies.notFound).not.toHaveBeenCalled(); + }, + ); + + it.each([LessonPage, generateMetadata])( + 'honors a loader refusal before UI or metadata work', + async (consumer) => { + dependencies.resource.mockRejectedValue(new Error('Admission refused')); + await expect(consumer()).rejects.toThrow('Admission refused'); + expect(dependencies.ui).not.toHaveBeenCalled(); + expect(dependencies.metadata).not.toHaveBeenCalled(); + }, + ); + + it('refuses a mismatched resource kind', async () => { + dependencies.resource.mockResolvedValue({ kind: 'status', request: lesson().request }); + dependencies.ui.mockResolvedValue(createPublicTranslator('en')); + await expect(LessonPage()).rejects.toThrow('Not found'); + expect(dependencies.notFound).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/apps/web/src/components/public/lesson.tsx b/frontend/apps/web/src/components/public/lesson.tsx new file mode 100644 index 00000000..dd1ea1fc --- /dev/null +++ b/frontend/apps/web/src/components/public/lesson.tsx @@ -0,0 +1,56 @@ +import type { PublicTranslate } from '@/i18n/catalogues'; +import { textDirection } from '@/i18n/locale'; +import type { TextDirection } from '@/i18n/locale'; +import { coursePath } from '@/server/public-paths'; +import type { PublicResource } from '@/server/public-resource'; + +type LessonResource = Extract<PublicResource, { kind: 'lesson' }>; +type PublicLessonProps = { + readonly resource: LessonResource; + readonly ui: { + readonly locale: string; + readonly direction: TextDirection; + readonly t: PublicTranslate; + }; +}; + +/** Renders the API's ordered text fields without introducing active content sinks. */ +export function PublicLesson({ resource, ui }: PublicLessonProps) { + const { data } = resource; + const { content } = data; + const backPath = coursePath(resource.request.locale ?? '', data.course.slug); + + return ( + <article className="public-lesson" lang={data.locale} dir={textDirection(data.locale)}> + {backPath && <a href={backPath}>{data.course.title}</a>} + <h1>{data.lesson.title}</h1> + {content.state !== 'ready' || content.rendererKey !== 'default-card' ? ( + <p className="public-muted" lang={ui.locale} dir={ui.direction}> + {ui.t('lesson.unavailable')} + </p> + ) : ( + <section className="public-lesson-content"> + <h2 lang={content.label.locale} dir={textDirection(content.label.locale)}> + {content.label.value} + </h2> + {content.fields.length === 0 ? ( + <p className="public-muted" lang={ui.locale} dir={ui.direction}> + {ui.t('lesson.empty')} + </p> + ) : ( + <dl className="public-lesson-fields"> + {content.fields.map((field) => ( + <div className="public-lesson-field" key={field.name}> + <dt lang={field.label.locale} dir={textDirection(field.label.locale)}> + {field.label.value} + </dt> + <dd>{field.value}</dd> + </div> + ))} + </dl> + )} + </section> + )} + </article> + ); +} diff --git a/frontend/apps/web/src/server/public-metadata.test.ts b/frontend/apps/web/src/server/public-metadata.test.ts index cc9bd3eb..8075fcc3 100644 --- a/frontend/apps/web/src/server/public-metadata.test.ts +++ b/frontend/apps/web/src/server/public-metadata.test.ts @@ -65,6 +65,25 @@ function course(): Extract<Resource, { kind: 'course' }> { }; } +function lesson(): Extract<Resource, { kind: 'lesson' }> { + return { + kind: 'lesson', + request: request('/tr/courses/temel/lessons/giris'), + data: { + locale: 'tr', + course: { slug: 'temel', title: 'Temel kursu' }, + lesson: { slug: 'giris', title: 'Giriş' }, + content: { + state: 'ready', + rendererKey: 'default-card', + label: { value: 'Ders kartı', locale: 'tr' }, + fields: [], + }, + alternates: [{ locale: 'en', courseSlug: 'foundation', lessonSlug: 'introduction' }], + }, + }; +} + const ui = createPublicTranslator('tr'); describe('public metadata projection', () => { @@ -189,6 +208,95 @@ describe('public metadata projection', () => { for (const operation of Object.values(resource.request.client)) expect(operation).not.toHaveBeenCalled(); }); + + it('uses actual eligible lesson and course translations, with the current canonical self', () => { + const resource = lesson(); + const metadata = publicMetadata(resource, ui); + expect(metadata).toMatchObject({ + title: 'Giriş', + robots: { index: true, follow: true }, + alternates: { + canonical: 'https://school.example:3000/tr/courses/temel/lessons/giris', + languages: { + tr: 'https://school.example:3000/tr/courses/temel/lessons/giris', + en: 'https://school.example:3000/en/courses/foundation/lessons/introduction', + }, + }, + openGraph: { title: 'Giriş', locale: 'tr', alternateLocale: ['en'] }, + }); + expect(metadata).not.toHaveProperty('description'); + expect(JSON.stringify(metadata)).not.toMatch(/\/en\/courses\/temel|\/ar\//); + for (const operation of Object.values(resource.request.client)) + expect(operation).not.toHaveBeenCalled(); + }); + + it('ignores lesson query/UI/payload locale when constructing admitted resource URLs', () => { + const original = lesson(); + const resource = { + ...original, + request: { + ...original.request, + context: { + ...original.request.context, + target: original.request.context.target + '?locale=ar&host=evil.example&cursor=private', + }, + }, + }; + resource.data.locale = 'ar'; + const metadata = publicMetadata(resource, createPublicTranslator('en')); + expect(metadata.robots).toEqual({ index: true, follow: true }); + expect(metadata.alternates?.canonical).toBe( + 'https://school.example:3000/tr/courses/temel/lessons/giris', + ); + expect(metadata.openGraph).toMatchObject({ locale: 'tr' }); + expect(JSON.stringify(metadata)).not.toMatch(/private|evil|\?/); + }); + + it.each(['unavailable', 'unknown'] as const)( + 'keeps an eligible %s lesson noindex without losing its canonical identity', + (state) => { + const resource = lesson(); + resource.data.content = + state === 'unavailable' + ? { state: 'unavailable' } + : { + state: 'ready', + rendererKey: 'future-composite', + label: { value: 'Unused', locale: 'en' }, + fields: [], + }; + expect(publicMetadata(resource, ui)).toMatchObject({ + title: 'Giriş', + robots: { index: false, follow: true }, + alternates: { canonical: 'https://school.example:3000/tr/courses/temel/lessons/giris' }, + }); + }, + ); + + it('keeps the current lesson self when no other locale is eligible and refuses unsafe alternatives', () => { + const resource = lesson(); + resource.data.alternates = [ + { locale: 'en', courseSlug: '../private', lessonSlug: 'introduction' }, + { locale: 'en', courseSlug: 'foundation', lessonSlug: '%69ntroduction' }, + { locale: 'tr', courseSlug: 'wrong', lessonSlug: 'wrong' }, + { locale: 'fr', courseSlug: 'fondation', lessonSlug: 'introduction' }, + ]; + expect(publicMetadata(resource, ui).alternates?.languages).toEqual({ + tr: 'https://school.example:3000/tr/courses/temel/lessons/giris', + }); + }); + + it.each(['../private', 'https://evil.example', 'giris?token=private', '%67iris'])( + 'refuses an invalid current lesson slug %s before emitting resource URLs', + (slug) => { + const resource = lesson(); + resource.data.lesson.slug = slug; + const metadata = publicMetadata(resource, ui); + expect(metadata).not.toHaveProperty('alternates'); + expect(metadata).not.toHaveProperty('openGraph'); + expect(metadata.robots).toEqual({ index: false, follow: false }); + }, + ); }); describe('metadata rejects unsafe URL inputs', () => { @@ -317,26 +425,10 @@ describe('controlled noindex metadata states', () => { }); }); - it('keeps scaffold and pending lesson implementation noindex', () => { - const resources: Resource[] = [ - { kind: 'scaffold', request: request('/studio', null) }, - { - kind: 'lesson', - request: request('/tr/courses/temel/lessons/giris'), - data: { - locale: 'tr', - course: { slug: 'temel', title: 'Temel' }, - lesson: { slug: 'giris', title: 'Giriş' }, - content: { state: 'unavailable' }, - alternates: [], - }, - }, - ]; - for (const resource of resources) { - expect(publicMetadata(resource, ui)).toEqual({ - title: 'School', - robots: { index: false, follow: false }, - }); - } + it('keeps the admitted scaffold noindex', () => { + expect(publicMetadata({ kind: 'scaffold', request: request('/studio', null) }, ui)).toEqual({ + title: 'School', + robots: { index: false, follow: false }, + }); }); }); diff --git a/frontend/apps/web/src/server/public-metadata.ts b/frontend/apps/web/src/server/public-metadata.ts index 2704afa8..f6d9a702 100644 --- a/frontend/apps/web/src/server/public-metadata.ts +++ b/frontend/apps/web/src/server/public-metadata.ts @@ -4,7 +4,7 @@ import type { PublicTranslate } from '@/i18n/catalogues'; import type { TextDirection } from '@/i18n/locale'; import { publicRedirect } from './public-entry'; -import { catalogPath, coursePath } from './public-paths'; +import { catalogPath, coursePath, lessonPath } from './public-paths'; import type { requirePublicResource } from './public-resource'; type Resource = Awaited<ReturnType<typeof requirePublicResource>>; @@ -57,29 +57,40 @@ export function publicMetadata(resource: Resource, { t }: PublicUi): Metadata { } return unavailable(t); } - // Lesson metadata is extended with the actual lesson page in P02d-6 Step 3. - if (resource.kind === 'lesson' || resource.kind === 'scaffold') { + if (resource.kind === 'scaffold') { return { title: resource.request.site.displayName, robots: { index: false, follow: false } }; } - const { request, pagination } = resource; + const { request } = resource; const { locale, site, context } = request; if (locale === null || !site.enabledLocales.includes(locale)) return unavailable(t); const path = resource.kind === 'catalog' ? catalogPath(locale) - : coursePath(locale, resource.data.course.slug); + : resource.kind === 'course' + ? coursePath(locale, resource.data.course.slug) + : lessonPath(locale, resource.data.course.slug, resource.data.lesson.slug); const canonical = publicUrl(context.host, path); if (canonical === null) return unavailable(t); - const title = resource.kind === 'catalog' ? t('catalog.title') : resource.data.course.title; + const title = + resource.kind === 'catalog' + ? t('catalog.title') + : resource.kind === 'course' + ? resource.data.course.title + : resource.data.lesson.title; const summary = resource.kind === 'course' ? resource.data.course.summary : null; const description = summary?.trim() ? summary : undefined; const candidates = resource.kind === 'catalog' ? site.enabledLocales.map((language) => [language, catalogPath(language)] as const) - : resource.data.alternates.map( - ({ locale: language, slug }) => [language, coursePath(language, slug)] as const, - ); + : resource.kind === 'course' + ? resource.data.alternates.map( + ({ locale: language, slug }) => [language, coursePath(language, slug)] as const, + ) + : resource.data.alternates.map( + ({ locale: language, courseSlug, lessonSlug }) => + [language, lessonPath(language, courseSlug, lessonSlug)] as const, + ); // Resource alternates intentionally exclude the current API locale. const languages: Record<string, string> = { [locale]: canonical }; for (const [language, alternatePath] of candidates) { @@ -90,7 +101,14 @@ export function publicMetadata(resource: Resource, { t }: PublicUi): Metadata { return { title, ...(description === undefined ? {} : { description }), - robots: { index: !pagination.isPaginated, follow: true }, + robots: { + index: + resource.kind === 'lesson' + ? resource.data.content.state === 'ready' && + resource.data.content.rendererKey === 'default-card' + : !resource.pagination.isPaginated, + follow: true, + }, alternates: { canonical, ...(Object.keys(languages).length ? { languages } : {}) }, openGraph: { type: 'website', diff --git a/frontend/apps/web/src/server/public-resource.test.ts b/frontend/apps/web/src/server/public-resource.test.ts index 0b7d9325..1b8d60bd 100644 --- a/frontend/apps/web/src/server/public-resource.test.ts +++ b/frontend/apps/web/src/server/public-resource.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import type { ApiResult, AppError } from '@learnstack/sdk/server'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ConfiguredPublicClient } from './configured-public-client'; import { getPublicRequest } from './public-request'; @@ -106,6 +106,8 @@ beforeEach(() => { }); }); +afterEach(() => vi.restoreAllMocks()); + describe('public resource dispatch after shared live admission', () => { it('refuses before any content call when request admission fails', async () => { getRequest.mockResolvedValue(null); @@ -177,6 +179,67 @@ describe('public resource dispatch after shared live admission', () => { expect(client.getCourse).not.toHaveBeenCalled(); }); + it('diagnoses an unsupported composite once without its key, label, names or values', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const admitted = request('/tr-TR/courses/foundation/lessons/intro'); + getRequest.mockResolvedValue(admitted); + client.getLesson.mockResolvedValue({ + kind: 'success', + status: 200, + data: { + ...lesson, + content: { + state: 'ready', + rendererKey: 'PRIVATE_COMPOSITE', + label: { locale: 'en', value: 'PRIVATE_LABEL' }, + fields: [ + { + name: 'PRIVATE_FIELD', + label: { locale: 'en', value: 'PRIVATE_FIELD_LABEL' }, + value: 'PRIVATE_VALUE', + }, + ], + }, + }, + }); + expect((await getPublicResource()).kind).toBe('lesson'); + expect(client.getLesson).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith('Public lesson presentation unavailable', { + state: 'unsupported_renderer', + count: 1, + }); + expect(JSON.stringify(warn.mock.calls)).not.toMatch( + /PRIVATE|203\.0|foundation|intro|Institution/, + ); + }); + + it.each(['ready', 'unavailable'] as const)( + 'does not duplicate API diagnostics for %s content', + async (state) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + getRequest.mockResolvedValue(request('/tr-TR/courses/foundation/lessons/intro')); + client.getLesson.mockResolvedValue({ + kind: 'success', + status: 200, + data: { + ...lesson, + content: + state === 'unavailable' + ? { state: 'unavailable' } + : { + state: 'ready', + rendererKey: 'default-card', + label: { locale: 'en', value: 'Card' }, + fields: [], + }, + }, + }); + expect((await getPublicResource()).kind).toBe('lesson'); + expect(warn).not.toHaveBeenCalled(); + }, + ); + it.each([ '/tr-TR/courses?cursor=', '/tr-TR/courses?cursor=a&cursor=b', diff --git a/frontend/apps/web/src/server/public-resource.ts b/frontend/apps/web/src/server/public-resource.ts index f3d4aa68..f2351b22 100644 --- a/frontend/apps/web/src/server/public-resource.ts +++ b/frontend/apps/web/src/server/public-resource.ts @@ -60,9 +60,16 @@ export const getPublicResource = requestMemo(async (): Promise<PublicResource> = if (route.kind === 'scaffold') return { kind: 'scaffold', request }; if (route.kind === 'lesson') { const result = await client.getLesson({ slug: route.slug, lessonSlug: route.lessonSlug }); - return result.kind === 'success' - ? { kind: 'lesson', request, data: result.data } - : failure(result, request); + if (result.kind !== 'success') return failure(result, request); + if (result.data.content.state === 'ready' && result.data.content.rendererKey !== 'default-card') + // This memoized resource read is shared by metadata, layout and page. + // The API diagnoses unavailable definitions; only an unsupported future + // composite needs a renderer diagnostic, without its key or field values. + console.warn('Public lesson presentation unavailable', { + state: 'unsupported_renderer', + count: 1, + }); + return { kind: 'lesson', request, data: result.data }; } const pagination = publicPagination(context.target, route.kind === 'course'); if (!pagination) return { kind: 'failure', request, state: 'invalid_cursor' }; diff --git a/frontend/apps/web/src/test/accessibility-lint.test.ts b/frontend/apps/web/src/test/accessibility-lint.test.ts new file mode 100644 index 00000000..40a7cc5e --- /dev/null +++ b/frontend/apps/web/src/test/accessibility-lint.test.ts @@ -0,0 +1,149 @@ +// @vitest-environment node +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { LegacyESLint } from 'eslint/use-at-your-own-risk'; +import { describe, expect, it } from 'vitest'; + +// Standards 16 and accepted P02d-6 G43 require the applicable recommended +// jsx-a11y rules to block the real web app's JSX. This literal census means a +// plugin upgrade or config edit cannot silently change what the guard covers. +const cases = [ + ['alt-text', '<img src="/photo.png" />', '<img src="/photo.png" alt="Mountains" />'], + ['anchor-has-content', '<a href="/course" />', '<a href="/course">Course</a>'], + ['anchor-is-valid', '<a href="#">Course</a>', '<a href="/course">Course</a>'], + [ + 'aria-activedescendant-has-tabindex', + '<div role="listbox" aria-activedescendant="option" />', + '<div role="listbox" aria-activedescendant="option" tabIndex={0} />', + ], + ['aria-props', '<div aria-typo="x" />', '<div aria-label="Item" />'], + ['aria-proptypes', '<div aria-hidden="maybe" />', '<div aria-hidden="true" />'], + ['aria-role', '<div role="unknown" />', '<div role="main" />'], + [ + 'aria-unsupported-elements', + '<meta aria-label="Item" />', + '<meta name="theme-color" content="#fff" />', + ], + ['autocomplete-valid', '<input autoComplete="fake" />', '<input autoComplete="email" />'], + [ + 'click-events-have-key-events', + '<div onClick={() => {}}>Open</div>', + '<button type="button" onClick={() => {}}>Open</button>', + ], + ['heading-has-content', '<h2 />', '<h2>Courses</h2>'], + ['html-has-lang', '<html><body /></html>', '<html lang="en"><body /></html>'], + ['iframe-has-title', '<iframe src="/media" />', '<iframe src="/media" title="Lesson video" />'], + [ + 'img-redundant-alt', + '<img src="/photo.png" alt="photo of mountains" />', + '<img src="/photo.png" alt="Mountains" />', + ], + [ + 'interactive-supports-focus', + '<div role="button" onClick={() => {}}>Open</div>', + '<button type="button" onClick={() => {}}>Open</button>', + ], + [ + 'label-has-associated-control', + '<label>Name</label>', + '<><label htmlFor="name">Name</label><input id="name" /></>', + ], + [ + 'media-has-caption', + '<video src="/clip.mp4" />', + '<video src="/clip.mp4"><track kind="captions" src="/captions.vtt" /></video>', + ], + [ + 'mouse-events-have-key-events', + '<div onMouseOver={() => {}}>Details</div>', + '<div onMouseOver={() => {}} onFocus={() => {}}>Details</div>', + ], + ['no-access-key', '<button accessKey="x">Open</button>', '<button type="button">Open</button>'], + ['no-autofocus', '<input autoFocus />', '<input aria-label="Search" />'], + ['no-distracting-elements', '<marquee>News</marquee>', '<p>News</p>'], + [ + 'no-interactive-element-to-noninteractive-role', + '<button role="article">Open</button>', + '<button type="button">Open</button>', + ], + [ + 'no-noninteractive-element-interactions', + '<article onClick={() => {}}>Open</article>', + '<article>Open</article>', + ], + ['no-noninteractive-element-to-interactive-role', '<h2 role="button">Open</h2>', '<h2>Open</h2>'], + ['no-noninteractive-tabindex', '<article tabIndex={0}>Open</article>', '<article>Open</article>'], + [ + 'no-redundant-roles', + '<button role="button">Open</button>', + '<button type="button">Open</button>', + ], + [ + 'no-static-element-interactions', + '<div onClick={() => {}}>Open</div>', + '<button type="button" onClick={() => {}}>Open</button>', + ], + [ + 'role-has-required-aria-props', + '<div role="checkbox" tabIndex={0}>Accept</div>', + '<div role="checkbox" aria-checked="false" tabIndex={0}>Accept</div>', + ], + [ + 'role-supports-aria-props', + '<button aria-checked="true">Open</button>', + '<button type="button" aria-label="Open">Open</button>', + ], + ['scope', '<div scope="col" />', '<th scope="col">Course</th>'], + ['tabindex-no-positive', '<div tabIndex={1} />', '<button type="button">Open</button>'], +] as const; + +describe('actual web JSX accessibility lint config', () => { + const app = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + const filePath = join(app, 'src', 'accessibility-probe.tsx'); + const eslint = new LegacyESLint({ + cwd: app, + resolvePluginsRelativeTo: resolve(app, '../../packages/config/eslint'), + // Planted source strings have no tsconfig file; these rules inspect syntax. + overrideConfig: { parserOptions: { project: null } }, + }); + + const lint = async (jsx: string) => { + const results = await eslint.lintText(`export const Probe = () => (${jsx});\n`, { filePath }); + expect(results).toHaveLength(1); // An ignored probe checks nothing. + const messages = results[0]!.messages; + expect(messages.filter((message) => !message.ruleId || message.fatal)).toEqual([]); + return messages.filter((message) => message.ruleId?.startsWith('jsx-a11y/')); + }; + + it('keeps the complete active recommended rule census blocking', async () => { + const config = await eslint.calculateConfigForFile(filePath); + const enabled = Object.entries(config.rules) + .filter( + ([name, setting]) => + name.startsWith('jsx-a11y/') && + (Array.isArray(setting) ? setting[0] : setting) !== 'off' && + (Array.isArray(setting) ? setting[0] : setting) !== 0, + ) + .map(([name]) => name); + const expected = cases.map(([name]) => `jsx-a11y/${name}`); + + expect(expected.length).toBeGreaterThan(0); + expect(new Set(expected).size).toBe(expected.length); + expect(enabled.sort()).toEqual([...expected].sort()); + for (const name of enabled) { + const setting = config.rules[name]; + expect(Array.isArray(setting) ? setting[0] : setting, name).toBe('error'); + } + }); + + it.each(cases)('blocks jsx-a11y/%s and accepts its clean control', async (name, dirty, clean) => { + const violation = await lint(dirty); + expect(violation, name).toEqual( + expect.arrayContaining([ + expect.objectContaining({ ruleId: `jsx-a11y/${name}`, severity: 2 }), + ]), + ); + expect(await lint(clean), `${name} clean control`).toEqual([]); + }); +}); From 3ac4ee18bb8e97ca0399181d4dbb9b3137ae0f11 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 01:20:47 +0300 Subject: [PATCH 12/58] fix(public): align failure recovery with its catalog label Send unavailable course and lesson recovery to the admitted catalog, while retaining same-route cursor reset. Record the delivered narrow text-renderer proof and the first independent Step 3 review round. ADR: 0027, 0051 --- docs/roadmap/phase-02d-walking-skeleton.md | 19 ++++++++++++++ .../21-architecture-tests-catalogue.md | 11 +++++--- .../[slug]/lessons/[lessonSlug]/page.tsx | 7 +++++- .../(public)/[locale]/courses/[slug]/page.tsx | 7 +++++- .../web/src/components/public/lesson.test.tsx | 25 +++++++++++++++---- 5 files changed, 58 insertions(+), 11 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index fd156b3e..0e462e9b 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4412,6 +4412,25 @@ closure, DEBUG containment and development HMR. The solution Release build passe with zero warnings/errors. No browser or manual accessibility pass is claimed; full product/manual proof remains Step 4. +#### Step 3 review round 1 (2026-10-10) + +Fresh GPT-6-astra xhigh runtime/security and GPT-6.1-sol xhigh proof/corpus agents +review `c250677..0be38e8` independently. No Blocker/Major is found. The runtime +review confirms a misleading recovery destination: the translated catalog anchor +reloads the failed lesson. The lesson route and the adjacent course route now +send unavailable/rate-limited recovery to the admitted locale's catalog; invalid +cursor reset retains its original route. Dispatch controls render the synchronous +state and assert translated label and destination together. + +The proof reviewer independently confirms a stale narrow ADR-0051 catalogue row. +It now names the delivered component and actual presentation fixture as +Implemented, with its published counts reconciled. The five broader P6 rules stay Registered +and Accessibility stays Adopted pending Step 4. Each reviewer passes 164 focused +frontend cases, zero failures/skips; neither runs native/Docker or manual checks. +The root passes 167 focused frontend cases, five Release corpus cases, workspace +typecheck and diff checks after the fixes. A fresh second round follows before +Step 4. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 5ec71ad4..f603c403 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -130,8 +130,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. -**159 rules in this catalogue are Implemented, and 107 of them are in that assembly.** -The other 52 are no less binding, and most could not live there. The table says where +**160 rules in this catalogue are Implemented, and 107 of them are in that assembly.** +The other 53 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. @@ -494,8 +494,11 @@ otherwise). Public descriptors are projected only after P02d-4 content eligibility. - **Source:** [ADR-0051](../decisions/0051-ordered-text-card-presentation.md). - **Type:** frontend component and public-boundary tests. **Kind:** behavioural. -- **Status:** **Registered**; P02d-4 delivers the eligible public projection; - the P02d-6 renderer is not delivered. +- **Status:** **Implemented** — P02d-6 Step 3, `PublicServerRenderingTests`, + `frontend/apps/web/src/components/public/lesson.test.tsx` and the unchanged + product-route presentation checks in `scripts/verify-public-rendering.mjs`. + P02d-4 supplies eligible descriptors; Step 3 proves ordered escaped text, + unsupported fallbacks and exact schema changes through the production renderer. - **Phase:** 02d (P02d-4/P02d-6). #### `JsonSchema_Net_Types_NotImportedOutsideInfrastructure` diff --git a/frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/lessons/[lessonSlug]/page.tsx b/frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/lessons/[lessonSlug]/page.tsx index 8db59c5d..fd022f25 100644 --- a/frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/lessons/[lessonSlug]/page.tsx +++ b/frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/lessons/[lessonSlug]/page.tsx @@ -3,6 +3,7 @@ import { notFound } from 'next/navigation'; import { PublicLesson } from '@/components/public/lesson'; import { PublicState } from '@/components/public/state'; import { publicMetadata } from '@/server/public-metadata'; +import { catalogPath } from '@/server/public-paths'; import { requirePublicResource } from '@/server/public-resource'; import { getPublicUi } from '@/server/public-ui'; @@ -18,7 +19,11 @@ export default async function LessonPage() { return ( <PublicState state={resource.state} - recoveryPath={resource.request.route.path} + recoveryPath={ + resource.state === 'invalid_cursor' + ? resource.request.route.path + : (catalogPath(resource.request.locale ?? '') ?? '/') + } locale={ui.locale} direction={ui.direction} t={ui.t} diff --git a/frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/page.tsx b/frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/page.tsx index b417c44e..fa742edc 100644 --- a/frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/page.tsx +++ b/frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/page.tsx @@ -3,6 +3,7 @@ import { notFound } from 'next/navigation'; import { PublicCourse } from '@/components/public/course'; import { PublicState } from '@/components/public/state'; import { publicMetadata } from '@/server/public-metadata'; +import { catalogPath } from '@/server/public-paths'; import { requirePublicResource } from '@/server/public-resource'; import { getPublicUi } from '@/server/public-ui'; @@ -18,7 +19,11 @@ export default async function CoursePage() { return ( <PublicState state={resource.state} - recoveryPath={resource.request.route.path} + recoveryPath={ + resource.state === 'invalid_cursor' + ? resource.request.route.path + : (catalogPath(resource.request.locale ?? '') ?? '/') + } locale={ui.locale} direction={ui.direction} t={ui.t} diff --git a/frontend/apps/web/src/components/public/lesson.test.tsx b/frontend/apps/web/src/components/public/lesson.test.tsx index 1287afef..e461c219 100644 --- a/frontend/apps/web/src/components/public/lesson.test.tsx +++ b/frontend/apps/web/src/components/public/lesson.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import LessonPage, { generateMetadata, } from '@/app/(public)/[locale]/courses/[slug]/lessons/[lessonSlug]/page'; +import CoursePage from '@/app/(public)/[locale]/courses/[slug]/page'; import { createPublicTranslator } from '@/i18n/catalogues'; import type { ConfiguredPublicClient } from '@/server/configured-public-client'; import type { PublicRequest } from '@/server/public-request'; @@ -366,23 +367,37 @@ describe('lesson route dispatch', () => { for (const operation of Object.values(client)) expect(operation).not.toHaveBeenCalled(); }); - it.each(['invalid_cursor', 'rate_limited', 'unavailable'] as const)( - 'selects the controlled %s state before lesson markup', - async (state) => { + it.each( + [CoursePage, LessonPage].flatMap((page) => + (['invalid_cursor', 'rate_limited', 'unavailable'] as const).map( + (state) => [page, state] as const, + ), + ), + )( + 'selects %s controlled %s recovery with an accurate translated destination', + async (page, state) => { const request = lesson().request; const ui = createPublicTranslator('tr-TR'); dependencies.resource.mockResolvedValue({ kind: 'failure', request, state }); dependencies.ui.mockResolvedValue(ui); - expect(await LessonPage()).toMatchObject({ + const view = await page(); + const recoveryPath = state === 'invalid_cursor' ? request.route.path : '/tr-TR/courses'; + expect(view).toMatchObject({ type: PublicState, props: { state, - recoveryPath: request.route.path, + recoveryPath, locale: ui.locale, direction: ui.direction, t: ui.t, }, }); + render(view); + expect( + screen.getByRole('link', { + name: state === 'invalid_cursor' ? 'İlk sayfaya dön' : 'Kurslara göz at', + }), + ).toHaveAttribute('href', recoveryPath); expect(dependencies.notFound).not.toHaveBeenCalled(); }, ); From 593d3e1195f77097ba60f17f1dc8c2fe62e399fe Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 01:26:02 +0300 Subject: [PATCH 13/58] fix(public): reserve cursor recovery for paginated reads Keep lesson validation failures in the bounded unavailable state and reject non-400 cursor classification. Close both independent Step 3 review rounds after focused fix verification. ADR: 0027, 0051 --- docs/roadmap/phase-02d-walking-skeleton.md | 21 +++++++++++++++++-- .../web/src/server/public-resource.test.ts | 3 ++- .../apps/web/src/server/public-resource.ts | 6 +++++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 0e462e9b..4dabad48 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -14,7 +14,7 @@ > | 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 | in progress — decision pass Accepted 2026-10-09; [decision package](#p02d-6-decision-package-2026-10-09); [Step 1](#p02d-6-step-1-localization-and-document-foundation) and [Step 2 catalog/course](#p02d-6-step-2-catalog-and-course-pages) review rounds complete; [Step 3 lesson](#p02d-6-step-3-ordered-lesson-presentation) implemented, review pending; Step 4 pending | +> | P02d-6 | Public renderer | in progress — decision pass Accepted 2026-10-09; [decision package](#p02d-6-decision-package-2026-10-09); [Step 1](#p02d-6-step-1-localization-and-document-foundation) and [Step 2 catalog/course](#p02d-6-step-2-catalog-and-course-pages) review rounds complete; [Step 3 lesson](#p02d-6-step-3-ordered-lesson-presentation) review rounds complete; Step 4 pending | > | P02d-7 | Demo, full-stack CI and exit | not started | **Acceptance update — 2026-10-02.** P02d-1 remains merged. The maintainer accepted @@ -4364,7 +4364,7 @@ independent review rounds; Step 3 follows. ### P02d-6 Step 3: Ordered lesson presentation -**Implementation — 2026-10-10; independent review rounds pending.** The actual +**Implementation — 2026-10-10; both independent review rounds complete.** The actual lesson route consumes the shared verified resource loader in page and metadata, without a separate content read. Its synchronous view renders the API's ordered plain-string fields as definition pairs, preserves present empty strings, and @@ -4431,6 +4431,23 @@ The root passes 167 focused frontend cases, five Release corpus cases, workspace typecheck and diff checks after the fixes. A fresh second round follows before Step 4. +#### Step 3 review round 2 (2026-10-10) + +Fresh GPT-6-astra xhigh runtime/security and GPT-6-sol xhigh proof/corpus agents +review `c250677..3ac4ee1` independently. Runtime approves with no actionable issue +and passes 173 focused frontend cases. Proof/corpus confirms one defensive recovery +edge: a lesson API validation failure selects cursor reset although lessons have +no cursor. The closed mapping now reserves cursor failure for HTTP 400 catalog/ +course reads; lesson and other-status validation failures select unavailable. +The result matrix tests both boundaries without displaying backend diagnostics. +This is a defensive case, not a reproduced admitted normal-path API failure. + +Both reviewers approve the focused mapping fix with no new concrete finding. The +root passes 176 focused frontend cases, zero failures/skips/todos, workspace +typecheck and diff checks. Both independent rounds are complete; Step 4 follows. +Actual product RTL/fault/concurrency and manual accessibility obligations remain +explicit. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/frontend/apps/web/src/server/public-resource.test.ts b/frontend/apps/web/src/server/public-resource.test.ts index 1b8d60bd..b9ed8b0a 100644 --- a/frontend/apps/web/src/server/public-resource.test.ts +++ b/frontend/apps/web/src/server/public-resource.test.ts @@ -274,8 +274,9 @@ describe.each([ code: 'validation_failed', fieldErrors: { private: ['private diagnostic'] }, }), - 'invalid_cursor', + target.includes('/lessons/') ? 'unavailable' : 'invalid_cursor', ], + ['failure', apiError(503, { code: 'validation_failed' }), 'unavailable'], [ 'failure', apiError(503, { code: 'dependency_unavailable', provider: 'private provider' }), diff --git a/frontend/apps/web/src/server/public-resource.ts b/frontend/apps/web/src/server/public-resource.ts index f2351b22..a9c10ae3 100644 --- a/frontend/apps/web/src/server/public-resource.ts +++ b/frontend/apps/web/src/server/public-resource.ts @@ -45,7 +45,11 @@ function failure<T>( if (result.kind === 'api-error') { if (result.status === 404) return { kind: 'missing', request }; if (result.status === 429) return { kind: 'failure', request, state: 'rate_limited' }; - if (result.error.code === 'validation_failed') + if ( + result.status === 400 && + result.error.code === 'validation_failed' && + (request.route.kind === 'catalog' || request.route.kind === 'course') + ) return { kind: 'failure', request, state: 'invalid_cursor' }; } return { kind: 'failure', request, state: 'unavailable' }; From e2b8de40e6c41c498a3cb1247229c1b33263d91c Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 01:26:34 +0300 Subject: [PATCH 14/58] test(public): complete the defensive validation error fixture Keep the non-400 negative control consistent with the typed error contract and record passing final Step 3 type verification. ADR: 0027 --- docs/roadmap/phase-02d-walking-skeleton.md | 6 ++++-- frontend/apps/web/src/server/public-resource.test.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 4dabad48..46eb7429 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4443,8 +4443,10 @@ The result matrix tests both boundaries without displaying backend diagnostics. This is a defensive case, not a reproduced admitted normal-path API failure. Both reviewers approve the focused mapping fix with no new concrete finding. The -root passes 176 focused frontend cases, zero failures/skips/todos, workspace -typecheck and diff checks. Both independent rounds are complete; Step 4 follows. +root passes 176 focused frontend cases, zero failures/skips/todos. Final typecheck +requires the negative test DTO's mandatory empty `fieldErrors`; it is corrected, +and all 53 resource cases and workspace typecheck pass. Diff checks pass. Both +independent rounds are complete; Step 4 follows. Actual product RTL/fault/concurrency and manual accessibility obligations remain explicit. diff --git a/frontend/apps/web/src/server/public-resource.test.ts b/frontend/apps/web/src/server/public-resource.test.ts index b9ed8b0a..30b71de9 100644 --- a/frontend/apps/web/src/server/public-resource.test.ts +++ b/frontend/apps/web/src/server/public-resource.test.ts @@ -276,7 +276,7 @@ describe.each([ }), target.includes('/lessons/') ? 'unavailable' : 'invalid_cursor', ], - ['failure', apiError(503, { code: 'validation_failed' }), 'unavailable'], + ['failure', apiError(503, { code: 'validation_failed', fieldErrors: {} }), 'unavailable'], [ 'failure', apiError(503, { code: 'dependency_unavailable', provider: 'private provider' }), From e0730752ca08ab332bf897e6f8bbddc2ace69a0d Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 01:36:59 +0300 Subject: [PATCH 15/58] test(public): prove concurrent product isolation and recovery Exercise unchanged product pages under deterministic host and locale barriers, live publication changes and exact content-boundary faults. Keep transport controls and visitor budgets intact while recording the remaining manual accessibility obligation explicitly. ADR: 0027, 0052, 0053, 0054 --- CLAUDE.md | 5 +- README.md | 13 +- .../Database/PublicServerRenderingTests.cs | 282 +++++++++++- docs/roadmap/phase-02d-walking-skeleton.md | 44 +- .../web/scripts/verify-public-rendering.mjs | 418 +++++++++++++++++- 5 files changed, 747 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f34b7d95..0e7806c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,7 +130,10 @@ owns current verification and review status. Step 3 adds the ordered plain-text lesson page, eligible lesson metadata and 31 blocking JSX accessibility rules with actual-config planted controls. Its [delivery record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-3-ordered-lesson-presentation) -owns verification and review status; full product/manual closeout remains Step 4. +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) +owns remaining reviews and manual accessibility closeout. P02d-7 owns the browser demo and Lighthouse; Phase 06 retains the full renderer/Studio expansion. diff --git a/README.md b/README.md index 06ff9abc..fc6c2b97 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,11 @@ Its four steps deliver the read-only host boundary, anonymous site/Education GET/HEAD reads and OpenAPI/SDK/CI controls. Every step completed both review rounds; final fix verification and all six real required CI jobs pass. The sixth required OpenAPI check is active, with all other live protection settings preserved. -Trusted server rendering is delivered by P02d-5; public product pages and the -browser demo remain P02d-6–7. The +Trusted server rendering is delivered by P02d-5. P02d-6 implements localized +catalog/course/lesson pages, ordered text fields, safe themes and metadata; its +[product closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) +records current verification and pending manual accessibility/review work. +The browser demo remains P02d-7. 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 @@ -105,9 +108,9 @@ page consumers and P02d-7 the two-host browser demo. | **Tenancy** | Tenant provisioning, organizations, locales, typed settings/branding reads, host resolution and database isolation | User membership and permissions in [Phase 03](docs/roadmap/phase-03-identity-admin.md) | | **Customization** | Content types, level taxonomies, exact-definition and generation-cached batched display readers, text-card metadata validation and tenant-authored seeds | Remaining authoring capabilities across [Phases 04–08a](docs/roadmap/README.md) | | **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 | Public page consumers in [P02d-6](docs/roadmap/phase-02d-walking-skeleton.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, route scaffolds and generated typed SDK | Public pages and two-tenant browser demo in [P02d-6–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 and generated typed SDK | Public-page accessibility closeout in P02d-6; two-tenant browser demo in [P02d-7](docs/roadmap/phase-02d-walking-skeleton.md) | **Four modules contain domain implementations:** Tenancy, Customization, Audit and Education. Identity, Content and Media remain scaffolded. @@ -192,7 +195,7 @@ deadline/body limits and the closed authenticated hop. All four steps completed 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. Public pages are P02d-6; +owns current review/CI evidence. P02d-6 implements public pages; the two-host browser demo with `make demo` is P02d-7. See [local setup](.claude/skills/local-dev-setup/SKILL.md) and [Compose documentation](infra/compose/README.md) for troubleshooting. diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs index f6263284..edf404cc 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs @@ -6,7 +6,10 @@ using System.Text.Json; using System.Text.RegularExpressions; using FluentAssertions; +using LearnStack.SharedKernel.Entitlements; +using LearnStack.SharedKernel.Identifiers; using LearnStack.Tools.Seeder; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; @@ -24,7 +27,7 @@ namespace LearnStack.Tests.Integration.Database; /// <summary> /// Production Next/native TLS/SDK/API/PostgreSQL proof. The disposable renderer /// separates the P5 transport probe from unchanged P6 product routes. -/// Pagination inventory and presentation revisions belong to their own modes. +/// Pagination, presentation revisions and product closeout use fresh-budget modes. /// </summary> [Collection(PublicReadTestGroup.Name)] [Trait(RequiresDocker.Key, RequiresDocker.Value)] @@ -48,10 +51,13 @@ public sealed class PublicServerRenderingTests(PublicReadFixture fixture) [InlineData("foundation")] [InlineData("foundation-pagination")] [InlineData("presentation")] + [InlineData("product-isolation")] + [InlineData("product-freshness")] public async Task Production_rendering_is_host_isolated_fresh_and_private_through_the_real_public_API(string mode) { var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); var observed = new RenderingObservation(secret); + var product = mode.StartsWith("product-", StringComparison.Ordinal); await using var factory = fixture.WithWebHostBuilder(builder => { builder.UseSetting("Tenancy:TrustedHop:Networks:0", "127.0.0.1/32"); @@ -62,6 +68,7 @@ public async Task Production_rendering_is_host_isolated_fresh_and_private_throug // Production Serilog reads sinks from DI; keep its real // configuration/enrichers and observe the same emitted events. services.AddSingleton<ILogEventSink>(observed); + if (product) services.AddSingleton<IEntitlementProvider, ProductEntitlementProvider>(); }); }); factory.UseKestrel(options => options.Listen(IPAddress.Loopback, 0)); @@ -84,6 +91,11 @@ public async Task Production_rendering_is_host_isolated_fresh_and_private_throug TenantInput(SeedData.English, firstCourse, locale), TenantInput(SeedData.Yoga, secondCourse, locale) }; + var productRows = new List<(SeedTenant Tenant, SeedCourse Course)>(); + var productInputs = product + ? new[] { ProductInput(SeedData.English, firstCourse, 1), ProductInput(SeedData.Yoga, secondCourse, 2) } + : []; + var malformedTheme = false; var root = RepositoryRoot(); using var process = RendererProcess(root); using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(8)); @@ -114,6 +126,14 @@ await fixture.ExecuteAsync(SeedData.English, await AddPaginationRowsAsync(firstCourse); paginationRows = true; } + if (product) + { + foreach (var (tenant, course, index) in new[] { (SeedData.English, firstCourse, 1), (SeedData.Yoga, secondCourse, 2) }) + { + await AddProductTranslationsAsync(tenant, course, index); + productRows.Add((tenant, course)); + } + } started = process.Start(); started.Should().BeTrue(); var errors = BoundedErrorsAsync(process.StandardError, deadline.Token); @@ -126,6 +146,7 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new courseSlug = sharedSlug, traceparent = SuppliedTrace, tenants, + product = productInputs, presentation = new { label = PresentationTypeLabel, fields = PresentationFields } }, JsonOptions)); while (await process.StandardOutput.ReadLineAsync(deadline.Token) is { } checkpoint) @@ -294,6 +315,57 @@ await SetPresentationLessonAsync(firstCourse, SeedData.English.Curriculum!.Conte observed.Requests.Length.Should().Be(foundationPosition); await RestorePresentationLessonAsync(firstCourse); break; + case "product-arm-html": + case "product-arm-rsc": + observed.Requests.Length.Should().Be(foundationPosition); + observed.ArmBarrier(productInputs.SelectMany(input => new[] + { + new ContentTarget(input.Host + ":3000", "en", $"/api/v1/public/courses/{sharedSlug}/lessons/{input.EnglishLessonSlug}"), + new ContentTarget(input.Host + ":3000", "ar", $"/api/v1/public/courses/{input.CourseSlug}/lessons/{input.LessonSlug}") + }).ToArray()); + break; + case "product-html": + case "product-rsc": + AssertProductReads(observed, foundationPosition, 12); + observed.VerifyBarrier(); + var concurrent = observed.Requests.Skip(foundationPosition).Where(request => request.Path != "/api/v1/public/site").ToArray(); + concurrent.Select(request => (request.Host, request.Locale)).Should().BeEquivalentTo( + productInputs.SelectMany(input => new[] { (input.Host + ":3000", "en"), (input.Host + ":3000", "ar") })); + concurrent.Should().OnlyContain(request => request.Status == 200 && request.Path.Contains("/lessons/")); + foundationPosition = observed.Requests.Length; + break; + case "product-malform-theme": + await SetProductThemeAsync("{\"primary\":\"#abcdef\",\"background\":\"#ffffff\",\"foreground\":\"#111827\",\"muted\":\"fixture-invalid-color\"}"); + malformedTheme = true; + break; + case "product-restore-theme": + await SetProductThemeAsync(SeedData.English.Curriculum!.Theme.Value); + malformedTheme = false; + break; + case "product-arm-429": + observed.ArmFault(new(SeedData.Yoga.Host + ":3000", SeedData.Yoga.Curriculum!.Locales.Single(row => row.IsDefault).Locale, + "/api/v1/public/courses/" + secondCourse.Translations.Single(row => row.Locale == "tr-TR").Slug), 429); + break; + case "product-arm-503": + observed.ArmFault(new(SeedData.English.Host + ":3000", "ar", + $"/api/v1/public/courses/{productInputs[0].CourseSlug}/lessons/{productInputs[0].LessonSlug}"), 503); + break; + case var name when name.StartsWith("product-", StringComparison.Ordinal): + var missing = name is "product-draft-course" or "product-draft-lesson" or "product-protected-en" or "product-protected-tr"; + AssertProductReads(observed, foundationPosition, missing ? 5 : 3); + var productContent = observed.Requests.Skip(foundationPosition).Single(request => request.Path != "/api/v1/public/site"); + productContent.Status.Should().Be(missing ? 404 : name switch + { + "product-bad-cursor" => 400, + "product-content-429" => 429, + "product-content-503" => 503, + _ => 200 + }); + if (name == "product-bad-cursor") + productContent.Query.Should().Contain("lessonCursor=bad-cursor", "the syntactically accepted cursor must reach the real API validator"); + if (name is "product-content-429" or "product-content-503") observed.VerifyFault(); + foundationPosition = observed.Requests.Length; + break; case "verified": verified = true; break; @@ -316,11 +388,21 @@ await SetPresentationLessonAsync(firstCourse, SeedData.English.Curriculum!.Conte else if (mode == "foundation-pagination") checkpoints.Should().Equal("build-complete", "pagination-catalog-first", "pagination-catalog-next", "pagination-catalog-restart", "pagination-outline-first", "pagination-outline-next", "pagination-outline-restart", "verified"); - else checkpoints.Should().Equal("build-complete", "presentation-english", "presentation-yoga-en", "presentation-yoga-tr", + else if (mode == "presentation") checkpoints.Should().Equal("build-complete", "presentation-english", "presentation-yoga-en", "presentation-yoga-tr", "presentation-rsc-english", "presentation-rsc-yoga-tr", "presentation-publish-revision", "presentation-exact-pin", "presentation-pin-revision", "presentation-swapped", "presentation-rsc-swapped", "presentation-yoga-unchanged", "presentation-make-empty", "presentation-empty", "presentation-make-unavailable", "presentation-unavailable", "presentation-restore-lesson", "presentation-restored", "presentation-cross-host", "presentation-protected", "verified"); + else if (mode == "product-isolation") checkpoints.Should().Equal("build-complete", "product-arm-html", "product-html", "product-arm-rsc", "product-rsc", + "product-malform-theme", "product-theme-fallback", "product-theme-other", "product-restore-theme", "product-theme-restored", + "product-protected-en", "product-protected-tr", "verified"); + else if (mode == "product-freshness") checkpoints.Should().Equal("build-complete", "product-warm-catalog", "product-warm-course", "product-warm-lesson", + "make-draft", "product-draft-catalog", "product-draft-course", "product-draft-lesson", "product-draft-other", "restore-published", + "product-restored-catalog", "product-restored-course", "product-restored-lesson", "product-bad-cursor", "product-cursor-recovery", + "product-arm-429", "product-content-429", "product-429-recovery", "product-arm-503", "product-content-503", "product-503-recovery", "verified"); + if (mode == "product-isolation") observed.Requests.Should().HaveCount(43); + if (mode == "product-freshness") observed.Requests.Should().HaveCount(52); + if (product) foundationPosition.Should().Be(observed.Requests.Length, "every product request belongs to an asserted checkpoint"); if (mode == "foundation") observed.Requests.Should().HaveCount(52, "normal/repeat, both hosts and languages, restricted/empty/cross-tenant, status and refusal checks fit one visitor window"); if (mode == "foundation-pagination") observed.Requests.Should().HaveCount(18, @@ -349,6 +431,8 @@ await SetPresentationLessonAsync(firstCourse, SeedData.English.Curriculum!.Conte finally { if (draft) await SetStatusAsync(firstCourse, firstCourse.Status); + if (malformedTheme) await SetProductThemeAsync(SeedData.English.Curriculum!.Theme.Value); + foreach (var (tenant, course) in productRows) await RemoveProductTranslationsAsync(tenant, course); if (foundationLocales) await fixture.ExecuteAsync(SeedData.English, "DELETE FROM tenant_locales WHERE tenant_id=@tenant AND locale IN ('ar','tr-TR','tr')"); @@ -453,6 +537,93 @@ public void Api_log_containment_recognizes_private_values_without_matching_ordin observation.PrivateLog.Should().Be(expected); } + private sealed record ProductTenant(string Host, string CourseSlug, string CourseTitle, string CourseSummary, + string LessonSlug, string LessonTitle, string EnglishLessonSlug, PresentationContent Content, + JsonElement Theme, bool ShowAttribution, string[] AllCanaries, string[] ProtectedCanaries); + private sealed record PresentationContent(PresentationLabel Label, PresentationField[] Fields); + + private static ProductTenant ProductInput(SeedTenant tenant, SeedCourse course, int index) + { + var curriculum = tenant.Curriculum!; + var defaultLocale = curriculum.Locales.Single(row => row.IsDefault).Locale; + var type = curriculum.ContentType; + using var schema = JsonDocument.Parse(type.JsonSchema); + using var theme = JsonDocument.Parse(curriculum.Theme.Value); + var fields = schema.RootElement.GetProperty("x-fields").EnumerateArray().Select((field, fieldIndex) => + new PresentationField(field.GetProperty("name").GetString()!, + new(field.GetProperty("label").GetProperty(defaultLocale).GetString()!, defaultLocale), + $"محتوى عربي للمؤسسة {index} والحقل {fieldIndex + 1}")).ToArray(); + static string[] LessonMarkers(SeedLesson lesson) => lesson.Translations.SelectMany(translation => + { + using var body = JsonDocument.Parse(translation.Body); + return new[] { translation.Title }.Concat(body.RootElement.EnumerateObject().Select(field => field.Value.GetString()!)).ToArray(); + }).ToArray(); + bool Eligible(SeedCourse row, SeedLesson lesson) => row.ContentAccess == "public" && row.Status == "Published" && lesson.Status == "Published" + && (row.OrganizationId is null || (tenant.MapHostToDefaultOrganization && row.OrganizationId == tenant.DefaultOrganization.OrganizationId)); + var allCanaries = curriculum.Courses.SelectMany(row => row.Translations.SelectMany(translation => + new[] { translation.Title, translation.Summary ?? "" }).Concat(row.Lessons.SelectMany(LessonMarkers))) + .Where(value => value.Length > 0).Distinct().ToArray(); + // A shared authored sentence is not an identity marker. Assert every + // unique hidden title/body marker, while visible controls prove content. + var visibleMarkers = curriculum.Courses.SelectMany(row => row.Lessons.Where(lesson => Eligible(row, lesson)).SelectMany(LessonMarkers)).ToHashSet(); + var protectedCanaries = curriculum.Courses.SelectMany(row => row.Lessons.Where(lesson => !Eligible(row, lesson)).SelectMany(LessonMarkers)) + .Where(value => !visibleMarkers.Contains(value)).Distinct().ToArray(); + protectedCanaries.Should().NotBeEmpty(); + return new(tenant.Host, "p6-ar-course", $"دورة المؤسسة {index}", $"وصف الدورة العربية للمؤسسة {index}", + "p6-ar-lesson", $"درس المؤسسة {index}", course.Lessons.First(row => row.Status == "Published").Translations.Single(row => row.Locale == "en").Slug, + new(new(type.DisplayName[defaultLocale], defaultLocale), fields), theme.RootElement.Clone(), + tenant.TenantId != SeedData.English.TenantId, allCanaries, protectedCanaries); + } + + /// <summary>Owner-only test setup for exact-locale rows; this does not exercise application writers.</summary> + private Task AddProductTranslationsAsync(SeedTenant tenant, SeedCourse course, int index) + { + var input = ProductInput(tenant, course, index); + return fixture.ExecuteOwnerFixtureAsync(tenant, + """ + INSERT INTO tenant_locales(tenant_id,locale,is_default,is_enabled,sort) VALUES(@tenant,'ar',false,true,8); + INSERT INTO course_translations(course_id,tenant_id,organization_id,locale,title,summary,slug) + SELECT id,tenant_id,organization_id,'ar',@title,@summary,@slug FROM courses WHERE tenant_id=@tenant AND id=@course; + INSERT INTO lesson_translations(lesson_id,tenant_id,organization_id,locale,title,slug,body) + SELECT id,tenant_id,organization_id,'ar',@lessonTitle,@lessonSlug,CAST(@body AS jsonb) + FROM lessons WHERE tenant_id=@tenant AND id=@lesson; + """, + new Dictionary<string, object> + { + ["course"] = course.Id, + ["lesson"] = course.Lessons.First(row => row.Status == "Published").Id, + ["title"] = input.CourseTitle, + ["summary"] = input.CourseSummary, + ["slug"] = input.CourseSlug, + ["lessonTitle"] = input.LessonTitle, + ["lessonSlug"] = input.LessonSlug, + ["body"] = JsonSerializer.Serialize(input.Content.Fields.ToDictionary(field => field.Name, field => field.Value), JsonOptions) + }, course.OrganizationId); + } + + private Task RemoveProductTranslationsAsync(SeedTenant tenant, SeedCourse course) => fixture.ExecuteOwnerFixtureAsync(tenant, + """ + DELETE FROM lesson_translations WHERE tenant_id=@tenant AND lesson_id=@lesson AND locale='ar'; + DELETE FROM course_translations WHERE tenant_id=@tenant AND course_id=@course AND locale='ar'; + DELETE FROM tenant_locales WHERE tenant_id=@tenant AND locale='ar'; + """, new Dictionary<string, object> + { + ["course"] = course.Id, + ["lesson"] = course.Lessons.First(row => row.Status == "Published").Id + }, course.OrganizationId); + + private Task SetProductThemeAsync(string value) => fixture.ExecuteAsync(SeedData.English, + "UPDATE tenant_settings SET value=CAST(@value AS jsonb) WHERE tenant_id=@tenant AND key='branding.theme'", + new Dictionary<string, object> { ["value"] = value }); + + private static void AssertProductReads(RenderingObservation observed, int position, int count) + { + var requests = observed.Requests.Skip(position).ToArray(); + requests.Should().HaveCount(count, "only the actual middleware/bootstrap/content operations reach the API"); + requests.Count(request => request.Path == "/api/v1/public/site").Should().Be(count == 12 ? 8 : count == 5 ? 4 : 2); + requests.Where(request => request.Path == "/api/v1/public/site").Should().OnlyContain(request => request.Status == 200); + } + private Task SetStatusAsync(SeedCourse course, string status) => fixture.ExecuteOwnerFixtureAsync( SeedData.English, "UPDATE courses SET status=@status WHERE tenant_id=@tenant AND id=@id", @@ -624,6 +795,7 @@ private static object TenantInput(SeedTenant tenant, SeedCourse course, string l restrictedDefaultSlug = restrictedDefault?.Slug, restrictedDefaultLessonSlug = restricted?.Lessons.First(row => row.Status == "Published") .Translations.Single(row => row.Locale == defaultLocale).Slug, + restrictedLessonSlug = restricted?.Lessons.First(row => row.Status == "Published").Translations.Single(row => row.Locale == locale).Slug, restrictedTitle = restrictedTranslation?.Title, restrictedSummary = restrictedTranslation?.Summary, restrictedCanaries = LessonCanaries(restricted, locale), @@ -708,9 +880,48 @@ private static async Task<string> BoundedErrorsAsync(StreamReader stream, Cancel ? result.ToString() : "Production rendering fixture exited unexpectedly."; } - private sealed record ObservedRequest(string Path, string Locale, string TraceId, bool ValidHop); + private sealed record ObservedRequest(string Path, string Locale, string TraceId, bool ValidHop, string Host, string Query) + { + public int Status { get; set; } + } + + private sealed record ContentTarget(string Host, string Locale, string Path); + + private sealed record ContentFault(ContentTarget Target, int Status); + + /// <summary>Four exact content calls must arrive before any enters the real API pipeline.</summary> + private sealed class ContentBarrier(ContentTarget[] targets) + { + private readonly HashSet<ContentTarget> _remaining = targets.ToHashSet(); + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _arrivals; + public int Arrivals => Volatile.Read(ref _arrivals); + public bool Released => _release.Task.IsCompletedSuccessfully; + + public async Task ArriveAsync(ContentTarget target, CancellationToken cancellationToken) + { + lock (_remaining) + { + if (!targets.Contains(target)) return; + _remaining.Remove(target).Should().BeTrue("each held host/locale content operation runs exactly once"); + Interlocked.Increment(ref _arrivals); + if (_remaining.Count == 0) _release.SetResult(); + } + await _release.Task.WaitAsync(TimeSpan.FromSeconds(8), cancellationToken); + } + } - /// <summary>Observes the real Kestrel request without changing API resolution or transactions.</summary> + private sealed class ProductEntitlementProvider : IEntitlementProvider + { + public Task<EntitlementProjection> GetAsync(TenantId tenantId, CancellationToken ct = default) => + Task.FromResult(new EntitlementProjection(tenantId, "fixture-product", + new Dictionary<string, bool> { [FeatureKeys.WhiteLabelBranding.Value] = tenantId == SeedData.English.TenantId }, + new Dictionary<string, long>(), ComplianceCaps.None, null, null, 1)); + public Task<EntitlementRefreshOutcome> RefreshAsync(EntitlementProjection projection, CancellationToken ct = default) => + Task.FromResult(EntitlementRefreshOutcome.IgnoredAsStale); + } + + /// <summary>Observes real Kestrel requests; optional content boundary faults never replace site bootstrap.</summary> private sealed class RenderingObservation(string secret) : IStartupFilter, ILogEventSink { // Match the complete unpadded wire shape, rather than ordinary v1 route @@ -721,12 +932,42 @@ private sealed class RenderingObservation(string secret) : IStartupFilter, ILogE RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1)); private readonly ConcurrentQueue<ObservedRequest> _requests = new(); + private ContentBarrier? _barrier; + private ContentFault? _fault; + private int _faults; + private int _verifiedFaults; private int _logs; private int _privateLog; public ObservedRequest[] Requests => _requests.ToArray(); public int Logs => Volatile.Read(ref _logs); public bool PrivateLog => Volatile.Read(ref _privateLog) != 0; + public void ArmBarrier(ContentTarget[] targets) + { + _barrier.Should().BeNull(); + Volatile.Write(ref _barrier, new ContentBarrier(targets)); + } + + public void VerifyBarrier() + { + _barrier.Should().NotBeNull(); + _barrier!.Arrivals.Should().Be(4, "all host/locale lanes reached the held content boundary before any was released"); + _barrier.Released.Should().BeTrue(); + _barrier = null; + } + + public void ArmFault(ContentTarget target, int status) + { + _fault.Should().BeNull(); + Volatile.Write(ref _fault, new(target, status)); + } + + public void VerifyFault() + { + _fault.Should().BeNull("the exact content fault is consumed once"); + Volatile.Read(ref _faults).Should().Be(++_verifiedFaults); + } + public void Emit(LogEvent logEvent) { using var text = new StringWriter(CultureInfo.InvariantCulture); @@ -744,6 +985,7 @@ public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) = { app.Use(async (context, continuation) => { + ObservedRequest? observedRequest = null; if (context.Request.Path.StartsWithSegments("/api/v1/public")) { var headers = context.Request.Headers; @@ -755,9 +997,39 @@ public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) = && !headers.ContainsKey("Authorization") && !headers.ContainsKey("Cookie") && !headers.ContainsKey("X-Tenant-Id") && !headers.ContainsKey("X-Organization-Id") && !headers.ContainsKey("X-Locale") && !headers.ContainsKey("X-Forwarded-For"); - _requests.Enqueue(new(context.Request.Path.Value!, context.Request.Query["locale"].ToString(), Activity.Current?.TraceId.ToHexString() ?? "", validHop)); + observedRequest = new(context.Request.Path.Value!, context.Request.Query["locale"].ToString(), + Activity.Current?.TraceId.ToHexString() ?? "", validHop, headers["X-LearnStack-Host"].ToString(), context.Request.QueryString.Value ?? ""); + _requests.Enqueue(observedRequest); + var target = new ContentTarget(observedRequest.Host, observedRequest.Locale, observedRequest.Path); + if (Volatile.Read(ref _barrier) is { } barrier) await barrier.ArriveAsync(target, context.RequestAborted); + var fault = Volatile.Read(ref _fault); + if (fault is not null && target == fault.Target && context.Request.Method == "GET" + && Interlocked.CompareExchange(ref _fault, null, fault) == fault) + { + // A one-shot content boundary fault, not a real handler or + // limiter failure. Both site bootstraps still run normally. + Interlocked.Increment(ref _faults); + context.Response.StatusCode = fault.Status; + context.Response.ContentType = "application/problem+json"; + context.Response.Headers.CacheControl = "no-store"; + await context.Response.WriteAsJsonAsync(new + { + type = "https://fixture.invalid/problem-private-type", + title = "fixture-private-problem-title", + status = fault.Status, + detail = "fixture-private-problem-detail", + instance = "/fixture-private-instance", + code = fault.Status == 429 ? "rate_limited" : "service_unavailable", + messageKey = "lockey_fixture_private_problem", + correlationId = "fixture-private-correlation", + messageParams = new { value = "fixture-private-parameter" } + }, options: JsonOptions, contentType: "application/problem+json", cancellationToken: context.RequestAborted); + observedRequest.Status = context.Response.StatusCode; + return; + } } await continuation(); + if (observedRequest is not null) observedRequest.Status = context.Response.StatusCode; }); next(app); }; diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 46eb7429..8f0b890c 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -14,7 +14,7 @@ > | 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 | in progress — decision pass Accepted 2026-10-09; [decision package](#p02d-6-decision-package-2026-10-09); [Step 1](#p02d-6-step-1-localization-and-document-foundation) and [Step 2 catalog/course](#p02d-6-step-2-catalog-and-course-pages) review rounds complete; [Step 3 lesson](#p02d-6-step-3-ordered-lesson-presentation) review rounds complete; Step 4 pending | +> | P02d-6 | Public renderer | in progress — decision pass Accepted 2026-10-09; [decision package](#p02d-6-decision-package-2026-10-09); [Step 1](#p02d-6-step-1-localization-and-document-foundation) and [Step 2 catalog/course](#p02d-6-step-2-catalog-and-course-pages) review rounds complete; [Step 3 lesson](#p02d-6-step-3-ordered-lesson-presentation) review rounds complete; [Step 4 product proof](#p02d-6-step-4-product-proof-and-accessibility-closeout) implemented; reviews and manual accessibility pending | > | P02d-7 | Demo, full-stack CI and exit | not started | **Acceptance update — 2026-10-02.** P02d-1 remains merged. The maintainer accepted @@ -4450,6 +4450,48 @@ independent rounds are complete; Step 4 follows. Actual product RTL/fault/concurrency and manual accessibility obligations remain explicit. +### P02d-6 Step 4: Product proof and accessibility closeout + +**Implementation in progress — 2026-10-10.** The unchanged product routes now +have dedicated `product-isolation` and `product-freshness` modes in +`PublicServerRenderingTests` / `verify-public-rendering.mjs`. P5's synthetic +transport mode and the foundation, pagination and presentation modes remain +separate and mandatory. This step adds proof inventory; it changes no production +route, API, rate budget or historical seed declaration. + +Test-owned Arabic translations use the same ASCII course/lesson slugs on both +hosts, with each course's original organization scope. A four-target content +barrier admits both hosts × English/Arabic before continuing the real API pipeline; +HTML and Flight checks reject opposite-tenant and protected authored markers. +Actual document direction, authored content and independently labelled fallback +UI are observed. Both seeded four-color palettes and effective attribution are +checked independently; one malformed theme must fall back atomically to the +whole local default palette, then recover after restoration. + +Freshness runs in one native server process. Publishing state changes remove the +course from the catalog and send course/lesson detail through the approved local +307→fixed localized 404 chain; restoring publication restores eligible content +without changing the other host. A naturally invalid cursor reaches the real +API. Strict one-shot exact host/locale/path content-boundary faults exercise +429 and 503 page states while bootstrap still uses the real application role and +read-only pipeline. These two faulted Education requests intentionally stop at +the test-owned API boundary; they do not claim production handler execution. + +Known content failures expose bounded translated HTTP 200/noindex views and +accurate cursor-reset/catalog recovery. Next may retain the request's own cursor +in its private Flight routing tree; the visible state/title and recovery link do +not echo it. Problem details and private provider copy stay absent from the +complete response. Fixture mutations restore locales, translations, theme and +publication in `finally`; every mode uses a fresh unchanged visitor budget. + +**Validation and reviews.** The guarded frontend run passes 1,065 cases +(1,005 web, 60 SDK), zero failures/skips/todos. All 20 focused Release production-rendering cases pass, zero failures/skips. +The new isolation/freshness modes observe exactly 43/52 API calls; the earlier +foundation/pagination/presentation modes retain 52/18/46. Full backend regression, +both fresh review rounds and the manual accessibility record follow. No browser or screen-reader pass is claimed here. +P6 completion and Accessibility promotion remain pending until actual keyboard, +focus, 320 CSS px reflow, contrast and screen-reader evidence exists. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/frontend/apps/web/scripts/verify-public-rendering.mjs b/frontend/apps/web/scripts/verify-public-rendering.mjs index 547bf49b..7ae82c4a 100644 --- a/frontend/apps/web/scripts/verify-public-rendering.mjs +++ b/frontend/apps/web/scripts/verify-public-rendering.mjs @@ -1165,6 +1165,412 @@ async function verifyPresentation(native) { assert.equal(native.exitCode, null, 'All schema and content changes use the same native process'); } +// Step 4 drives unchanged product routes. All expected identity/content values +// arrive from the C# seed/test-owned rows; this is not another seed registry. +function productDetails(tenant, locale) { + const extra = configuration.product.find((candidate) => candidate.host === tenant.host); + const arabic = locale === 'ar'; + const courseSlug = arabic + ? extra.courseSlug + : locale === 'en' + ? configuration.courseSlug + : tenant.defaultCourseSlug; + return { + ...extra, + locale, + coursePath: '/' + locale + '/courses/' + courseSlug, + lessonPath: + '/' + + locale + + '/courses/' + + courseSlug + + '/lessons/' + + (arabic ? extra.lessonSlug : locale === 'en' ? tenant.lessonSlug : tenant.defaultLessonSlug), + courseTitle: arabic + ? extra.courseTitle + : locale === 'en' + ? tenant.courseTitle + : tenant.defaultCourseTitle, + lessonTitle: arabic + ? extra.lessonTitle + : locale === 'en' + ? tenant.lessonTitle + : tenant.defaultLessonTitle, + content: arabic ? extra.content : locale === 'en' ? tenant.content : tenant.defaultContent, + }; +} + +function absentFromWholeResponse(response, values) { + const decoded = new JSDOM(response.body).window.document.documentElement.textContent; + const phase = stage; + for (const [index, value] of values.entries()) { + stage = phase + ' containment marker ' + index; + assert.ok(value.length > 0, 'Leak markers are nonempty'); + for (const representation of [value, JSON.stringify(value).slice(1, -1)]) + assert.equal( + response.body.includes(representation), + false, + 'Complete HTML/Flight containment', + ); + assert.equal(decoded.includes(value), false, 'Entity-decoded response containment'); + } + stage = phase; +} + +function productContainment(response, tenant) { + const own = configuration.product.find((candidate) => candidate.host === tenant.host); + const other = configuration.product.find((candidate) => candidate.host !== tenant.host); + const opposite = configuration.tenants.find((candidate) => candidate.host !== tenant.host); + assert.ok(own.protectedCanaries.length > 0 && other.allCanaries.length > 0); + absentFromWholeResponse(response, [ + opposite.name, + ...other.allCanaries, + other.courseTitle, + other.courseSummary, + other.lessonTitle, + ...other.content.fields.map((field) => field.value), + ...own.protectedCanaries, + ...catalogueCanaries, + ]); +} + +function productTheme(doc, details, malformed = false) { + const styles = [...doc.querySelectorAll('style')].filter((element) => + element.textContent.includes('--ls-primary:'), + ); + const { primary, background, foreground, muted } = details.theme; + assert.equal(styles.length, malformed ? 0 : 1, 'A malformed palette emits no partial override'); + if (!malformed) + assert.equal( + styles[0].textContent, + `:root{--ls-primary:${primary};--ls-bg:${background};--ls-fg:${foreground};--ls-muted:${muted};}`, + 'Exact complete seed palette is independent of entitlement', + ); + const footer = doc.querySelector('footer.public-footer'); + assert.equal( + footer !== null, + details.showAttribution, + 'Effective tenant entitlement alone selects attribution', + ); + if (footer) + assert.equal( + footer.textContent, + details.locale === 'tr-TR' ? 'LearnStack altyapısıyla' : 'Powered by LearnStack', + ); + if (malformed) { + assert.equal(doc.documentElement.hasAttribute('style'), false); + const cssLinks = [...doc.querySelectorAll('link[rel="stylesheet"]')]; + assert.ok(cssLinks.length > 0, 'The fallback has a real compiled stylesheet'); + const css = cssLinks + .map((link) => { + const path = link.getAttribute('href').split('?')[0]; + assert.match(path, /^\/_next\/static\/css\/[a-zA-Z0-9._-]+\.css$/); + return readFileSync(join(app, '.next', path.slice('/_next/'.length)), 'utf8'); + }) + .join(''); + assert.match( + css, + /:root\{[^}]*--ls-primary:#1f6feb;[^}]*--ls-bg:#fff(?:fff)?;[^}]*--ls-fg:#0f172a;[^}]*--ls-muted:#64748b[;}]/, + 'All four existing CSS defaults remain available together', + ); + } +} + +function productLesson(response, tenant, details, malformed = false) { + const doc = productDocument( + response, + tenant, + details.locale, + details.lessonTitle, + details.lessonPath, + details.locale === 'ar' ? 'rtl' : 'ltr', + ); + productContainment(response, tenant); + productTheme(doc, details, malformed); + const article = doc.querySelector('article.public-lesson'); + assert.equal(article.lang, details.locale); + assert.equal(article.dir, details.locale === 'ar' ? 'rtl' : 'ltr'); + assert.equal(article.querySelector('a').textContent, details.courseTitle); + assert.equal(article.querySelector('a').getAttribute('href'), details.coursePath); + const skip = doc.querySelector('a.public-skip'); + assert.equal(skip.textContent, 'Skip to content'); + assert.equal(skip.lang, 'en', 'Arabic content uses the authored English UI fallback'); + assert.equal(skip.dir, 'ltr'); + assert.equal(doc.querySelector('header nav').lang, 'en'); + assert.equal(doc.querySelector('header nav').textContent, 'Course catalog'); + const label = article.querySelector('h2'); + assert.equal(label.textContent, details.content.label.value); + assert.equal( + label.lang, + details.content.label.locale, + 'Resolved API label language is independent of UI language', + ); + assert.equal(label.dir, 'ltr'); + const terms = [...article.querySelectorAll('dt')]; + const values = [...article.querySelectorAll('dd')]; + assert.deepEqual( + terms.map((term) => term.textContent), + details.content.fields.map((field) => field.label.value), + ); + assert.deepEqual( + values.map((value) => value.textContent), + details.content.fields.map((field) => field.value), + ); + assert.ok(values.length > 0, 'The RTL document contains actual authored content'); + details.content.fields.forEach((field, index) => { + assert.equal(terms[index].lang, field.label.locale); + assert.equal(terms[index].dir, 'ltr'); + assert.equal(values[index].closest('[lang]').lang, details.locale); + assert.equal(values[index].closest('[dir]').dir, details.locale === 'ar' ? 'rtl' : 'ltr'); + assert.equal(values[index].childElementCount, 0); + }); + return doc; +} + +async function productRequest(tenant, path, rsc = false) { + return call(true, 3000, path, { Host: tenant.host + ':3000', ...(rsc ? { RSC: '1' } : {}) }); +} + +async function productMissing(name, tenant, path, locale, hidden) { + stage = name; + const refusal = await productRequest(tenant, path); + safeResponse(refusal, 307); + productContainment(refusal, tenant); + absentFromWholeResponse(refusal, hidden); + const target = new URL(refusal.headers.location, 'https://' + tenant.host + ':3000'); + assert.equal(target.origin, 'https://' + tenant.host + ':3000'); + assert.equal(target.pathname, '/' + locale + '/status/not-found'); + assert.equal(target.search, ''); + const response = await productRequest(tenant, target.pathname); + statusDocument(response, tenant, locale, locale === 'tr-TR' ? 'tr' : 'en'); + productContainment(response, tenant); + absentFromWholeResponse(response, [ + ...hidden, + ...path.split('/').filter((segment) => segment.startsWith('p6-')), + ]); + await checkpoint(name); +} + +async function verifyProductIsolation(native) { + const pid = native.pid; + const lanes = configuration.tenants.flatMap((tenant) => + ['en', 'ar'].map((locale) => ({ tenant, details: productDetails(tenant, locale) })), + ); + for (const rsc of [false, true]) { + stage = rsc ? 'product concurrent Flight barrier' : 'product concurrent HTML barrier'; + await checkpoint(rsc ? 'product-arm-rsc' : 'product-arm-html'); + // Promise.all only starts the work. The API holds every exact content lane + // until all four have arrived, and C# independently verifies that barrier. + const responses = await Promise.all( + lanes.map(({ tenant, details }) => productRequest(tenant, details.lessonPath, rsc)), + ); + for (const [index, response] of responses.entries()) { + const { tenant, details } = lanes[index]; + stage = `product concurrent ${rsc ? 'Flight' : 'HTML'} lane ${index}`; + safeResponse(response, 200, rsc); + productContainment(response, tenant); + if (rsc) { + for (const value of [ + tenant.name, + details.courseTitle, + details.lessonTitle, + ...details.content.fields.map((field) => field.value), + ]) + assert.ok( + response.body.includes(value) || + response.body.includes(JSON.stringify(value).slice(1, -1)), + 'Flight contains its own exact authored representation', + ); + } else productLesson(response, tenant, details); + } + await checkpoint(rsc ? 'product-rsc' : 'product-html'); + } + const [first, second] = configuration.tenants; + await checkpoint('product-malform-theme'); + for (const [name, tenant, malformed] of [ + ['product-theme-fallback', first, true], + ['product-theme-other', second, false], + ]) { + stage = name; + const details = productDetails(tenant, 'ar'); + const response = await productRequest(tenant, details.lessonPath); + productLesson(response, tenant, details, malformed); + absentFromWholeResponse(response, ['fixture-invalid-color', '#abcdef']); + await checkpoint(name); + } + await checkpoint('product-restore-theme'); + stage = 'product restored theme'; + const restored = productDetails(first, 'ar'); + productLesson(await productRequest(first, restored.lessonPath), first, restored); + await checkpoint('product-theme-restored'); + for (const [name, tenant, locale, slug, lessonSlug] of [ + ['product-protected-en', first, 'en', first.restrictedSlug, first.restrictedLessonSlug], + [ + 'product-protected-tr', + second, + second.defaultLocale, + second.restrictedDefaultSlug, + second.restrictedDefaultLessonSlug, + ], + ]) { + await productMissing( + name, + tenant, + '/' + locale + '/courses/' + slug + '/lessons/' + lessonSlug, + locale, + [], + ); + } + assert.equal(native.pid, pid); + assert.equal(native.exitCode, null, 'The entire product matrix uses one native process'); +} + +async function verifyProductFreshness(native) { + const pid = native.pid; + const [first, second] = configuration.tenants; + const details = productDetails(first, 'ar'); + const catalog = '/ar/courses'; + const success = async (name, tenant, path, title, visible = true) => { + stage = name; + const response = await productRequest(tenant, path); + safeResponse(response, 200); + productContainment(response, tenant); + const doc = document(response); + assert.equal(doc.documentElement.lang, path.split('/')[1]); + assert.equal(doc.documentElement.dir, path.startsWith('/ar/') ? 'rtl' : 'ltr'); + assert.equal( + doc.body.textContent.includes(title), + visible, + 'Publication is observed by a fresh product read', + ); + if (!visible) + absentFromWholeResponse(response, [ + details.courseTitle, + details.lessonTitle, + ...details.content.fields.map((field) => field.value), + ]); + if (path === catalog) { + assert.equal(doc.querySelectorAll('ul.public-course-list > li').length, visible ? 1 : 0); + assert.equal(doc.querySelector('a[href="' + details.coursePath + '"]') !== null, visible); + } + await checkpoint(name); + }; + for (const prefix of ['warm', 'restored']) { + if (prefix === 'restored') { + await checkpoint('make-draft'); + await success('product-draft-catalog', first, catalog, details.courseTitle, false); + const hidden = [ + details.courseTitle, + details.lessonTitle, + ...details.content.fields.map((field) => field.value), + ]; + await productMissing('product-draft-course', first, details.coursePath, 'ar', hidden); + await productMissing('product-draft-lesson', first, details.lessonPath, 'ar', hidden); + const other = productDetails(second, 'ar'); + await success('product-draft-other', second, other.lessonPath, other.lessonTitle); + await checkpoint('restore-published'); + } + await success('product-' + prefix + '-catalog', first, catalog, details.courseTitle); + await success('product-' + prefix + '-course', first, details.coursePath, details.courseTitle); + await success('product-' + prefix + '-lesson', first, details.lessonPath, details.lessonTitle); + } + const turkish = productDetails(second, second.defaultLocale); + for (const scenario of [ + { + name: 'product-bad-cursor', + recovery: 'product-cursor-recovery', + tenant: second, + locale: second.defaultLocale, + path: turkish.coursePath + '?lessonCursor=bad-cursor', + state: 'invalid_cursor', + target: turkish.coursePath, + title: turkish.courseTitle, + }, + { + name: 'product-content-429', + recovery: 'product-429-recovery', + arm: 'product-arm-429', + tenant: second, + locale: second.defaultLocale, + path: turkish.coursePath, + state: 'rate_limited', + target: '/' + second.defaultLocale + '/courses', + title: turkish.courseTitle, + }, + { + name: 'product-content-503', + recovery: 'product-503-recovery', + arm: 'product-arm-503', + tenant: first, + locale: 'ar', + path: details.lessonPath, + state: 'unavailable', + target: catalog, + title: details.courseTitle, + }, + ]) { + if (scenario.arm) await checkpoint(scenario.arm); + stage = scenario.name; + const response = await productRequest(scenario.tenant, scenario.path); + safeResponse(response, 200, false); + productContainment(response, scenario.tenant); + const doc = document(response); + const uiLocale = scenario.locale === 'tr-TR' ? 'tr' : 'en'; + const messages = JSON.parse( + readFileSync(join(sourceApp, 'src/i18n/messages', uiLocale, 'public.json'), 'utf8'), + ); + stage = scenario.name + ' bounded state'; + const state = doc.querySelector('section.public-state'); + assert.ok(state, 'Known content failure renders a bounded product state'); + assert.equal(doc.documentElement.lang, scenario.locale); + assert.equal(doc.documentElement.dir, scenario.locale === 'ar' ? 'rtl' : 'ltr'); + assert.equal(state.lang, uiLocale); + assert.equal(state.dir, 'ltr'); + assert.equal(doc.querySelectorAll('h1').length, 1); + assert.equal(doc.querySelectorAll('main').length, 1); + assert.equal(state.querySelector('h1').textContent, messages.page[scenario.state].title); + assert.equal(state.querySelector('p').textContent, messages.page[scenario.state].description); + stage = scenario.name + ' metadata'; + assert.ok(doc.title.includes(messages.page[scenario.state].title)); + assert.ok(doc.querySelector('meta[name="robots"]')?.content.includes('noindex')); + assert.equal( + doc.querySelector('link[rel="alternate"]'), + null, + 'Failure advertises no resource alternates', + ); + stage = scenario.name + ' recovery'; + const recovery = state.querySelector('a'); + assert.equal(recovery.getAttribute('href'), scenario.target); + assert.equal( + recovery.textContent, + scenario.state === 'invalid_cursor' + ? messages.page.invalid_cursor.reset + : messages.page.recovery, + ); + assert.ok(state.textContent.length < 400, 'Failure copy remains bounded'); + // Next may serialize the request's own query in its router tree. The + // controlled visible state and metadata never echo that cursor. + assert.equal(doc.body.textContent.includes('bad-cursor'), false); + assert.equal(doc.title.includes('bad-cursor'), false); + stage = scenario.name + ' private failure'; + absentFromWholeResponse(response, [ + 'fixture-private-', + 'lockey_', + 'service_unavailable', + 'rate_limited', + 'validation_failed', + ]); + await checkpoint(scenario.name); + await success(scenario.recovery, scenario.tenant, scenario.target, scenario.title); + } + assert.equal(native.pid, pid); + assert.equal( + native.exitCode, + null, + 'Lifecycle and controlled failures use the same native process', + ); +} + // The real Next adapter removes Flight inputs before user middleware, then // restores them after its request-header override. Observe that final request // through a disposable admitted route, without exposing any header values. @@ -1189,11 +1595,13 @@ try { assert.match(configuration.apiOrigin, /^http:\/\/127\.0\.0\.1:\d+$/); assert.match(configuration.secret, /^[A-Za-z0-9_-]{43}$/); assert.equal(configuration.tenants.length, 2); + const product = configuration.mode?.startsWith('product-') ?? false; const presentation = configuration.mode === 'presentation'; const foundation = configuration.mode === 'foundation' || configuration.mode === 'foundation-pagination' || - presentation; + presentation || + product; const pagination = configuration.mode === 'foundation-pagination'; if (foundation) { catalogueCanaries = ['en', 'tr'].map( @@ -1237,7 +1645,7 @@ try { if (foundation) { assert.ok(existsSync(join(coursesDirectory, 'page.tsx')), 'Copied product catalog route'); assert.ok(existsSync(join(coursesDirectory, '[slug]/page.tsx')), 'Copied product course route'); - if (presentation) + if (presentation || product) assert.ok( existsSync(join(coursesDirectory, '[slug]/lessons/[lessonSlug]/page.tsx')), 'Copied product lesson route', @@ -1348,7 +1756,11 @@ try { const catalog = '/' + configuration.locale + '/courses'; const course = catalog + '/' + configuration.courseSlug; - if (presentation) { + if (configuration.mode === 'product-isolation') { + await verifyProductIsolation(native); + } else if (configuration.mode === 'product-freshness') { + await verifyProductFreshness(native); + } else if (presentation) { await verifyPresentation(native); } else if (pagination) { await verifyPagination(native); From b86e20ddbc4f3ab53a994f3ca18ecb84e26f699c Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 01:42:43 +0300 Subject: [PATCH 16/58] test(public): cover private problem types in product containment Make the injected type URI independently observable by the existing complete-response containment assertion. Align mutable product-status carriers with delivered pages while preserving pending manual evidence. ADR: 0027, 0053, 0054 --- .github/CONTRIBUTING.md | 4 ++- README.md | 6 ++-- .../Database/PublicServerRenderingTests.cs | 2 +- docs/roadmap/README.md | 5 +++- docs/roadmap/phase-02d-walking-skeleton.md | 29 +++++++++++++++++-- .../roadmap/phase-06-renderer-admin-studio.md | 10 ++++--- frontend/README.md | 5 ++-- frontend/apps/web/README.md | 6 ++-- 8 files changed, 51 insertions(+), 16 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 836335bc..a2300cfb 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -66,7 +66,9 @@ Configure these in **GitHub → Settings → Branches → Branch protection rule integration also builds a disposable Next app against the real API/PostgreSQL; Node 22.23.1/pnpm, frozen frontend dependencies and OpenSSL are required by both that job and the frontend native-ingress proof. - Its test-owned HTML/RSC routes do not claim P6 pages or a browser audit. + P5 keeps its synthetic transport probe; P6 adds unchanged product-page modes + for HTML/RSC isolation, pagination, presentation, freshness and recovery. + These socket/DOM proofs do not claim a browser or manual accessibility audit. - `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; diff --git a/README.md b/README.md index fc6c2b97..9a8bc07b 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ The planned surfaces serve the people on each side of that experience: | **Learner portal** | Enrolled learners | Work through lessons, track progress and join live sessions. | | **Optional Course Marketplace** | Learners and participating institutions | Shared discovery and central checkout; endorsed target, architecture approval pending. | -These are planned product capabilities. Today, the frontend contains route scaffolds -for the first three surfaces; the status below separates delivered foundations from the -remaining product work. +The public catalog, course and lesson pages are implemented. Studio and learner +portal remain scaffolds; the status below separates delivered work from remaining +product and validation obligations. **Built for different ways of teaching.** Content types and level taxonomies already have a validated customization model. The wider design covers page blocks, lesson diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs index edf404cc..69e2512c 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs @@ -1014,7 +1014,7 @@ public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) = context.Response.Headers.CacheControl = "no-store"; await context.Response.WriteAsJsonAsync(new { - type = "https://fixture.invalid/problem-private-type", + type = "https://fixture.invalid/fixture-private-problem-type", title = "fixture-private-problem-title", status = fault.Status, detail = "fixture-private-problem-detail", diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index b9affd56..61035a23 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -69,7 +69,10 @@ not deferred to the showcase phase. [PR #26](https://github.com/HodeTech/LearnStack/pull/26) on 2026-10-09; its [merge closeout](phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09) records final verification. - P02d-6's public-renderer decision pass is next; the P7 browser demo remains later. + P02d-6's ADR-0027 and decision package are Accepted — 2026-10-09. Its localized + catalog/course/lesson pages and product proofs are implemented; the + [closeout record](phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) + tracks reviews and remaining manual accessibility. P7 owns the browser/demo. - [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) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 8f0b890c..4bf2d970 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4485,13 +4485,38 @@ complete response. Fixture mutations restore locales, translations, theme and publication in `finally`; every mode uses a fresh unchanged visitor budget. **Validation and reviews.** The guarded frontend run passes 1,065 cases -(1,005 web, 60 SDK), zero failures/skips/todos. All 20 focused Release production-rendering cases pass, zero failures/skips. +(1,005 web, 60 SDK), zero failures/skips/todos. All 20 focused Release +production-rendering cases pass, zero failures/skips. The new isolation/freshness modes observe exactly 43/52 API calls; the earlier foundation/pagination/presentation modes retain 52/18/46. Full backend regression, -both fresh review rounds and the manual accessibility record follow. No browser or screen-reader pass is claimed here. +both fresh review rounds and the manual accessibility record follow. No browser +or screen-reader pass is claimed here. P6 completion and Accessibility promotion remain pending until actual keyboard, focus, 320 CSS px reflow, contrast and screen-reader evidence exists. +#### Step 4 review round 1 (2026-10-10) + +Fresh GPT-6-astra xhigh runtime/security and GPT-6.1-sol xhigh proof/corpus agents +review `e2b8de4..e073075` independently. Neither finds a Blocker/Major. Both +independently reproduce a narrow containment-test false negative: the injected +Problem Details type URI uses a different marker from the other private fields. +The URI now includes the common private sentinel; both reviewers confirm that a +type-only leak is rejected and a clean response remains accepted. No production +leak is demonstrated. The proof reviewer also confirms stale scaffold-only +wording in README; it now distinguishes implemented public pages from the +Studio/portal scaffolds. Related mutable roadmap, frontend setup and contributor +carriers are aligned without claiming completion or browser evidence. + +The root passes the full Release Docker integration suite: 866 passed, zero +failures/skips. After rebuilding the one-line fixture fix, both product modes +pass again (2/2); exact 43/52 request counts remain unchanged. Earlier current +Release results are 1,705 unit, 198 architecture, four contract and 172 Docker-free +integration cases, all passing with zero skips. TRX counters for the newly run +suites are inspected directly. Reviewers run read-only helper probes, syntax and +link checks; neither claims independent full-suite or manual execution. Both +fresh second-round reviews follow the fix commit. Manual accessibility remains +an explicit packet-completion requirement. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/roadmap/phase-06-renderer-admin-studio.md b/docs/roadmap/phase-06-renderer-admin-studio.md index 6294175c..aab7782f 100644 --- a/docs/roadmap/phase-06-renderer-admin-studio.md +++ b/docs/roadmap/phase-06-renderer-admin-studio.md @@ -8,8 +8,9 @@ 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; P02d-7 owns the browser/demo harness. Neither remaining packet is -delivered yet. This phase deepens that bounded path with authored navigation, +lesson pages, now implemented with product proofs and manual accessibility +closeout pending. P02d-7 owns the unbuilt browser/demo harness. 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, @@ -21,7 +22,8 @@ and a non-developer tenant admin can maintain it. ### What Phase 02d supplies -Rows identify their delivery packet; planned P02d-6/7 work is not current behavior. +Rows identify implemented P02d-6 scope; its closeout remains pending. Planned +P02d-7 work is not current behavior. | Phase 02d foundation | Phase 06 adds | |---|---| @@ -31,7 +33,7 @@ Rows identify their delivery packet; planned P02d-6/7 work is not current behavi | 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 | -**P02d-6 inheritance — Accepted 2026-10-09; Step 1 foundation delivered.** +**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, app-local `src/i18n/messages/<locale>/<namespace>.json` and complete `en`/`tr` `public` messages. This phase extends its namespaces for full Studio/portal and diff --git a/frontend/README.md b/frontend/README.md index 109b8a2d..ea1dab76 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -38,5 +38,6 @@ Before starting web, follow the root [HTTPS Quickstart](../README.md#quickstart) `make public-env` prepares the paired private configuration; hosts and mkcert trust 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 product pages and P7's browser/demo -harness remain pending. +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) +tracks remaining review/manual accessibility work. P7 owns the browser/demo harness. diff --git a/frontend/apps/web/README.md b/frontend/apps/web/README.md index 27c33d9f..f8a7155a 100644 --- a/frontend/apps/web/README.md +++ b/frontend/apps/web/README.md @@ -22,8 +22,10 @@ don't collide at `/`: P02d-4 delivers the API reads; P02d-5 delivers live bootstrap and membership-first entry under [ADR-0053](../../../docs/decisions/0053-trusted-public-server-rendering.md). Exact scaffold roots continue only after bootstrap; enabled locale membership takes -precedence. P02d-6 still owns G40's product pages and page-state behavior in the -[decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register). +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 current verification and pending manual accessibility/review work. There is **no `extensions/` folder for vertical-provided components** — per [ADR-0018](../../../docs/decisions/0018-tenant-driven-customization-model.md), From 180dde0d72eba8e0fcab69db9f65184fa625c883 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 01:55:51 +0300 Subject: [PATCH 17/58] test(public): close product proof review gaps Make atomic theme fallback and fixed 404 containment falsifiable while recording completed automated checks and pending manual accessibility evidence without promoting incomplete controls. ADR: 0027, 0051, 0052 --- .claude/skills/run-tests-locally/SKILL.md | 10 ++- CLAUDE.md | 2 +- README.md | 2 +- docs/architecture/14-frontend-architecture.md | 14 ++-- docs/decisions/0027-frontend-i18n.md | 19 ++++++ docs/decisions/README.md | 4 +- docs/glossary.md | 2 +- docs/roadmap/phase-02d-walking-skeleton.md | 64 +++++++++++++++++-- docs/standards/06-testing.md | 10 ++- docs/standards/07-frontend-architecture.md | 10 +-- docs/standards/08-localization.md | 5 +- docs/standards/16-accessibility.md | 14 ++-- docs/standards/README.md | 5 +- .../web/scripts/verify-public-rendering.mjs | 10 ++- 14 files changed, 132 insertions(+), 39 deletions(-) diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index 41fe2aeb..caad07d7 100644 --- a/.claude/skills/run-tests-locally/SKILL.md +++ b/.claude/skills/run-tests-locally/SKILL.md @@ -166,7 +166,10 @@ packet/PR validation uses the guarded workspace command. Backend Docker integrat also runs P5's disposable production Next/TLS fixture against the real API and PostgreSQL; Node, pnpm, installed frontend dependencies and OpenSSL are required. It owns ports 3000/3011 while running and refuses occupied ports without stopping -an existing user process. Product-page browser/a11y tests remain P6/P7. +an existing user process. P6 adds separate production product modes for foundation, +pagination, presentation, concurrent isolation and freshness/failure states. Those +HTTP/HTML/RSC proofs do not claim browser or screen-reader execution; P6's manual +accessibility closeout remains required and P7 owns the browser demo. > **`pnpm test:a11y` and `pnpm test:e2e` do not exist yet.** `package.json` > defines `dev`, `build`, `start`, `lint`, `typecheck` and `test`, and neither @@ -175,8 +178,9 @@ an existing user process. Product-page browser/a11y tests remain P6/P7. > 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 -> controls are implementation obligations, not delivered by acceptance. P5's -> production HTML/RSC fixture proves transport, not a browser/a11y audit. +> lint and product-semantic controls are implemented. Actual manual evidence remains +> pending 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. ### Step 7: Single-test focus diff --git a/CLAUDE.md b/CLAUDE.md index 0e7806c6..4bffe4f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,7 +133,7 @@ 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) -owns remaining reviews and manual accessibility closeout. +records both completed review rounds and the pending manual accessibility handoff. P02d-7 owns the browser demo and Lighthouse; Phase 06 retains the full renderer/Studio expansion. diff --git a/README.md b/README.md index 9a8bc07b..5c9c0480 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ required OpenAPI check is active, with all other live protection settings preser Trusted server rendering is delivered by P02d-5. P02d-6 implements localized catalog/course/lesson pages, ordered text fields, safe themes and metadata; its [product closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) -records current verification and pending manual accessibility/review work. +records completed automated verification/reviews and pending manual accessibility. The browser demo remains P02d-7. 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 diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index ea953752..75395d76 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -27,7 +27,7 @@ native method/upgrade admission and pinned URL controls. The remediation record owns execution and independent review evidence; the ongoing rules live in [Frontend Standards](../standards/07-frontend-architecture.md#native-admission-and-url-identity). -**P02d-6 decisions Accepted — 2026-10-09; Steps 1–3 implemented.** +**P02d-6 decisions Accepted — 2026-10-09; Steps 1–4 implemented.** [ADR-0027](../decisions/0027-frontend-i18n.md) selects the UI foundation; the [decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09) selects pages, status/navigation, safe theme and product/accessibility proofs. @@ -35,9 +35,10 @@ Step 1 installs the pinned runtime and supplies shared request-local loaders, document language/direction, atomic theme injection, chrome and controlled status/error/loading components. Step 2 adds actual catalog/course pages, independent opaque pagination and metadata. Step 3 adds ordered lesson fields, -language attribution and eligible metadata. The -[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-3-ordered-lesson-presentation) -owns current validation and review evidence. +language attribution and eligible metadata. Step 4 adds concurrent host/locale, +theme, freshness and content-fault product proofs. The +[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) +owns validation, review status and pending manual accessibility closeout. > **P02d-4 Accepted design — 2026-10-03.** Step 2 delivers site bootstrap; > Steps 3–4 deliver Education and contract/SDK controls; P02d-5 owns the server consumer. @@ -428,8 +429,9 @@ harness and assertions. The decision is recorded in - Accepted P02d-6/G38(b,c)/G43 requires actual product HTML/DOM proofs and applicable jsx-a11y error rules with planted controls. Manual keyboard, focus, reflow/zoom, contrast and real screen-reader evidence for catalog → course → lesson names - commit, environment and both hosts/locales. These remain pending, as does packet - completion until the manual evidence exists. See + commit, environment and both hosts/locales. Automated product proofs and blocking + lint controls are implemented; manual evidence and packet completion remain + pending. See [Testing Standards](../standards/06-testing.md#frontend-test-types); HTTP/RSC proofs do not claim browser E2E or full Playwright/axe delivery. - Color contrast is verified for every branded theme, including the merged tenant and diff --git a/docs/decisions/0027-frontend-i18n.md b/docs/decisions/0027-frontend-i18n.md index 93f2c82e..296c1343 100644 --- a/docs/decisions/0027-frontend-i18n.md +++ b/docs/decisions/0027-frontend-i18n.md @@ -286,6 +286,25 @@ Catalog/course pages remain Step 2; lesson presentation remains Step 3. The owns current validation, review and product/accessibility proof status. The Decision, Status, Date and Deciders remain unchanged. +### Amendment 2 — P02d-6 pages and automated product proof (2026-10-10) + +Steps 2–3 implement localized catalog, course and ordered plain-text lesson pages, +independent opaque pagination and eligible metadata from shared request-local +loaders. Known content failures use bounded translated HTTP 200/noindex views; +missing or hidden details use the approved local 307 to a fixed localized 404. +Resolved API label language remains distinct from UI fallback language. + +Step 4 adds separate real API/production Next product modes for concurrent +host/locale isolation, authored RTL content, atomic theme fallback, publication +freshness and content-failure recovery. The +[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) +owns execution and review evidence. Actual manual keyboard, focus, reflow, +contrast and screen-reader evidence remains pending; P6 completion and +Accessibility promotion are not claimed. This delivery note changes no decision +or historical amendment. Phase 04 consumes the implemented UI foundation; +Phase 06 owns the full renderer/Studio and Playwright/axe expansion, and P02d-7 +owns the browser demo and Lighthouse activation. + ## References - [ADR-0008 — Localization Schema](0008-localization-schema.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index e14ba0d8..8d0293c9 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -46,7 +46,7 @@ an amendment is not a lifecycle status change. | 0022 | [Custom Domain & TLS](0022-custom-domain-tls.md) | Hub-owned custom domain admin; DNS-01 + HTTP-01 + Let's Encrypt; APISIX hot-reload | | 0023 | [Strongly-Typed ID Source Generator — Vogen](0023-strongly-typed-id-source-generator.md) | Vogen as the source generator for both IDs and value objects; `[ValueObject<Guid>]` annotation; EF + JSON + ASP.NET + OpenAPI emitters out of the box (Amendment 1: Vogen 7.0.0 pin + architecture-test placement, 2026-05-21; Amendment 2: `UserId` / `TenantId` / `OrganizationId` are cross-cutting and live in `LearnStack.SharedKernel`, 2026-08-10; **Amendment 3: `IStronglyTypedId` gains `IsInitialized()` and is no longer a pure marker, 2026-08-10**; Amendments 4–7, 2026-08-27 to 2026-09-01: `idempotency_keys` mints no id, `gen_uuid_v7()` is not a PostgreSQL function, the retroactive disclosure of the 2026-05-21 edit, and an id's own formatting is not a wire format; **Amendment 8, 2026-09-07: a module-local identifier crosses a cross-module contract as `Guid`**) | | 0024 | [API Versioning Policy](0024-api-versioning-policy.md) | URL-based `/v{N}/`; 6-month deprecation window; RFC 8594 `Sunset` + `Deprecation` headers; OpenAPI `deprecated` + `x-sunset` extensions; 410 Gone with RFC 7807 on sunset | -| 0027 | [Frontend UI Localization with next-intl](0027-frontend-i18n.md) | Accepted 2026-10-09: next-intl 4.14.9, server-first app-local ICU catalogues, dotted UI keys and verified request-locale source; P02d-6 Step 1 implements the foundation Step 2 adds catalog/course pages and Step 3 adds ordered lesson presentation; Phase 04 consumes the foundation | +| 0027 | [Frontend UI Localization with next-intl](0027-frontend-i18n.md) | Accepted 2026-10-09: next-intl 4.14.9, server-first app-local ICU catalogues, dotted UI keys and verified request-locale source; P02d-6 implements the foundation, catalog/course/lesson pages and automated product proofs ([Amendment 2](0027-frontend-i18n.md#amendment-2--p02d-6-pages-and-automated-product-proof-2026-10-10)); manual accessibility closeout remains pending; Phase 04 consumes the foundation | | 0028 | [`audit_log` Partition Management — Hangfire Recurring Job](0028-audit-log-partition-management.md) | Daily `learnstack:audit:partition-management` Hangfire job; create-ahead 2 months; drop only on platform-max retention horizon; row-level purge separate; no `pg_partman` dependency (Amendment 2026-09-07: Packet 9 creates a **plain, unpartitioned** table and executes none of this ADR's DDL; `Partition_Manager_Job_Is_Registered_AtStartup` moves to Phase 11 with the job it guards) | | 0029 | [Object Storage — SeaweedFS](0029-object-storage-seaweedfs.md) | Self-hosted SeaweedFS behind the existing `IStorageProvider` S3 contract; partially supersedes ADR-0002's MinIO row | | 0030 | [Redis-compatible Store — Valkey](0030-redis-compatible-store-valkey.md) | Valkey (Linux Foundation, BSD-3-Clause) for the cache + Dapr state-store backend; RESP-protocol drop-in; partially supersedes ADR-0002's Redis row | @@ -66,7 +66,7 @@ an amendment is not a lifecycle status change. | 0044 | [The Audit Write Path](0044-audit-write-path.md) | **Amendments 1–5 (2026-09-08), 6–7 (2026-09-11).** The eleven questions ADR-0033 left open, decided before the first audit file: the reserved **non-nil** platform sentinel tenant id (the nil uuid is refused by three shipped guards that read all-zero as *no tenant*), and a `tenants` CHECK that keeps it unprovisionable; the row's `tenant_id` is the tenant the **transaction announced**, so a provisioning command audits under the tenant it creates and `make seed` still commits; **N intents per request**, one per audited resource, because two shipped commands already write two — and only the **owning** unit-of-work frame writes them or reports the commit boundary, which is what stops a nested `Send` from claiming durability nothing committed; `outcome` closes at four values (`indeterminate` included) and the writer always supplies `timestamp`, so ADR-0033's deliberate duplicate-id pair is legal under `(id, timestamp)` rather than a `23505`; the catalogue is **code** keyed by request type and the matrix is prose, with a test asserting they agree, and every request must be classified — there is no `RequestKind.Other`; the interceptor captures **every** tracked entity minus a named exclusion list, because the `AuditableEntity<>` predicate was blind to `platform_host_to_tenant`; `[PiiSensitive]` and an elision record that preserves the column's JSON type; `audit_log` is org-scoped, carries **no** FK to `tenants`, and both standalone writers announce **two** GUCs — measured: an org-scoped `denied` row is refused without the second. Amendment 1 makes `audit_config` tenant-**wide**; Amendment 2 corrects the redaction sentinel to the shipped constant and the reason `FORCE` needs a trigger (the policy binds the owner by tenant, not by immutability — measured); Amendment 3 gives the matrix↔catalogue join two directions with two domains, because 23 of 30 shipped matrix slugs have no request type and the rule as written was red on arrival, and moves the audit value types into `SharedKernel.Audit` to break the same project cycle `AuditEntryId` had; Amendment 4 makes `[PiiSensitive]` property-granular, `jsonb` included, ships `audit_config` unwritten and binds the matrix rule to modules with code; Amendment 5 guards the sentinel where it is announced and fills `entity_type` / `entity_id` from the declared aggregate; Amendment 6 — from an external review that reproduced four defects against real PostgreSQL — has a handler designate the subject when it writes two instances of its aggregate, carries the actor and correlation id on the intent, keeps each intent's own request's result, captures a contained entity with its aggregate, and instance-qualifies `changes` pointers; Amendment 7 corrects § 7's count of plain entities — seven today, five of them MUST — and names the Tenancy matrix alone as the one that singles out the host mapping | | 0045 | [The Entitlement and Feature-Flag Socket](0045-entitlement-and-feature-flag-socket.md) | **Amendment 1 (2026-09-08)** reads it against the Hub's merged code: the limit vocabulary is the Hub's, `expires_at`/`valid_until` are nullable, the generation guard admits the equal case, and `platform_killswitches` ships **unwritten** because `DenyAllPlatformAdminGate` makes a writer unreachable until Phase 03. **Amendment 2 (2026-09-11):** a registry's membership is the vocabulary the contract names — fourteen Hub features, nine `limits.*`, two tenant flags, three killswitches — and enforcement, not membership, waits for a consumer. **Amendment 3 (2026-09-11):** a key's fail-open/fail-closed class decides only when no projection exists; one past its grace window is read-only, as ADR-0021 decides. **Amendment 4 (2026-09-11):** of the projection's names, `PlanCode` differs from the wire (`tier`) and `ExpiresAt` from the column (`valid_until`) — § 1 had called both wire differences. Declares the port twenty documents name and none define. `IEntitlementProvider.GetAsync(TenantId)` + `RefreshAsync(projection)`, the projection carrying every field `entitlement-v1.schema.json` requires — **`compliance` and `generation` included**, both already `NOT NULL` columns — and the refresh **generation-guarded inside the write statement**, so a stale push cannot resurrect a revoked plan. `IFeatureFlags` is the only module-facing read and **composes over the provider** rather than querying `platform_entitlement_cache`, which is the only reading under which the Phase 02a criterion — swapping the provider changes the answer — can be true. Limits: **`-1` unlimited, `0` denied**, `long` not `long?`; the inverse reading would have made the degraded read-only mode grant unlimited. `NullEntitlementProvider` is registered in **every** deployment mode, per ADR-0035's default-implementation gate rather than ADR-0020's Development-only switch. Killswitches leave `tenant_feature_flags` for a platform-scoped `platform_killswitches`: a foreign key to `tenants` is a constraint no role or `BYPASSRLS` moves, so the sentinel could never have satisfied it. Push-primary, **not** push-only — ADR-0034's `license/verify` fallback stands; the no-Hub absolute belongs to host resolution | | 0050 | [Publication and Course Content Access](0050-publication-and-course-content-access.md) | Accepted 2026-10-02: publication/access separation, restricted backfill and anonymous denial; supersedes ADR-0048; P02d-2 policy, migration, writers and seed delivered ([Amendment 1](0050-publication-and-course-content-access.md#amendment-1--p02d-2-implementation-delivery-2026-10-02)); P02d-4 public-read enforcement delivered, grants Phase 07 | -| 0051 | [Ordered Text Card Presentation](0051-ordered-text-card-presentation.md) | Accepted 2026-10-02: optional root `x-fields`, localized ordered text cards; compatible extension linked by ADR-0043 Amendment 5; P02d-2 profile parsing, resolution and seed verification delivered ([Amendment 1](0051-ordered-text-card-presentation.md#amendment-1--p02d-2-implementation-delivery-2026-10-02)); P02d-4 public projection delivered, rendering P02d-6 | +| 0051 | [Ordered Text Card Presentation](0051-ordered-text-card-presentation.md) | Accepted 2026-10-02: optional root `x-fields`, localized ordered text cards; compatible extension linked by ADR-0043 Amendment 5; P02d-2 profile parsing, resolution and seed verification delivered ([Amendment 1](0051-ordered-text-card-presentation.md#amendment-1--p02d-2-implementation-delivery-2026-10-02)); P02d-4 public projection delivered; P02d-6 implements ordered plain-text rendering and automated product proofs, with manual accessibility closeout pending | | 0052 | [Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md) | Accepted 2026-10-03: host-resolved bootstrap, immutable host ceiling, mandatory marked dispatch and read-only ambient frames; ADR-0036/0040 Amendment 8; P02d-4 Steps 1–3 deliver host provenance, physical read-only frames and all institution GET/HEAD reads; both Step 3 review rounds pass; Step 4 delivers OpenAPI/SDK/CI controls; both review rounds and focused fix verification pass; six real required CI jobs and live sixth-check activation verified; P02d-4 complete and merged through PR #25 on 2026-10-08 ([Amendment 13](0052-anonymous-public-read-boundary.md#amendment-13--merge-closeout-2026-10-08)) | | 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)) | diff --git a/docs/glossary.md b/docs/glossary.md index acd9fc05..624f1158 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -147,7 +147,7 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: |---|---| | **`IExactCustomizationDefinitionReader`** | Contextual uncached application reader for exact content-type/taxonomy revision values, with NewBinding versus ExistingPin eligibility; [Customization spec](modules/customization/README.md#p02d-2-accepted-exact-write-contract). | | **`ICustomizationDefinitionProjectionReader`** | Accepted P02d-3 internal batched display reader for exact tenant-owned revision pins, with immutable generation-keyed definition families. Step 3 implements coherent loading, generation caching and scope-safe bypass; both review rounds passed. Not a public API; the [decision package](roadmap/phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) records acceptance on 2026-10-02. | -| **`ResolvedLocalizedText`** | An immutable display label paired with its actual authored canonical locale, following [Localization § Fallback Rules](architecture/12-localization.md#fallback-rules). P02d-3 delivers internal metadata; P02d-4 Step 3 delivers its public `{value, locale}` projection. P02d-6 owns page language attributes. | +| **`ResolvedLocalizedText`** | An immutable display label paired with its actual authored canonical locale, following [Localization § Fallback Rules](architecture/12-localization.md#fallback-rules). P02d-3 delivers internal metadata; P02d-4 Step 3 delivers its public `{value, locale}` projection. P02d-6 implements page language/direction attributes, independently of the UI fallback language. | | **`ITenantSettingsAccessor`** | Accepted P02d-3 typed, uncached ambient settings reader with registered scope/grammar and explicit organization precedence. Step 1 implements the reader; the [Tenancy contract](modules/tenancy/README.md#p02d-3-accepted-typed-settings-contract) owns its scope. Both review rounds passed. | | **`IPublicTenantConfigurationReader`** | P02d-4's typed internal live-scope and locale configuration reader, on the announced read-only transaction. Valid emptiness, unavailable scope and invalid stored configuration stay distinct; [Tenancy contract](modules/tenancy/README.md#p02d-4-public-configuration-contract). | | **`ITenantLocaleEligibilityReader`** | Contextual uncached Tenancy contract for canonical enabled locale membership and valid locale configuration; [Tenancy spec](modules/tenancy/README.md#p02d-2-accepted-locale-and-branding-contract). | diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 4bf2d970..acf210e4 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -14,7 +14,7 @@ > | 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 | in progress — decision pass Accepted 2026-10-09; [decision package](#p02d-6-decision-package-2026-10-09); [Step 1](#p02d-6-step-1-localization-and-document-foundation) and [Step 2 catalog/course](#p02d-6-step-2-catalog-and-course-pages) review rounds complete; [Step 3 lesson](#p02d-6-step-3-ordered-lesson-presentation) review rounds complete; [Step 4 product proof](#p02d-6-step-4-product-proof-and-accessibility-closeout) implemented; reviews and manual accessibility pending | +> | P02d-6 | Public renderer | in progress — decision pass Accepted 2026-10-09; [decision package](#p02d-6-decision-package-2026-10-09); [Step 1](#p02d-6-step-1-localization-and-document-foundation), [Step 2 catalog/course](#p02d-6-step-2-catalog-and-course-pages), [Step 3 lesson](#p02d-6-step-3-ordered-lesson-presentation) and [Step 4 product proof](#p02d-6-step-4-product-proof-and-accessibility-closeout) implemented with both review rounds complete; actual manual accessibility closeout pending | > | P02d-7 | Demo, full-stack CI and exit | not started | **Acceptance update — 2026-10-02.** P02d-1 remains merged. The maintainer accepted @@ -4452,7 +4452,7 @@ explicit. ### P02d-6 Step 4: Product proof and accessibility closeout -**Implementation in progress — 2026-10-10.** The unchanged product routes now +**Implemented; manual closeout pending — 2026-10-10.** The unchanged product routes now have dedicated `product-isolation` and `product-freshness` modes in `PublicServerRenderingTests` / `verify-public-rendering.mjs`. P5's synthetic transport mode and the foundation, pagination and presentation modes remain @@ -4488,9 +4488,10 @@ publication in `finally`; every mode uses a fresh unchanged visitor budget. (1,005 web, 60 SDK), zero failures/skips/todos. All 20 focused Release production-rendering cases pass, zero failures/skips. The new isolation/freshness modes observe exactly 43/52 API calls; the earlier -foundation/pagination/presentation modes retain 52/18/46. Full backend regression, -both fresh review rounds and the manual accessibility record follow. No browser -or screen-reader pass is claimed here. +foundation/pagination/presentation modes retain 52/18/46. Full backend regression +passes as recorded below; both review rounds are complete after their fixes. +Manual accessibility closeout remains pending. No browser or screen-reader pass +is claimed here. P6 completion and Accessibility promotion remain pending until actual keyboard, focus, 320 CSS px reflow, contrast and screen-reader evidence exists. @@ -4517,6 +4518,59 @@ link checks; neither claims independent full-suite or manual execution. Both fresh second-round reviews follow the fix commit. Manual accessibility remains an explicit packet-completion requirement. +#### Step 4 review round 2 and automated verification (2026-10-10) + +Fresh GPT-6-astra xhigh runtime/security and GPT-6-sol xhigh proof/corpus agents +review `e2b8de4..b86e20d` independently. No production defect is demonstrated. The +proof review identifies two assertion gaps: the malformed-theme check only sees +primary-token style elements, and fixed-status containment only checks `p6-` +requested slugs. The first now rejects any of the four color-token declarations +in style elements or style attributes. The second rejects every requested course +and lesson slug on the final fixed 404, including historical seed slugs. Own +requested route segments remain permitted in Next's 307 Flight tree, as already +recorded in Step 3; private content remains forbidden on both responses. + +Both reviewers independently verify the fixes with extracted real helpers and +clean/planted controls. The root's ten planted violations are all rejected; +both real product modes then pass again, zero failures/skips, preserving exact +43/52 API call counts. Current-state documents distinguish implemented automated +product proof from pending manual observation. ADR-0027 Amendment 2 is insertion +only and changes no accepted decision. The optional suggestion to revisit the +original faulted resource after catalog recovery is not required: the accepted +G40 catalog-navigation contract is directly exercised and passes. No automatic +retry or broader recovery guarantee is introduced. + +Full Release backend verification totals 2,945 distinct passing cases: 1,705 unit, +198 architecture, four contract, 172 Docker-free integration and 866 Docker +integration. There are zero failures/skips; recorded TRX counters are inspected. +The architecture suite is rerun after the current-status documentation changes. +Guarded frontend verification passes 1,065 cases (1,005 web, 60 SDK), zero +failures/skips/todos. Workspace lint/typecheck, JavaScript syntax, Prettier and +the seven ADR workflow tests pass. Production build evidence remains the earlier +Step 3 build; the review fixes change proof assertions and documentation only. +A local audit checks 2,194 relative links and 666 fragments across 33 changed +Markdown files against `origin/main`, excluding one explicit catalogue-link +template placeholder. No target is missing. This manual script audit does not +claim that CI's file-only link job enforces fragments. Added prose respects the +88-column rule and `git diff --check` passes. + +#### Manual accessibility handoff (2026-10-10) + +A temporary production fixture is prepared from `b86e20d`, with the real API, +`learnstack_app`, read-only frames, actual public routes and test-owned Arabic +long-string content. A separate Brave window opens both institution hosts. The +browser refuses the temporary test certificate before any product page loads; +the computer-use policy requires the user to pass that security interstitial. +The requested handoff is pending. No keyboard, focus, 320 CSS px reflow, contrast +or screen-reader pass is claimed. The temporary server and its owned children +are stopped cleanly before the automated rerun; ports 3000/3011 are free and no +test container remains. Existing developer services are untouched. + +Resume the actual manual walkthrough after the browser handoff, record its +commit/environment/hosts/locales/assistive technology and fix any observed issue +before closing P6 or promoting Accessibility and the five registered P6 rules. +The packet remains in progress; P02d-7 and its Lighthouse job have not started. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index aa0ebb77..e76619ee 100644 --- a/docs/standards/06-testing.md +++ b/docs/standards/06-testing.md @@ -191,14 +191,18 @@ Rules: - Playwright covers the same golden flows the backend E2E covers, from the user's perspective. - Visual regression covers the public renderer and page-builder block output. -**P02d-6 G38(b,c)/G43 — Steps 1–3 implemented, 2026-10-10.** Vitest +**P02d-6 G38(b,c)/G43 — Steps 1–4 implemented, 2026-10-10.** Vitest covers synchronous views and pure mappings; all 31 active recommended JSX accessibility rules have blocking actual-config dirty/clean controls. Async pages -require production -HTML/RSC proof against unchanged product routes in the existing required real +have production +HTML/RSC proofs against unchanged product routes in the existing required real API/Next Docker fixture; keep P5's synthetic transport probe in a separate mode so its catch-all cannot shadow product routes. Preserve `learnstack_app`, RLS, read-only transactions, native HTTPS, closed hop headers and fixture containment. +Step 4 adds deterministic overlapping host/locale lanes, authored RTL content, +whole-theme fallback, next-document freshness and one-shot content-failure modes. +The [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) +owns execution and review evidence; manual accessibility remains pending. Assert visible HTML/DOM separately from Flight: status, `lang`/`dir`, headings, theme, attribution, both hosts/locales, cross-tenant absence, overlapping concurrent diff --git a/docs/standards/07-frontend-architecture.md b/docs/standards/07-frontend-architecture.md index bed020b9..8f476a86 100644 --- a/docs/standards/07-frontend-architecture.md +++ b/docs/standards/07-frontend-architecture.md @@ -242,11 +242,13 @@ cache is permitted. Request-local reuse is isolated to one incoming request. Freshness is the next new server/document request, not client Router Cache history. Disable local Server Component HMR caching. P02d-6 Step 1 implements document loaders, chrome, state views and atomic theme injection. Step 2 adds catalog/course -views; Step 3 adds ordered lesson views. The -[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-3-ordered-lesson-presentation) -owns verification and review status. +views; Step 3 adds ordered lesson views. Step 4 adds concurrent host/locale, theme, +freshness and failure-state product proofs. The +[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) +owns verification, review status and pending manual accessibility evidence. P5 delivers the dynamic layout, transport and source/runtime proofs. Test-owned -production routes exercise the real API; P6 public pages are not delivered by them. +production routes exercise the real API. P6 product modes copy the actual public +routes separately; the synthetic transport mode cannot shadow those pages. - Renders **published** pages, courses, blog content. - Institution public SSR uses ADR-0053's dynamic/no-store policy. diff --git a/docs/standards/08-localization.md b/docs/standards/08-localization.md index 715c3f04..e7b901e6 100644 --- a/docs/standards/08-localization.md +++ b/docs/standards/08-localization.md @@ -313,8 +313,9 @@ enabled `ar` content proves `lang="ar"`/`dir="rtl"` and labelled English fallbac UI independently; it neither rewrites the seed nor claims an Arabic UI catalogue. Step 1 implements direction selection and labelled fallback chrome; Step 2 proves an empty admitted Arabic catalog in production. Step 3 adds per-field -label direction. Eligible authored RTL content and full manual proof remain -Step 4 obligations. +label direction. Step 4 adds eligible authored RTL content with concurrent +host/locale HTML/Flight proofs. Actual manual accessibility remains pending in +the [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout). ## Admin Studio UI diff --git a/docs/standards/16-accessibility.md b/docs/standards/16-accessibility.md index f6855928..902bb55f 100644 --- a/docs/standards/16-accessibility.md +++ b/docs/standards/16-accessibility.md @@ -106,12 +106,14 @@ LearnStack is an education platform; learners with disabilities are a first-clas jsx-a11y rules run at error severity with a literal rule census and per-rule dirty/ clean controls through the actual app configuration in `src/test/accessibility-lint.test.ts`. Synchronous catalog/course/lesson and state -views have semantic/language cases. Actual product-page HTML/DOM must prove -titles, languages, one main, -one descriptive h1, sequential headings, semantic lists/definition lists, skip -target and visible focus, including all controlled states. Logical CSS and wrapping -must support RTL and long unbroken strings. Acceptance does not promote this -standard: it stays **Adopted** while full product/manual proof is pending. +views have semantic/language cases. Product-page HTML/DOM proofs now cover +titles, languages, one main, one descriptive h1, sequential headings, semantic +lists/definition lists and skip targets, including controlled states. Visible +focus, logical layout and long-string wrapping still require actual manual +observation. Acceptance does not promote this +standard: it stays **Adopted** while actual manual proof is pending. Step 4's +[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) +owns automated product execution and the remaining manual closeout. Catalog → course → lesson is a critical flow requiring manual keyboard, focus, 320 CSS px reflow/zoom, long-string, contrast and real screen-reader smoke evidence. diff --git a/docs/standards/README.md b/docs/standards/README.md index d09c9e10..6fe56644 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -80,7 +80,8 @@ loaders, chrome/states and atomic theme injection. Its [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) owns foundation validation and review evidence. Steps 2–3 implement actual catalog/course/lesson views, pagination/metadata and blocking JSX accessibility -controls. Full product/manual proof remains Step 4. P7 owns +controls. Step 4 implements automated product HTML/Flight proofs; manual +accessibility closeout remains pending. P7 owns demo/browser/Lighthouse. Acceptance alone promotes no standard or test status. **The documents and this table say the same thing, and a test holds them to it.** @@ -108,7 +109,7 @@ included. | 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) | **Adopted** | P6 Steps 2–3 deliver catalog/course/lesson pages. Accepted G43 requires applicable jsx-a11y error rules with actual-config planted controls, actual-page semantic/language proofs and manual keyboard/focus/reflow/contrast/screen-reader evidence. Step 1 supplies chrome/state semantics; Steps 2–3 add catalog/course/lesson semantics. Step 3 blocks all 31 active recommended jsx-a11y rules with per-rule actual-config dirty/clean controls; full product/manual evidence remains pending. Promotion occurs with enforcement. Full Playwright/axe remains [Phase 06](../roadmap/phase-06-renderer-admin-studio.md); Accepted G44 assigns Lighthouse to P02d-7 after P6. | +| 16 | [Accessibility](16-accessibility.md) | **Adopted** | P6 Steps 2–3 deliver catalog/course/lesson pages. Accepted G43 requires applicable jsx-a11y error rules with actual-config planted controls, actual-page semantic/language proofs and manual keyboard/focus/reflow/contrast/screen-reader evidence. Step 1 supplies chrome/state semantics; Steps 2–3 add catalog/course/lesson semantics. Step 3 blocks all 31 active recommended jsx-a11y rules with per-rule actual-config dirty/clean controls; Step 4 adds automated product HTML/Flight proofs and completes both review rounds. Actual manual evidence remains pending. Promotion occurs with enforcement. Full Playwright/axe remains [Phase 06](../roadmap/phase-06-renderer-admin-studio.md); Accepted G44 assigns Lighthouse to P02d-7 after P6. | | 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). | diff --git a/frontend/apps/web/scripts/verify-public-rendering.mjs b/frontend/apps/web/scripts/verify-public-rendering.mjs index 7ae82c4a..1cfa4cdb 100644 --- a/frontend/apps/web/scripts/verify-public-rendering.mjs +++ b/frontend/apps/web/scripts/verify-public-rendering.mjs @@ -1235,8 +1235,10 @@ function productContainment(response, tenant) { } function productTheme(doc, details, malformed = false) { - const styles = [...doc.querySelectorAll('style')].filter((element) => - element.textContent.includes('--ls-primary:'), + const styles = [...doc.querySelectorAll('style,[style]')].filter((element) => + /--ls-(?:primary|bg|fg|muted)\s*:/.test( + element.tagName === 'STYLE' ? element.textContent : element.getAttribute('style'), + ), ); const { primary, background, foreground, muted } = details.theme; assert.equal(styles.length, malformed ? 0 : 1, 'A malformed palette emits no partial override'); @@ -1346,7 +1348,9 @@ async function productMissing(name, tenant, path, locale, hidden) { productContainment(response, tenant); absentFromWholeResponse(response, [ ...hidden, - ...path.split('/').filter((segment) => segment.startsWith('p6-')), + // Own route segments can occur in the 307 Flight tree, but the fixed 404 + // destination must not reflect any requested course or lesson slug. + ...path.split('/').filter((_segment, index) => index === 3 || index === 5), ]); await checkpoint(name); } From 5d7aafcd7b8a16c301339dccc80d328fe19a0578 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 02:26:16 +0300 Subject: [PATCH 18/58] docs(roadmap): record manual P02d-6 browser observations Record actual keyboard, narrow-viewport and contrast evidence without substituting accessibility trees for the pending screen-reader smoke. ADR: 0027, 0051 --- docs/roadmap/phase-02d-walking-skeleton.md | 61 ++++++++++++++++++++-- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index acf210e4..d1b1d11b 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4490,10 +4490,10 @@ production-rendering cases pass, zero failures/skips. The new isolation/freshness modes observe exactly 43/52 API calls; the earlier foundation/pagination/presentation modes retain 52/18/46. Full backend regression passes as recorded below; both review rounds are complete after their fixes. -Manual accessibility closeout remains pending. No browser or screen-reader pass -is claimed here. -P6 completion and Accessibility promotion remain pending until actual keyboard, -focus, 320 CSS px reflow, contrast and screen-reader evidence exists. +Automated checks do not establish manual accessibility. The dated walkthrough +below records keyboard, focus, 320 CSS px reflow and contrast observations; +the actual screen-reader pass remains pending. P6 completion and Accessibility +promotion remain pending that final evidence. #### Step 4 review round 1 (2026-10-10) @@ -4571,6 +4571,59 @@ commit/environment/hosts/locales/assistive technology and fix any observed issue before closing P6 or promoting Accessibility and the five registered P6 rules. The packet remains in progress; P02d-7 and its Lighthouse job have not started. +#### Manual browser observations and remaining AT check (2026-10-10) + +The fixture is restarted and both product pages load after the user handles the +temporary certificate interstitial. The tested production routes are unchanged +at `180dde0`; the disposable fixture adds Arabic translations and a 240-character +unbroken `W` value. Observation uses Brave 154.1.96.61 on macOS 27.0.1, with the +real API/application role and native HTTPS ingress. The manual fixture uses the +default entitlement provider; the distinct attribution branches are proved by +the automated product-isolation mode, not by this walkthrough. +The tested hosts are `english.localhost:3000` (`en`, `ar`) and +`yoga.localhost:3000` (`tr-TR`, `ar`). + +- Keyboard-only English catalog → English foundations → Present simple and + Turkish Yoga catalog → Stüdyo temelleri → Ağaç duruşu navigation succeeds. + The skip link becomes visible on first Tab, Enter moves to main content and + subsequent links follow content order. Blue/green focus outlines are visible. +- DevTools explicitly reports a 320 CSS px viewport. English and Turkish + catalogs, the Turkish course and lesson, and both hosts' Arabic lessons reflow + without observed horizontal clipping or overlapping text. The long unbroken + value wraps; scrolling reaches the subsequent labelled fields. Arabic authored + content remains right-to-left while fallback UI/definition labels remain + left-to-right. This is a desktop responsive-viewport observation, not a mobile + device or zoom-conformance claim. +- At 320 CSS px, the English invalid-cursor view has readable status text and a + visible focused reset link; Enter opens the query-free catalog. A missing Yoga + lesson arrives at `/tr-TR/status/not-found`; the Turkish branded status and + focused catalog recovery link fit, and Enter returns to the catalog. HTTP + status/noindex assertions remain automated evidence. + +Contrast is calculated from the exact four-color palettes separately from the +visual observations, using WCAG relative luminance. Ratios against each theme's +background are: + +| Palette | Foreground | Muted text | Primary focus outline | +|---|---|---|---| +| English | 17.740:1 | 7.557:1 | 6.702:1 | +| Yoga | 14.628:1 | 7.357:1 | 6.876:1 | +| Local defaults | 17.853:1 | 4.759:1 | 4.634:1 | + +These exceed 4.5:1 for the text pairs and 3:1 for the focus pair. They do not +constitute a full WCAG audit. Normal viewport/zoom are restored after observation. + +**Real screen-reader evidence is still pending.** VoiceOver is initially off; +its caption-panel preference is already on. Enabling VoiceOver through System +Settings changes the switch to on, but the native app inventory still reports +VoiceOver as not running; no speech or caption output is observed. Direct +VoiceOver app access times out; VO navigation produces no observed output. +The switch is restored to off without changing the caption preference. +Accessibility-tree inspection is not substituted for an +actual screen-reader pass. The maintainer is asked to perform the remaining +English/Turkish catalog → course → lesson screen-reader smoke. P6 and standards +promotion remain pending that evidence; no production defect is demonstrated. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved From 27a02ba4a4d7f4152a4d4b2549ea29dc1ad589cc Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 02:36:36 +0300 Subject: [PATCH 19/58] docs(roadmap): keep P02d-6 screen-reader closeout pending Record the maintainer-requested wait and clean fixture shutdown while retaining the accepted completion gate and observed browser evidence. ADR: 0027 --- docs/roadmap/phase-02d-walking-skeleton.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index d1b1d11b..5c55e495 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4624,6 +4624,13 @@ actual screen-reader pass. The maintainer is asked to perform the remaining English/Turkish catalog → course → lesson screen-reader smoke. P6 and standards promotion remain pending that evidence; no production defect is demonstrated. +The maintainer explicitly leaves the real screen-reader check pending on +2026-10-10; this does not waive the accepted completion requirement. The +temporary fixture stops cleanly with child exit 0, ports 3000/3011 are free and +its disposable test container is gone. Existing developer services remain +untouched. Resume this final check in a fresh manual fixture before packet +closeout, standards promotion or the completed-implementation PR handoff. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved From 7d982286effa53b4ee398a2a68c33bb74d467c41 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 02:56:11 +0300 Subject: [PATCH 20/58] docs: close P02d-6 manual accessibility and delivery Record the maintainer VoiceOver pass alongside the earlier manual and automated evidence so packet and enforcement statuses agree. Preserve accepted ADR history and keep PR merge and P7 delivery separate. ADR: 0027, 0051 --- .claude/skills/run-tests-locally/SKILL.md | 4 +- CLAUDE.md | 6 +- README.md | 5 +- docs/architecture/12-localization.md | 4 +- docs/architecture/14-frontend-architecture.md | 8 ++- .../32-tenant-customization-model.md | 2 +- docs/decisions/0027-frontend-i18n.md | 16 +++++ .../0051-ordered-text-card-presentation.md | 22 +++++++ docs/decisions/README.md | 4 +- docs/roadmap/README.md | 3 +- docs/roadmap/phase-02d-walking-skeleton.md | 61 +++++++++++++++++-- docs/roadmap/phase-04-cms-media-pages.md | 9 +-- .../phase-05-education-learning-content.md | 10 +-- .../roadmap/phase-06-renderer-admin-studio.md | 6 +- .../phase-07-enrollment-learner-portal.md | 9 +-- docs/standards/06-testing.md | 2 +- docs/standards/07-frontend-architecture.md | 2 +- docs/standards/08-localization.md | 2 +- docs/standards/16-accessibility.md | 13 ++-- .../21-architecture-tests-catalogue.md | 35 ++++++++--- docs/standards/README.md | 14 ++--- frontend/README.md | 3 +- frontend/apps/web/README.md | 3 +- 23 files changed, 182 insertions(+), 61 deletions(-) diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index caad07d7..c1aa9b03 100644 --- a/.claude/skills/run-tests-locally/SKILL.md +++ b/.claude/skills/run-tests-locally/SKILL.md @@ -178,8 +178,8 @@ accessibility closeout remains required and P7 owns the browser demo. > 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. Actual manual evidence remains -> pending in the P6 delivery record. P5's production HTML/RSC mode proves transport; +> 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. ### Step 7: Single-test focus diff --git a/CLAUDE.md b/CLAUDE.md index 4bffe4f0..f3b063aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,7 +133,11 @@ 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 and the pending manual accessibility handoff. +records both completed review rounds. **P02d-6 implementation is complete — +2026-10-10, unmerged**: 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. P02d-7 owns the browser demo and Lighthouse; Phase 06 retains the full renderer/Studio expansion. diff --git a/README.md b/README.md index 5c9c0480..1694122b 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,8 @@ required OpenAPI check is active, with all other live protection settings preser Trusted server rendering is delivered by P02d-5. P02d-6 implements localized catalog/course/lesson pages, ordered text fields, safe themes and metadata; its [product closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) -records completed automated verification/reviews and pending manual accessibility. +records completed automated verification/reviews and passing manual accessibility. +P02d-6 implementation is complete; maintainer PR review and merge remain pending. The browser demo remains P02d-7. 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 @@ -110,7 +111,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 and generated typed SDK | Public-page accessibility closeout in P02d-6; two-tenant browser demo 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 | Two-tenant browser demo and Lighthouse in [P02d-7](docs/roadmap/phase-02d-walking-skeleton.md) | **Four modules contain domain implementations:** Tenancy, Customization, Audit and Education. Identity, Content and Media remain scaffolded. diff --git a/docs/architecture/12-localization.md b/docs/architecture/12-localization.md index 4c3f072e..3e86c807 100644 --- a/docs/architecture/12-localization.md +++ b/docs/architecture/12-localization.md @@ -215,8 +215,8 @@ Pattern B is cheaper for short fields where joining a translation table is overk [ADR-0008](../decisions/0008-localization-schema.md); the standard links here. [P02d-3's decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) records acceptance. Step 1 implements locale-carrying resolution; both review -rounds passed. P02d-4 Step 3 supplies public response fields; language attributes -remain P02d-6. +rounds passed. P02d-4 Step 3 supplies public response fields; P02d-6 delivers +language attributes. When the requested locale is unavailable: diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index 75395d76..b0575822 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -38,7 +38,7 @@ independent opaque pagination and metadata. Step 3 adds ordered lesson fields, language attribution and eligible metadata. Step 4 adds concurrent host/locale, theme, freshness and content-fault product proofs. The [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) -owns validation, review status and pending manual accessibility closeout. +owns validation, completed reviews and passing manual accessibility closeout. > **P02d-4 Accepted design — 2026-10-03.** Step 2 delivers site bootstrap; > Steps 3–4 deliver Education and contract/SDK controls; P02d-5 owns the server consumer. @@ -430,8 +430,10 @@ harness and assertions. The decision is recorded in jsx-a11y error rules with planted controls. Manual keyboard, focus, reflow/zoom, contrast and real screen-reader evidence for catalog → course → lesson names commit, environment and both hosts/locales. Automated product proofs and blocking - lint controls are implemented; manual evidence and packet completion remain - pending. See + lint controls are implemented; the + [packet closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) + records passing manual browser checks and maintainer-reported VoiceOver evidence. + See [Testing Standards](../standards/06-testing.md#frontend-test-types); HTTP/RSC proofs do not claim browser E2E or full Playwright/axe delivery. - Color contrast is verified for every branded theme, including the merged tenant and diff --git a/docs/architecture/32-tenant-customization-model.md b/docs/architecture/32-tenant-customization-model.md index 3db3de3a..23faa835 100644 --- a/docs/architecture/32-tenant-customization-model.md +++ b/docs/architecture/32-tenant-customization-model.md @@ -782,7 +782,7 @@ arrives with the aggregate it edits, per [§ 12](#12-phasing). > P02d-4's accepted G16(f/g) contract applies baseline colors independently of plan; > effective `tenancy.white_label_branding` removes LearnStack attribution only. > Public theme/attribution projection is delivered by P02d-4 Step 2; -> browser rendering remains P02d-6. Advanced Studio branding remains +> browser rendering is delivered by P02d-6. Advanced Studio branding remains > Phase 06 scope, separate from the four-color contract. ## 11. Hard architectural invariants diff --git a/docs/decisions/0027-frontend-i18n.md b/docs/decisions/0027-frontend-i18n.md index 296c1343..2ae0dfa7 100644 --- a/docs/decisions/0027-frontend-i18n.md +++ b/docs/decisions/0027-frontend-i18n.md @@ -305,6 +305,22 @@ or historical amendment. Phase 04 consumes the implemented UI foundation; Phase 06 owns the full renderer/Studio and Playwright/axe expansion, and P02d-7 owns the browser demo and Lighthouse activation. +### Amendment 3 — P02d-6 manual accessibility closeout (2026-10-10) + +P02d-6 completes its four implementation steps and two independent review rounds +per step. The [packet closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) +records keyboard, focus, 320 CSS px desktop reflow and calculated contrast +observations, followed by the maintainer's passing real VoiceOver checks on the +English and Turkish catalog → course → lesson flows and missing/invalid-link +recovery views. The earlier pending amendments remain historical. + +Accessibility Standards becomes Active for delivered public-page enforcement; +the five P6 catalogue entries become Implemented with named existing tests. +Implementation is complete; maintainer PR review and merge remain separate. +This delivery note changes no accepted decision and claims no full WCAG audit, +Arabic screen-reader check or zoom-conformance result. P02d-7 still owns the +browser demo/Lighthouse and Phase 06 owns full Playwright/axe expansion. + ## References - [ADR-0008 — Localization Schema](0008-localization-schema.md) diff --git a/docs/decisions/0051-ordered-text-card-presentation.md b/docs/decisions/0051-ordered-text-card-presentation.md index ce8151a4..05fee628 100644 --- a/docs/decisions/0051-ordered-text-card-presentation.md +++ b/docs/decisions/0051-ordered-text-card-presentation.md @@ -2,6 +2,13 @@ ## Status +> **Implementation update — 2026-10-10.** +> P02d-6 delivers ordered plain-text lesson rendering, bounded fallbacks and +> inert HTML/script/URL-like value proofs. Product HTML/RSC checks and manual +> accessibility smoke are complete; PR review and merge remain separate. See +> [Amendment 4](#amendment-4--p02d-6-renderer-delivery-2026-10-10). +> Earlier delivery disclosures and the acceptance-time statement are historical. + > **Implementation update — 2026-10-05.** > P02d-4 delivers ordered public descriptor/content projection after eligibility, > including bounded unavailable results. The dated 2026-10-02 disclosure below @@ -220,3 +227,18 @@ The Decision and acceptance-time status are unchanged. A new dated disclosure inside Status points to this amendment without rewriting earlier records. Updated carriers: this Status disclosure, the corresponding ADR-0050/0052 delivery disclosures, the Education module spec and the P02d-4 delivery record. + +### Amendment 4 — P02d-6 renderer delivery (2026-10-10) + +P02d-6 completes ordered plain-string lesson rendering with authored field order, +resolved label language/direction, omitted absent optional fields and bounded +unavailable states. HTML/script/URL-like values remain inert text. Unit and +production HTML/RSC cases cover the actual product routes; the +[packet closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) +records automated evidence, two review rounds per step and passing manual +browser/maintainer VoiceOver smoke. PR review and merge remain separate. + +The new Status disclosure points to this delivery note. Earlier amendments, +Decision, acceptance Status, Date and Deciders remain unchanged. Richer +primitives, media, Markdown/HTML sanitization and the full registry retain their +Phase 04/05/06 owners; P02d-7 still owns the browser demo and Lighthouse. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 8d0293c9..297de2ac 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -46,7 +46,7 @@ an amendment is not a lifecycle status change. | 0022 | [Custom Domain & TLS](0022-custom-domain-tls.md) | Hub-owned custom domain admin; DNS-01 + HTTP-01 + Let's Encrypt; APISIX hot-reload | | 0023 | [Strongly-Typed ID Source Generator — Vogen](0023-strongly-typed-id-source-generator.md) | Vogen as the source generator for both IDs and value objects; `[ValueObject<Guid>]` annotation; EF + JSON + ASP.NET + OpenAPI emitters out of the box (Amendment 1: Vogen 7.0.0 pin + architecture-test placement, 2026-05-21; Amendment 2: `UserId` / `TenantId` / `OrganizationId` are cross-cutting and live in `LearnStack.SharedKernel`, 2026-08-10; **Amendment 3: `IStronglyTypedId` gains `IsInitialized()` and is no longer a pure marker, 2026-08-10**; Amendments 4–7, 2026-08-27 to 2026-09-01: `idempotency_keys` mints no id, `gen_uuid_v7()` is not a PostgreSQL function, the retroactive disclosure of the 2026-05-21 edit, and an id's own formatting is not a wire format; **Amendment 8, 2026-09-07: a module-local identifier crosses a cross-module contract as `Guid`**) | | 0024 | [API Versioning Policy](0024-api-versioning-policy.md) | URL-based `/v{N}/`; 6-month deprecation window; RFC 8594 `Sunset` + `Deprecation` headers; OpenAPI `deprecated` + `x-sunset` extensions; 410 Gone with RFC 7807 on sunset | -| 0027 | [Frontend UI Localization with next-intl](0027-frontend-i18n.md) | Accepted 2026-10-09: next-intl 4.14.9, server-first app-local ICU catalogues, dotted UI keys and verified request-locale source; P02d-6 implements the foundation, catalog/course/lesson pages and automated product proofs ([Amendment 2](0027-frontend-i18n.md#amendment-2--p02d-6-pages-and-automated-product-proof-2026-10-10)); manual accessibility closeout remains pending; Phase 04 consumes the foundation | +| 0027 | [Frontend UI Localization with next-intl](0027-frontend-i18n.md) | Accepted 2026-10-09: next-intl 4.14.9, server-first app-local ICU catalogues, dotted UI keys and verified request-locale source; P02d-6 completes the foundation, catalog/course/lesson pages, automated product proofs and manual accessibility ([Amendment 3](0027-frontend-i18n.md#amendment-3--p02d-6-manual-accessibility-closeout-2026-10-10)); Phase 04 consumes the foundation | | 0028 | [`audit_log` Partition Management — Hangfire Recurring Job](0028-audit-log-partition-management.md) | Daily `learnstack:audit:partition-management` Hangfire job; create-ahead 2 months; drop only on platform-max retention horizon; row-level purge separate; no `pg_partman` dependency (Amendment 2026-09-07: Packet 9 creates a **plain, unpartitioned** table and executes none of this ADR's DDL; `Partition_Manager_Job_Is_Registered_AtStartup` moves to Phase 11 with the job it guards) | | 0029 | [Object Storage — SeaweedFS](0029-object-storage-seaweedfs.md) | Self-hosted SeaweedFS behind the existing `IStorageProvider` S3 contract; partially supersedes ADR-0002's MinIO row | | 0030 | [Redis-compatible Store — Valkey](0030-redis-compatible-store-valkey.md) | Valkey (Linux Foundation, BSD-3-Clause) for the cache + Dapr state-store backend; RESP-protocol drop-in; partially supersedes ADR-0002's Redis row | @@ -66,7 +66,7 @@ an amendment is not a lifecycle status change. | 0044 | [The Audit Write Path](0044-audit-write-path.md) | **Amendments 1–5 (2026-09-08), 6–7 (2026-09-11).** The eleven questions ADR-0033 left open, decided before the first audit file: the reserved **non-nil** platform sentinel tenant id (the nil uuid is refused by three shipped guards that read all-zero as *no tenant*), and a `tenants` CHECK that keeps it unprovisionable; the row's `tenant_id` is the tenant the **transaction announced**, so a provisioning command audits under the tenant it creates and `make seed` still commits; **N intents per request**, one per audited resource, because two shipped commands already write two — and only the **owning** unit-of-work frame writes them or reports the commit boundary, which is what stops a nested `Send` from claiming durability nothing committed; `outcome` closes at four values (`indeterminate` included) and the writer always supplies `timestamp`, so ADR-0033's deliberate duplicate-id pair is legal under `(id, timestamp)` rather than a `23505`; the catalogue is **code** keyed by request type and the matrix is prose, with a test asserting they agree, and every request must be classified — there is no `RequestKind.Other`; the interceptor captures **every** tracked entity minus a named exclusion list, because the `AuditableEntity<>` predicate was blind to `platform_host_to_tenant`; `[PiiSensitive]` and an elision record that preserves the column's JSON type; `audit_log` is org-scoped, carries **no** FK to `tenants`, and both standalone writers announce **two** GUCs — measured: an org-scoped `denied` row is refused without the second. Amendment 1 makes `audit_config` tenant-**wide**; Amendment 2 corrects the redaction sentinel to the shipped constant and the reason `FORCE` needs a trigger (the policy binds the owner by tenant, not by immutability — measured); Amendment 3 gives the matrix↔catalogue join two directions with two domains, because 23 of 30 shipped matrix slugs have no request type and the rule as written was red on arrival, and moves the audit value types into `SharedKernel.Audit` to break the same project cycle `AuditEntryId` had; Amendment 4 makes `[PiiSensitive]` property-granular, `jsonb` included, ships `audit_config` unwritten and binds the matrix rule to modules with code; Amendment 5 guards the sentinel where it is announced and fills `entity_type` / `entity_id` from the declared aggregate; Amendment 6 — from an external review that reproduced four defects against real PostgreSQL — has a handler designate the subject when it writes two instances of its aggregate, carries the actor and correlation id on the intent, keeps each intent's own request's result, captures a contained entity with its aggregate, and instance-qualifies `changes` pointers; Amendment 7 corrects § 7's count of plain entities — seven today, five of them MUST — and names the Tenancy matrix alone as the one that singles out the host mapping | | 0045 | [The Entitlement and Feature-Flag Socket](0045-entitlement-and-feature-flag-socket.md) | **Amendment 1 (2026-09-08)** reads it against the Hub's merged code: the limit vocabulary is the Hub's, `expires_at`/`valid_until` are nullable, the generation guard admits the equal case, and `platform_killswitches` ships **unwritten** because `DenyAllPlatformAdminGate` makes a writer unreachable until Phase 03. **Amendment 2 (2026-09-11):** a registry's membership is the vocabulary the contract names — fourteen Hub features, nine `limits.*`, two tenant flags, three killswitches — and enforcement, not membership, waits for a consumer. **Amendment 3 (2026-09-11):** a key's fail-open/fail-closed class decides only when no projection exists; one past its grace window is read-only, as ADR-0021 decides. **Amendment 4 (2026-09-11):** of the projection's names, `PlanCode` differs from the wire (`tier`) and `ExpiresAt` from the column (`valid_until`) — § 1 had called both wire differences. Declares the port twenty documents name and none define. `IEntitlementProvider.GetAsync(TenantId)` + `RefreshAsync(projection)`, the projection carrying every field `entitlement-v1.schema.json` requires — **`compliance` and `generation` included**, both already `NOT NULL` columns — and the refresh **generation-guarded inside the write statement**, so a stale push cannot resurrect a revoked plan. `IFeatureFlags` is the only module-facing read and **composes over the provider** rather than querying `platform_entitlement_cache`, which is the only reading under which the Phase 02a criterion — swapping the provider changes the answer — can be true. Limits: **`-1` unlimited, `0` denied**, `long` not `long?`; the inverse reading would have made the degraded read-only mode grant unlimited. `NullEntitlementProvider` is registered in **every** deployment mode, per ADR-0035's default-implementation gate rather than ADR-0020's Development-only switch. Killswitches leave `tenant_feature_flags` for a platform-scoped `platform_killswitches`: a foreign key to `tenants` is a constraint no role or `BYPASSRLS` moves, so the sentinel could never have satisfied it. Push-primary, **not** push-only — ADR-0034's `license/verify` fallback stands; the no-Hub absolute belongs to host resolution | | 0050 | [Publication and Course Content Access](0050-publication-and-course-content-access.md) | Accepted 2026-10-02: publication/access separation, restricted backfill and anonymous denial; supersedes ADR-0048; P02d-2 policy, migration, writers and seed delivered ([Amendment 1](0050-publication-and-course-content-access.md#amendment-1--p02d-2-implementation-delivery-2026-10-02)); P02d-4 public-read enforcement delivered, grants Phase 07 | -| 0051 | [Ordered Text Card Presentation](0051-ordered-text-card-presentation.md) | Accepted 2026-10-02: optional root `x-fields`, localized ordered text cards; compatible extension linked by ADR-0043 Amendment 5; P02d-2 profile parsing, resolution and seed verification delivered ([Amendment 1](0051-ordered-text-card-presentation.md#amendment-1--p02d-2-implementation-delivery-2026-10-02)); P02d-4 public projection delivered; P02d-6 implements ordered plain-text rendering and automated product proofs, with manual accessibility closeout pending | +| 0051 | [Ordered Text Card Presentation](0051-ordered-text-card-presentation.md) | Accepted 2026-10-02: optional root `x-fields`, localized ordered text cards; compatible extension linked by ADR-0043 Amendment 5; P02d-2 profile parsing, resolution and seed verification delivered ([Amendment 1](0051-ordered-text-card-presentation.md#amendment-1--p02d-2-implementation-delivery-2026-10-02)); P02d-4 public projection delivered; P02d-6 completes ordered plain-text rendering, automated product proofs and manual accessibility smoke | | 0052 | [Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md) | Accepted 2026-10-03: host-resolved bootstrap, immutable host ceiling, mandatory marked dispatch and read-only ambient frames; ADR-0036/0040 Amendment 8; P02d-4 Steps 1–3 deliver host provenance, physical read-only frames and all institution GET/HEAD reads; both Step 3 review rounds pass; Step 4 delivers OpenAPI/SDK/CI controls; both review rounds and focused fix verification pass; six real required CI jobs and live sixth-check activation verified; P02d-4 complete and merged through PR #25 on 2026-10-08 ([Amendment 13](0052-anonymous-public-read-boundary.md#amendment-13--merge-closeout-2026-10-08)) | | 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)) | diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 61035a23..e233eb03 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -72,7 +72,8 @@ not deferred to the showcase phase. P02d-6's ADR-0027 and decision package are Accepted — 2026-10-09. Its localized catalog/course/lesson pages and product proofs are implemented; the [closeout record](phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) - tracks reviews and remaining manual accessibility. P7 owns the browser/demo. + records completed reviews and passing manual accessibility. Implementation is + complete; maintainer PR review and merge remain pending. P7 owns the browser/demo. - [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) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 5c55e495..4ca18ac9 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -14,7 +14,7 @@ > | 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 | in progress — decision pass Accepted 2026-10-09; [decision package](#p02d-6-decision-package-2026-10-09); [Step 1](#p02d-6-step-1-localization-and-document-foundation), [Step 2 catalog/course](#p02d-6-step-2-catalog-and-course-pages), [Step 3 lesson](#p02d-6-step-3-ordered-lesson-presentation) and [Step 4 product proof](#p02d-6-step-4-product-proof-and-accessibility-closeout) implemented with both review rounds complete; actual manual accessibility closeout pending | +> | P02d-6 | Public renderer | ✅ implementation complete — 2026-10-10; all four steps and both review rounds per step complete; manual accessibility 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 | **Acceptance update — 2026-10-02.** P02d-1 remains merged. The maintainer accepted @@ -119,6 +119,13 @@ implementation steps. The acceptance commit closes its named gate parts and reconciles their carriers before code. Every step uses commits and two fresh independent review rounds. Development remains the working branch. +**P02d-6 implementation complete — 2026-10-10, unmerged.** All four steps and +both independent review rounds per step are complete. The +[packet closeout](#p02d-6-packet-closeout-2026-10-10) records automated verification, +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. + ## Goal Put a working education site in a browser — twice, on two hosts, for two tenants in @@ -4452,7 +4459,7 @@ explicit. ### P02d-6 Step 4: Product proof and accessibility closeout -**Implemented; manual closeout pending — 2026-10-10.** The unchanged product routes now +**Complete — 2026-10-10.** The unchanged product routes now have dedicated `product-isolation` and `product-freshness` modes in `PublicServerRenderingTests` / `verify-public-rendering.mjs`. P5's synthetic transport mode and the foundation, pagination and presentation modes remain @@ -4492,8 +4499,8 @@ foundation/pagination/presentation modes retain 52/18/46. Full backend regressio passes as recorded below; both review rounds are complete after their fixes. Automated checks do not establish manual accessibility. The dated walkthrough below records keyboard, focus, 320 CSS px reflow and contrast observations; -the actual screen-reader pass remains pending. P6 completion and Accessibility -promotion remain pending that final evidence. +the maintainer's subsequent VoiceOver confirmation closes the remaining +screen-reader check in the packet closeout below. #### Step 4 review round 1 (2026-10-10) @@ -4631,6 +4638,52 @@ its disposable test container is gone. Existing developer services remain untouched. Resume this final check in a fresh manual fixture before packet closeout, standards promotion or the completed-implementation PR handoff. +#### P02d-6 packet closeout (2026-10-10) + +The preceding pending notes record earlier attempts. The manual fixture is +restarted from unchanged production routes at `180dde0`, using the same +Brave/macOS environment recorded above, real API/application role, read-only +transactions and native HTTPS. The maintainer performs the actual VoiceOver +smoke and explicitly confirms both checks: + +- English (`english.localhost:3000/en`): catalog → English foundations → + Present simple passes without an observed issue. +- Yoga (`yoga.localhost:3000/tr-TR`): catalog → Stüdyo temelleri → Ağaç duruşu + passes without an observed issue. +- On both flows, headings, links, skip navigation and lesson field label → value + order are understandable. The maintainer also confirms reading the missing-page + and invalid-link messages and following their catalog recovery links. + +This is maintainer-reported real VoiceOver evidence, separate from the agent's +earlier keyboard, focus, 320 CSS px desktop reflow and calculated contrast checks. +It does not claim a full WCAG audit, mobile-device test, zoom-conformance result +or Arabic screen-reader pass. The owned fixture stops cleanly with child exit 0; +ports 3000/3011 are free, its disposable container is removed and existing +developer services remain untouched. + +All four implementation steps and both fresh review rounds per step are complete. +The automated totals and scope remain those recorded above: 2,945 passing backend +cases and 1,065 passing guarded frontend cases, zero failures/skips/todos. +The manual closeout permits Accessibility Standards to become Active and the +five P6 catalogue entries to become Implemented; the automated checks named by +those entries are already present. The closure change updates documentation only. +Final corpus/link validation and two fresh documentation reviews follow before +the implementation PR is handed to the maintainer. + +Closeout validation passes all 198 Release architecture cases, including the +corpus guards, with zero failures/skips, and all seven ADR-workflow tests. A local +inline-link audit, excluding fenced and inline code, checks 2,217 relative links +and 678 fragments in 34 Markdown files changed against `origin/main`; no file or +fragment is missing. This remains a manual audit, not a new CI anchor check. +Added prose and diff checks pass. No production code or test changes accompany +this closeout. + +P02d-6 implementation is complete; PR review and merge remain separate. P02d-7 +owns `make demo`, the browser/full-stack CI harness, Lighthouse activation and the +Phase 02d exit checks. Its decision pass must close G20/G33/G38/G44/G45's remaining +parts before implementation. Full Playwright/axe and the expanded renderer/Studio +remain Phase 06; no later packet is delivered by this closeout. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/roadmap/phase-04-cms-media-pages.md b/docs/roadmap/phase-04-cms-media-pages.md index ef33d580..ce8e8184 100644 --- a/docs/roadmap/phase-04-cms-media-pages.md +++ b/docs/roadmap/phase-04-cms-media-pages.md @@ -8,8 +8,8 @@ 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 remain P02d-6; the two-host browser/demo proof remains P02d-7. Its bounded -renderer uses fixed section names and ADR-0051's ordered plain-text profile. +pages are delivered by P02d-6; the two-host browser/demo proof remains P02d-7. +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. @@ -39,8 +39,9 @@ this phase's responsibility. The [P02d-6 package](phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09) records accepted page/status behavior; its [Step 1 delivery record](phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) -owns foundation verification. Actual content pages and product proofs remain -later P02d-6 steps. +owns foundation verification. Steps 2–4 deliver actual content pages and product +proofs; the [packet closeout](phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) +records passing manual accessibility smoke. ## Scope diff --git a/docs/roadmap/phase-05-education-learning-content.md b/docs/roadmap/phase-05-education-learning-content.md index 4106871b..34c23230 100644 --- a/docs/roadmap/phase-05-education-learning-content.md +++ b/docs/roadmap/phase-05-education-learning-content.md @@ -15,7 +15,7 @@ each with its own publication state and translation satellites; an ordered lesso list; and ADR-0051's bounded ordered plain-text lesson presentation (G18 in [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register)). P02d-4 delivers the public projection and P02d-5 its trusted transport; actual -product pages remain P02d-6. Phase 05 adds the structure a real catalog needs: +product pages are delivered by P02d-6. Phase 05 adds the structure a real catalog needs: programs, versioning, modules, lesson items, and tenant-defined item types. @@ -60,8 +60,10 @@ 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 backfill and writers; P02d-4 delivered eligible public reads. Preserve those -contracts through this phase's version/module migration. Renderer and browser -proof remain P02d-6/7, not a prerequisite silently claimed as shipped here. +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. Phase 05 does not re-create these. Phase 02d's [delivery status](phase-02d-walking-skeleton.md#delivery-record-p02d-1) distinguishes @@ -81,7 +83,7 @@ Each change follows [Database Standards § Migrations](../standards/05-database. | The customization definition read path — content types and taxonomies through the generation-keyed cache ([32-tenant-customization-model.md § 8.2](../architecture/32-tenant-customization-model.md#82-cache-strategy)) | The `TenantLessonItemType` read, the batched reference walk and the measured cost model | | `[TenantOwned]` markers, EF query filters, RLS policies | The same layers on every new table, with no exception | -**Accepted renderer inheritance — 2026-10-09; implementation pending.** +**Accepted renderer inheritance — 2026-10-09; implemented by P02d-6.** The [P02d-6 package](phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09) fixes exact-locale content, both opaque pagination surfaces, inert plain-text presentation and distinct restricted/empty/unavailable states. Its local 307→404 diff --git a/docs/roadmap/phase-06-renderer-admin-studio.md b/docs/roadmap/phase-06-renderer-admin-studio.md index aab7782f..7c286ce2 100644 --- a/docs/roadmap/phase-06-renderer-admin-studio.md +++ b/docs/roadmap/phase-06-renderer-admin-studio.md @@ -8,8 +8,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 manual accessibility -closeout pending. P02d-7 owns the unbuilt browser/demo harness. This phase deepens +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, tenant error pages, complete block composition and editing surfaces. @@ -22,7 +22,7 @@ and a non-developer tenant admin can maintain it. ### What Phase 02d supplies -Rows identify implemented P02d-6 scope; its closeout remains pending. Planned +Rows identify implemented P02d-6 scope; its PR review and merge remain pending. Planned P02d-7 work is not current behavior. | Phase 02d foundation | Phase 06 adds | diff --git a/docs/roadmap/phase-07-enrollment-learner-portal.md b/docs/roadmap/phase-07-enrollment-learner-portal.md index 837c668d..8ee5efe2 100644 --- a/docs/roadmap/phase-07-enrollment-learner-portal.md +++ b/docs/roadmap/phase-07-enrollment-learner-portal.md @@ -30,9 +30,10 @@ Worth stating plainly, because a walking skeleton is easy to over-read: [Phase 02d](phase-02d-walking-skeleton.md) is **anonymous and read-only**. Its API and trusted server transport are delivered through P02d-5; three product pages -(catalog, course-detail/outline and lesson) remain P02d-6, with browser/demo proof -in P02d-7. There is no learner, enrollment, access grant, progress row or -learner-side write path. The anonymous lesson has no "mark complete" control. +(catalog, course-detail/outline and lesson) are delivered by P02d-6, with +browser/demo proof in P02d-7. There is no learner, enrollment, access grant, +progress row or learner-side write path. The anonymous lesson has no "mark complete" +control. Manual enrollment, invitation enrollment, lesson progress and resume-learning are new construction in this phase. The portal shell belongs to @@ -42,7 +43,7 @@ What Phase 07 inherits from 02d is narrow and read-side only: `Course` and host-based tenant and organization resolution works end to end, and the public content path exists to contrast the entitled one against. -**Accepted UI inheritance — 2026-10-09; implementation pending in P02d-6.** +**Accepted UI inheritance — 2026-10-09; implemented by P02d-6.** [ADR-0027](../decisions/0027-frontend-i18n.md) selects exact `next-intl` 4.14.9 and app-local `src/i18n/messages/<locale>/<namespace>.json`. Learner screens add feature UI namespaces through the Phase 06 portal shell, preserving complete diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index e76619ee..1eace866 100644 --- a/docs/standards/06-testing.md +++ b/docs/standards/06-testing.md @@ -202,7 +202,7 @@ read-only transactions, native HTTPS, closed hop headers and fixture containment Step 4 adds deterministic overlapping host/locale lanes, authored RTL content, whole-theme fallback, next-document freshness and one-shot content-failure modes. The [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) -owns execution and review evidence; manual accessibility remains pending. +owns execution, review evidence and the completed manual accessibility smoke. Assert visible HTML/DOM separately from Flight: status, `lang`/`dir`, headings, theme, attribution, both hosts/locales, cross-tenant absence, overlapping concurrent diff --git a/docs/standards/07-frontend-architecture.md b/docs/standards/07-frontend-architecture.md index 8f476a86..9e41acf9 100644 --- a/docs/standards/07-frontend-architecture.md +++ b/docs/standards/07-frontend-architecture.md @@ -245,7 +245,7 @@ loaders, chrome, state views and atomic theme injection. Step 2 adds catalog/cou views; Step 3 adds ordered lesson views. Step 4 adds concurrent host/locale, theme, freshness and failure-state product proofs. The [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) -owns verification, review status and pending manual accessibility evidence. +owns verification, completed reviews and passing manual accessibility evidence. P5 delivers the dynamic layout, transport and source/runtime proofs. Test-owned production routes exercise the real API. P6 product modes copy the actual public routes separately; the synthetic transport mode cannot shadow those pages. diff --git a/docs/standards/08-localization.md b/docs/standards/08-localization.md index e7b901e6..c011eb10 100644 --- a/docs/standards/08-localization.md +++ b/docs/standards/08-localization.md @@ -314,7 +314,7 @@ UI independently; it neither rewrites the seed nor claims an Arabic UI catalogue Step 1 implements direction selection and labelled fallback chrome; Step 2 proves an empty admitted Arabic catalog in production. Step 3 adds per-field label direction. Step 4 adds eligible authored RTL content with concurrent -host/locale HTML/Flight proofs. Actual manual accessibility remains pending in +host/locale HTML/Flight proofs. Passing manual accessibility is recorded in the [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout). ## Admin Studio UI diff --git a/docs/standards/16-accessibility.md b/docs/standards/16-accessibility.md index 902bb55f..f60ebfa0 100644 --- a/docs/standards/16-accessibility.md +++ b/docs/standards/16-accessibility.md @@ -1,6 +1,6 @@ # 16 — Accessibility Standards -**Status:** Adopted +**Status:** Active **Derives from:** WCAG 2.2 AA (external authoritative standard), [00-principles.md](00-principles.md) § 6 (Foundation First). Public-page obligations also follow [ADR-0027](../decisions/0027-frontend-i18n.md) and the [Accepted P02d-6 package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09). @@ -108,12 +108,13 @@ clean controls through the actual app configuration in `src/test/accessibility-lint.test.ts`. Synchronous catalog/course/lesson and state views have semantic/language cases. Product-page HTML/DOM proofs now cover titles, languages, one main, one descriptive h1, sequential headings, semantic -lists/definition lists and skip targets, including controlled states. Visible -focus, logical layout and long-string wrapping still require actual manual -observation. Acceptance does not promote this -standard: it stays **Adopted** while actual manual proof is pending. Step 4's +lists/definition lists and skip targets, including controlled states. Actual +keyboard, focus, 320 CSS px reflow, long-string and contrast observations plus +the maintainer's passing VoiceOver smoke complete P6's manual requirement on +2026-10-10. Enforcement and recorded observation promote this standard to +**Active** for delivered public-page scope. Step 4's [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) -owns automated product execution and the remaining manual closeout. +owns automated execution and manual evidence; this is not a full WCAG audit. Catalog → course → lesson is a critical flow requiring manual keyboard, focus, 320 CSS px reflow/zoom, long-string, contrast and real screen-reader smoke evidence. diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index f603c403..a4265b17 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -6,7 +6,7 @@ [ADR-0054 Bounded Public Renderer Admission](../decisions/0054-bounded-public-renderer-admission.md) (accepted replacement proofs implemented), [ADR-0027 Frontend UI Localization](../decisions/0027-frontend-i18n.md) -(P02d-6 rules Registered; complete proofs pending). The catalogue grows as +(P02d-6 proofs implemented; manual smoke recorded). The catalogue grows as subsequent ADRs and phases land their tests; per-test ownership stays with the originating ADR / standard. @@ -130,8 +130,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. -**160 rules in this catalogue are Implemented, and 107 of them are in that assembly.** -The other 53 are no less binding, and most could not live there. The table says where +**165 rules in this catalogue are Implemented, and 107 of them are in that assembly.** +The other 58 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. @@ -4227,10 +4227,11 @@ verification pass; the delivery record owns execution evidence. semantics and retained rejected work. Planted strong maps, copied/value/caller keys, eager loads, rejection retries and exposed/additional collections fail. Its unit controls distinguish overlapping equal-header requests and subsequent - requests; this does not claim the pending complete P6 product-page proof. + requests; separate P6 modes below supply actual product-page proof. `PublicServerRenderingTests` proves cold/interleaved same-path HTML/RSC host isolation and next-request publication freshness as `learnstack_app`. It owns - disposable routes, not P6 product pages or P7 browser delivery. + disposable routes in transport mode and actual P6 product routes in separate + modes; P7 browser delivery remains outside this fixture. - **Phase:** 02d (P02d-5). ## P02d-6 public UI localization controls @@ -4239,6 +4240,9 @@ The maintainer accepted ADR-0027 and the P6 decision package on 2026-10-09. The rules below are commitments before the first implementation; none is a passing test claim at acceptance. Existing P5 source/runtime controls remain mandatory, and P6 product proofs supplement the isolated synthetic transport fixture. +P02d-6 implements the five rules below and completes its manual smoke on +2026-10-10; the [packet closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) +owns execution, review and manual evidence. #### `Ui_Catalogues_Cover_Public_Call_Sites` @@ -4249,7 +4253,8 @@ and P6 product proofs supplement the isolated synthetic transport fixture. fail. Public graphs include configuration and JSON catalogue dependencies. - **Source:** ADR-0027; Accepted P02d-6 G39. - **Type:** frontend catalogue/callsite + production build. **Kind:** structural. -- **Status:** **Registered** — P02d-6 Step 1. +- **Status:** **Implemented** — `frontend/apps/web/src/i18n/catalogues.test.ts`; + `PublicServerRenderingTests` production build, P02d-6. - **Phase:** 02d (P02d-6). #### `Public_Ui_Locale_Does_Not_Change_Content_Admission` @@ -4261,7 +4266,9 @@ and P6 product proofs supplement the isolated synthetic transport fixture. the real configuration without another authority carrier or bootstrap call. - **Source:** ADR-0027; Accepted P02d-6 G39 and G38(b,c). - **Type:** frontend mapping + actual production rendering. **Kind:** behavioural. -- **Status:** **Registered** — P02d-6 Steps 1 and 4. +- **Status:** **Implemented** — `PublicServerRenderingTests` actual foundation/ + product-isolation modes, `frontend/apps/web/src/i18n/locale.test.ts` and + `frontend/apps/web/src/server/public-request.test.ts`, P02d-6. - **Phase:** 02d (P02d-6). #### `Public_Theme_Emits_Only_Validated_Color_Tokens` @@ -4272,7 +4279,9 @@ and P6 product proofs supplement the isolated synthetic transport fixture. Effective attribution alone controls the platform label, independently of theme. - **Source:** Accepted P02d-6 G16(g)/G42; Standards 07 § Tenant Branding. - **Type:** frontend pure mapping + rendered document. **Kind:** behavioural. -- **Status:** **Registered** — P02d-6 Step 1. +- **Status:** **Implemented** — `PublicServerRenderingTests` foundation/ + product-isolation modes and + `frontend/apps/web/src/components/public/foundation.test.tsx`, P02d-6. - **Phase:** 02d (P02d-6). #### `Public_Pages_Preserve_Approved_Response_States` @@ -4284,7 +4293,9 @@ and P6 product proofs supplement the isolated synthetic transport fixture. tests observe actual status, visible HTML, call counts, freshness and no leaks. - **Source:** Accepted P02d-6 G40/G38(b,c); ADR-0053/0054. - **Type:** actual production Next/API/PostgreSQL. **Kind:** behavioural. -- **Status:** **Registered** — P02d-6 Steps 1–4. +- **Status:** **Implemented** — `PublicServerRenderingTests` foundation/pagination/ + presentation/product-freshness modes and + `frontend/apps/web/src/server/public-resource.test.ts`, P02d-6. - **Phase:** 02d (P02d-6). #### `Public_Pages_Expose_Localized_Accessible_Semantics` @@ -4296,7 +4307,11 @@ and P6 product proofs supplement the isolated synthetic transport fixture. 320 CSS px reflow, contrast and screen-reader evidence; they do not replace it. - **Source:** Accepted P02d-6 G43; Standards 16 § Testing. - **Type:** frontend lint/DOM + actual production HTML. **Kind:** structural. -- **Status:** **Registered** — P02d-6 Steps 1–4; manual evidence remains separate. +- **Status:** **Implemented** — `PublicServerRenderingTests` product-page HTML + assertions, `frontend/apps/web/src/test/accessibility-lint.test.ts` and + `frontend/apps/web/src/components/public/lesson.test.tsx`, P02d-6. Manual + keyboard/focus/reflow/contrast and maintainer VoiceOver evidence remain separate + in the packet closeout; these tests do not claim a full accessibility audit. - **Phase:** 02d (P02d-6). ## References diff --git a/docs/standards/README.md b/docs/standards/README.md index 6fe56644..d0f3fc67 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -80,8 +80,8 @@ loaders, chrome/states and atomic theme injection. Its [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) 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; manual -accessibility closeout remains pending. P7 owns +controls. Step 4 implements automated product HTML/Flight proofs and completes +manual accessibility, including maintainer-reported VoiceOver. P7 owns demo/browser/Lighthouse. Acceptance alone promotes no standard or test status. **The documents and this table say the same thing, and a test holds them to it.** @@ -109,7 +109,7 @@ included. | 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) | **Adopted** | P6 Steps 2–3 deliver catalog/course/lesson pages. Accepted G43 requires applicable jsx-a11y error rules with actual-config planted controls, actual-page semantic/language proofs and manual keyboard/focus/reflow/contrast/screen-reader evidence. Step 1 supplies chrome/state semantics; Steps 2–3 add catalog/course/lesson semantics. Step 3 blocks all 31 active recommended jsx-a11y rules with per-rule actual-config dirty/clean controls; Step 4 adds automated product HTML/Flight proofs and completes both review rounds. Actual manual evidence remains pending. Promotion occurs with enforcement. Full Playwright/axe remains [Phase 06](../roadmap/phase-06-renderer-admin-studio.md); Accepted G44 assigns Lighthouse to P02d-7 after P6. | +| 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. | | 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). | @@ -126,12 +126,12 @@ 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. -Nineteen `Active`, three `Adopted`. Packet 10 moved five: the frontend rules, the one-app +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. The three that remain are the honest ones — no budget is measured, there is no -interface to audit, and no permission key exists — and each names the phase that will -promote it. That split is far more useful to a reviewer than twenty-two identical labels. +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. ## Relationship to ADRs diff --git a/frontend/README.md b/frontend/README.md index ea1dab76..c6045710 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -40,4 +40,5 @@ 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) -tracks remaining review/manual accessibility work. P7 owns the browser/demo harness. +records completed reviews and passing manual accessibility. P7 owns the browser/demo +harness; P6 awaits maintainer PR review and merge. diff --git a/frontend/apps/web/README.md b/frontend/apps/web/README.md index f8a7155a..a84bbaff 100644 --- a/frontend/apps/web/README.md +++ b/frontend/apps/web/README.md @@ -25,7 +25,8 @@ Exact scaffold roots continue only after bootstrap; enabled locale membership ta 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 current verification and pending manual accessibility/review work. +records completed verification, reviews and passing manual accessibility. +P6 awaits maintainer PR review and merge. There is **no `extensions/` folder for vertical-provided components** — per [ADR-0018](../../../docs/decisions/0018-tenant-driven-customization-model.md), From 2b6dbb410925f85404eaed2591edd5bf30749bd6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 02:58:38 +0300 Subject: [PATCH 21/58] docs(standards): remove stale status counts after P6 closeout Keep the index as the owner of the changing standard-status split and record the two independent closeout reviews before fresh verification. ADR: 0027 --- docs/roadmap/phase-02d-walking-skeleton.md | 9 +++++++++ docs/standards/21-architecture-tests-catalogue.md | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 4ca18ac9..9ddcb1f8 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4678,6 +4678,15 @@ fragment is missing. This remains a manual audit, not a new CI anchor check. Added prose and diff checks pass. No production code or test changes accompany this closeout. +Fresh GPT-6.1-sol high corpus and GPT-6-astra high evidence/governance reviewers +independently review `27a02ba..7d98228`. Both approve with one verified Minor: +the catalogue's current status-consistency assertion quotes the former +nineteen/three split. It now references the index-owned counts instead. Both +reviewers verify the five promoted entries against existing tests, 165 catalogue +rules (107 architecture, 58 outside), twenty Active/two Adopted standards, +maintainer-attributed VoiceOver scope and insertion-only ADR history. Neither +claims new broad runtime execution. Fresh second-round reviews follow the fix. + P02d-6 implementation is complete; PR review and merge remain separate. P02d-7 owns `make demo`, the browser/full-stack CI harness, Lighthouse activation and the Phase 02d exit checks. Its decision pass must close G20/G33/G38/G44/G45's remaining diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index a4265b17..9998d126 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -693,7 +693,7 @@ otherwise). - **Asserts:** each of the twenty-two standards declares the same status in its own header as [the index](README.md) assigns it in the table, and the sentence that counts - the split — "Nineteen `Active`, three `Adopted`" — matches the table it summarises. + the split matches the table it summarises. The index owns the current counts. - **Why it matters:** these are two views of one claim, *what is actually enforced*, and they disagreed for a month: every document declared `Active` while the index classified eight of them `Adopted`, with the index carrying a paragraph saying so and asking the From 47f9c122444d8f3c884c532be659e4fdec21010a Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 03:03:40 +0300 Subject: [PATCH 22/58] docs(roadmap): record final P02d-6 closeout reviews Record both fresh second-round approvals after the status-count fix so the completed manual and documentation gates have review evidence. ADR: 0027, 0051 --- docs/roadmap/phase-02d-walking-skeleton.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 9ddcb1f8..1147000d 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4667,8 +4667,8 @@ cases and 1,065 passing guarded frontend cases, zero failures/skips/todos. The manual closeout permits Accessibility Standards to become Active and the five P6 catalogue entries to become Implemented; the automated checks named by those entries are already present. The closure change updates documentation only. -Final corpus/link validation and two fresh documentation reviews follow before -the implementation PR is handed to the maintainer. +Final corpus/link validation and two fresh documentation review rounds complete +before the implementation PR is handed to the maintainer, as recorded below. Closeout validation passes all 198 Release architecture cases, including the corpus guards, with zero failures/skips, and all seven ADR-workflow tests. A local @@ -4685,7 +4685,15 @@ nineteen/three split. It now references the index-owned counts instead. Both reviewers verify the five promoted entries against existing tests, 165 catalogue rules (107 architecture, 58 outside), twenty Active/two Adopted standards, maintainer-attributed VoiceOver scope and insertion-only ADR history. Neither -claims new broad runtime execution. Fresh second-round reviews follow the fix. +claims new broad runtime execution. + +Fresh GPT-6-sol high consistency and GPT-6.1-sol high history/evidence reviewers +independently review `27a02ba..2b6dbb4` after the fix. Both approve without findings. +They verify current status, catalogue and standards counts, bounded manual +evidence, preserved historical records and insertion-only ADR changes. The +history reviewer independently checks 1,832 relative links and 565 fragments in +the 23 closeout Markdown files; none is missing. Neither reruns a broad runtime +suite. Both closeout review rounds are complete. P02d-6 implementation is complete; PR review and merge remain separate. P02d-7 owns `make demo`, the browser/full-stack CI harness, Lighthouse activation and the From e871e884bba02d1f9da5625e08909a19b1800067 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 09:27:20 +0300 Subject: [PATCH 23/58] fix(public): close renderer review proof gaps Derive the hidden course control from seed data, refuse a missing locale and keep catalogue heading fixes inside the corpus guard census. Align delivered error-handling docs and remove redundant proof assertions. ADR: 0027, 0051, 0052 --- README.md | 3 +- .../CorpusConsistencyTests.cs | 13 +++--- .../Database/PublicServerRenderingTests.cs | 15 +++++++ docs/roadmap/phase-02d-walking-skeleton.md | 45 +++++++++++++++++++ docs/standards/09-error-handling.md | 8 +++- .../21-architecture-tests-catalogue.md | 10 ++--- .../web/scripts/verify-public-rendering.mjs | 28 +++++++----- .../web/src/server/public-resource.test.ts | 9 ++++ .../apps/web/src/server/public-resource.ts | 5 ++- 9 files changed, 109 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 1694122b..1b9f30bb 100644 --- a/README.md +++ b/README.md @@ -271,7 +271,8 @@ backend/ tests/ Unit, architecture, contract and integration suites analyzers/ LearnStack Roslyn analyzers frontend/ - apps/web/ Public site, Admin Studio and learner portal scaffolds + apps/web/ Public catalog, course and lesson pages + Admin Studio and learner portal scaffolds packages/ Shared config, UI and generated API SDK infra/ Compose stack and service configuration scripts/ Seed and verification helpers diff --git a/backend/tests/LearnStack.Tests.Architecture/CorpusConsistencyTests.cs b/backend/tests/LearnStack.Tests.Architecture/CorpusConsistencyTests.cs index cd9572be..168c3261 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CorpusConsistencyTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CorpusConsistencyTests.cs @@ -199,11 +199,11 @@ public void The_Corpus_Guards_Can_Actually_Fail() - **Status:** **Implemented** — `SomeTests.cs`. - #### `A_Rule_Whose_Entry_Names_The_Class` + ### `A_Rule_Whose_Entry_Names_The_Class` - **Status:** **Implemented** (Packet 6 step 4, `SomeTests`). - #### `A_Rule_That_Does_Not` + ### `A_Rule_That_Does_Not` - **Status:** **Registered.** @@ -211,15 +211,16 @@ public void The_Corpus_Guards_Can_Actually_Fail() - **Status:** **Implemented** (`LearnStack.Tests.Integration`, `OtherTests`). - #### `An-Analyzer-Rule` + ### `An-Analyzer-Rule` - **Status:** **Implemented** — `SomeTests.cs`. """).Should().Equal([ "A_Rule_That_Runs", "A_Rule_Whose_Entry_Names_The_Class", "An-Analyzer-Rule", - ], "a hyphen in a rule name is a rule name: excluding it dropped the `LS0001` " - + "analyzer's entry from every count here, prose and recount alike"); + ], "both third- and fourth-level rule headings count; a hyphen in a rule name " + + "is a rule name: excluding it dropped the `LS0001` analyzer's entry from " + + "every count here, prose and recount alike"); // The class reader the orphan check rests on: a `…Tests` token in backticks, with or // without its extension, and nothing else. @@ -424,7 +425,7 @@ private static Dictionary<string, HashSet<string>> DeclaredMethods() => // name is `LearnStackException-DomainExceptionThrow`. Excluding it dropped that entry from // every count here, and because the prose and the recount read through this same pattern // they agreed with each other while both were short by one. - [GeneratedRegex(@"#### `(?<rule>[A-Za-z0-9_\-]+)`(?<status>.*?)(?=\n#{2,4} |\z)", RegexOptions.Singleline)] + [GeneratedRegex(@"^#{3,4} `(?<rule>[A-Za-z0-9_\-]+)`(?<status>.*?)(?=\n#{2,4} |\z)", RegexOptions.Singleline | RegexOptions.Multiline)] private static partial Regex CatalogueEntry(); // Three spellings, because the catalogue writes all three: the bare class, the file with diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs index 69e2512c..ad79350f 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs @@ -86,6 +86,12 @@ public async Task Production_rendering_is_host_isolated_fresh_and_private_throug var sharedSlug = firstTranslation.Slug; var secondCourse = SeedData.Yoga.Curriculum!.Courses.Single(course => course.Translations.Any(translation => translation.Locale == locale && translation.Slug == sharedSlug)); + var firstSlugs = SeedData.English.Curriculum.Courses.SelectMany(course => course.Translations) + .Where(translation => translation.Locale == locale).Select(translation => translation.Slug).ToHashSet(); + var foreignCourse = SeedData.Yoga.Curriculum.Courses + .Where(course => course.Status == "Published" && (course.OrganizationId is null + || (SeedData.Yoga.MapHostToDefaultOrganization && course.OrganizationId == SeedData.Yoga.DefaultOrganization.OrganizationId))) + .SelectMany(course => course.Translations).First(translation => translation.Locale == locale && !firstSlugs.Contains(translation.Slug)); var tenants = new[] { TenantInput(SeedData.English, firstCourse, locale), @@ -144,6 +150,7 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new secret, locale, courseSlug = sharedSlug, + foreignCourse = new { foreignCourse.Slug, foreignCourse.Title }, traceparent = SuppliedTrace, tenants, product = productInputs, @@ -208,6 +215,8 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new foundationRequests.Count(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)).Should().Be(1); foundationRequests.Single(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)) .Locale.Should().Be(locale, "UI configuration cannot change the exact content API locale"); + foundationRequests.Single(request => request.Path != "/api/v1/public/site").Status.Should() + .Be(checkpoint == "foundation-missing" ? 404 : 200); foundationPosition = observed.Requests.Length; break; case "foundation-catalog-english": @@ -227,6 +236,7 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new productRequests.Should().HaveCount(3, "an unchanged page uses one shared content operation"); productRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(2); productRequests.Count(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)).Should().Be(1); + productRequests.Single(request => request.Path != "/api/v1/public/site").Status.Should().Be(200); productRequests.Single(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)) .Locale.Should().Be(checkpoint switch { @@ -242,6 +252,10 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new crossTenantRequests.Should().HaveCount(5, "missing cross-tenant detail and followed status use the approved redirect chain"); crossTenantRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(4); crossTenantRequests.Count(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)).Should().Be(1); + var hiddenCourseRequest = crossTenantRequests.Single(request => request.Path != "/api/v1/public/site"); + hiddenCourseRequest.Path.Should().Be($"/api/v1/public/courses/{foreignCourse.Slug}"); + hiddenCourseRequest.Locale.Should().Be(locale); + hiddenCourseRequest.Status.Should().Be(404, "the seeded foreign course exists but is hidden on this host"); foundationPosition = observed.Requests.Length; break; case "foundation-status": @@ -290,6 +304,7 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new hiddenLessonRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(4); hiddenLessonRequests.Single(request => request.Path != "/api/v1/public/site").Locale.Should() .Be(checkpoint == "presentation-protected" ? SeedData.Yoga.Curriculum!.Locales.Single(row => row.IsDefault).Locale : locale); + hiddenLessonRequests.Single(request => request.Path != "/api/v1/public/site").Status.Should().Be(404); foundationPosition = observed.Requests.Length; break; case "presentation-publish-revision": diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 1147000d..59116301 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4701,6 +4701,51 @@ Phase 02d exit checks. Its decision pass must close G20/G33/G38/G44/G45's remain parts before implementation. Full Playwright/axe and the expanded renderer/Studio remain Phase 06; no later packet is delivered by this closeout. +#### P02d-6 external-review remediation (2026-10-10) + +The external review of PR #27 at `47f9c12` approves without Blocker/Major +findings. Its three Minors and three documentation comments are rechecked against +the current tree; all remain valid and receive bounded fixes: + +- The cross-tenant course probe derives a foreign-only published slug and locale + from `SeedData`, not a second JavaScript literal. The other host's asserted + catalog keeps a visible control for that course. C# independently checks the + refused API path, locale and 404; the shared slug cannot serve this purpose + because it exists in both tenants. Normal/missing course and hidden lesson + checkpoints also assert API status. Next's 307/Location/no-store/404 chain + remains owned by the Node artifact; the API observer does not observe Next + response headers. The broad 1–59 check remains the visitor-budget ceiling; + existing exact per-mode counts and checkpoints remain unchanged. +- The malformed-theme proof removes its redundant root-style-attribute check. + Its existing scan still rejects four-color token declarations in any style + element or inline style, and checks the complete compiled default palette. These HTML + proofs inspect emitted CSS; they do not measure a browser's computed cascade. + The earlier contrast numbers remain calculated palette ratios. +- Missing content without an admitted locale fails through `notFound()` rather + than constructing `/null/status/not-found`; a defensive unit control covers + the otherwise unreachable inconsistent request. +- Error Handling records the delivered mapping and links Steps 1–3. README + inventory distinguishes the public pages from Studio/portal scaffolds. All + five P6 rule headings use H3 under H2; the corpus reader admits H3/H4 with + mixed-level planted controls so no rule falls out of the catalogue census. +- Failure containment uses each scenario's actual private code, retaining the + common private-value/message-key canaries. The ICU parser suggestion does not + require a dependency change: the lockfile uses 3.5.18 for both the direct + structural parser and `use-intl`'s runtime `intl-messageformat` dependency. + Build-time `icu-minify` uses 3.5.21 separately; existing actual formatting and + production-build controls remain mandatory. No general parser-parity claim is + introduced. + +Validation passes 1,066 guarded frontend cases (1,006 web, 60 SDK), lint and +typecheck; 198 Release architecture cases; and all 20 real renderer/cleanup +integration cases, including all six production modes. All report zero failures +and skips/todos where applicable. The H4-only parser mutation fails the mixed +heading control; the restored parser passes the full architecture suite. Format +verification and diff checks pass. A local audit of the four changed Markdown +files checks 1,017 relative links and 353 fragments without a missing target. +No broad backend-suite rerun is claimed by these focused local checks. Fresh +remediation reviews and exact-head PR CI follow the commit. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/standards/09-error-handling.md b/docs/standards/09-error-handling.md index 598e2441..26de5d75 100644 --- a/docs/standards/09-error-handling.md +++ b/docs/standards/09-error-handling.md @@ -84,7 +84,7 @@ frontend UI copy. Backend code never returns raw English. consumer resolving a backend message reads `Message.Key` — two wire surfaces in sync by construction. This does not claim a shipped frontend message catalogue. See [Frontend Error Handling](#mapping-problem-details--ui) for current SDK -normalization and the Accepted, implementation-pending public UI mapping. Per +normalization and the public UI mapping implemented by P02d-6 Steps 1–3. Per [Phase 02a Packet 2](../roadmap/phase-02a-kernel-tenancy.md) and [ADR-0032 § Error Model](../decisions/0032-exception-handling-logging-and-observability.md). @@ -431,7 +431,11 @@ specifies P02d-6's closed page-outcome mapping to owned UI keys, with unknown outcomes mapped to the bounded unavailable state. It does not authorize arbitrary Problem Details keys, titles, field errors or parameters as UI lookup identifiers or visible copy. This public-page mapping is Accepted — 2026-10-09; -implementation remains pending. +P02d-6 Steps 1–3 implement it, with evidence in the +[Step 1](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation), +[Step 2](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-2-catalog-and-course-pages) +and [Step 3](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-3-ordered-lesson-presentation) +delivery records. ### Public Page Status and Recovery diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 9998d126..151339b9 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -4244,7 +4244,7 @@ P02d-6 implements the five rules below and completes its manual smoke on 2026-10-10; the [packet closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) owns execution, review and manual evidence. -#### `Ui_Catalogues_Cover_Public_Call_Sites` +### `Ui_Catalogues_Cover_Public_Call_Sites` - **Asserts:** nonempty supported UI catalogues have equal key sets, valid ICU syntax and matching argument names/types; every used key exists. General UI @@ -4257,7 +4257,7 @@ owns execution, review and manual evidence. `PublicServerRenderingTests` production build, P02d-6. - **Phase:** 02d (P02d-6). -#### `Public_Ui_Locale_Does_Not_Change_Content_Admission` +### `Public_Ui_Locale_Does_Not_Change_Content_Admission` - **Asserts:** request configuration uses the existing verified signed target and live membership. Whole-catalogue UI fallback changes neither exact API locale @@ -4271,7 +4271,7 @@ owns execution, review and manual evidence. `frontend/apps/web/src/server/public-request.test.ts`, P02d-6. - **Phase:** 02d (P02d-6). -#### `Public_Theme_Emits_Only_Validated_Color_Tokens` +### `Public_Theme_Emits_Only_Validated_Color_Tokens` - **Asserts:** the complete four-color value is admitted atomically before fixed `--ls-*` CSS emission. Malformed/null input preserves the whole default palette; @@ -4284,7 +4284,7 @@ owns execution, review and manual evidence. `frontend/apps/web/src/components/public/foundation.test.tsx`, P02d-6. - **Phase:** 02d (P02d-6). -#### `Public_Pages_Preserve_Approved_Response_States` +### `Public_Pages_Preserve_Approved_Response_States` - **Asserts:** real product routes preserve shared request-local reads, opaque pagination and no-store isolation. Missing/hidden content follows the approved @@ -4298,7 +4298,7 @@ owns execution, review and manual evidence. `frontend/apps/web/src/server/public-resource.test.ts`, P02d-6. - **Phase:** 02d (P02d-6). -#### `Public_Pages_Expose_Localized_Accessible_Semantics` +### `Public_Pages_Expose_Localized_Accessible_Semantics` - **Asserts:** public states expose document language/direction, one main, descriptive headings/title, skip navigation and language-bearing fallback UI. diff --git a/frontend/apps/web/scripts/verify-public-rendering.mjs b/frontend/apps/web/scripts/verify-public-rendering.mjs index 1cfa4cdb..15c99493 100644 --- a/frontend/apps/web/scripts/verify-public-rendering.mjs +++ b/frontend/apps/web/scripts/verify-public-rendering.mjs @@ -616,11 +616,19 @@ async function verifyFoundation(native, nextBin) { await checkpoint('foundation-empty'); stage = 'foundation cross-tenant detail refusal'; - const hidden = await request(first, '/en/courses/shared-practice'); + const foreign = configuration.foreignCourse; + assert.ok( + second.catalogEn.some( + (course) => course.slug === foreign.slug && course.title === foreign.title, + ), + 'The foreign course has a kept-visible control in the asserted other-host catalog', + ); + const hidden = await request(first, '/' + configuration.locale + '/courses/' + foreign.slug); safeResponse(hidden, 307); - assert.equal(hidden.headers.location, '/en/status/not-found'); - statusDocument(await request(first, '/en/status/not-found'), first, 'en', 'en'); - assert.equal(hidden.body.includes(second.courseTitle), false); + const hiddenStatus = '/' + configuration.locale + '/status/not-found'; + assert.equal(hidden.headers.location, hiddenStatus); + statusDocument(await request(first, hiddenStatus), first, configuration.locale, 'en'); + assert.equal(hidden.body.includes(foreign.title), false); await checkpoint('foundation-cross-tenant'); stage = 'foundation-missing'; @@ -1260,7 +1268,6 @@ function productTheme(doc, details, malformed = false) { details.locale === 'tr-TR' ? 'LearnStack altyapısıyla' : 'Powered by LearnStack', ); if (malformed) { - assert.equal(doc.documentElement.hasAttribute('style'), false); const cssLinks = [...doc.querySelectorAll('link[rel="stylesheet"]')]; assert.ok(cssLinks.length > 0, 'The fallback has a real compiled stylesheet'); const css = cssLinks @@ -1487,6 +1494,7 @@ async function verifyProductFreshness(native) { locale: second.defaultLocale, path: turkish.coursePath + '?lessonCursor=bad-cursor', state: 'invalid_cursor', + privateCode: 'validation_failed', target: turkish.coursePath, title: turkish.courseTitle, }, @@ -1498,6 +1506,7 @@ async function verifyProductFreshness(native) { locale: second.defaultLocale, path: turkish.coursePath, state: 'rate_limited', + privateCode: 'rate_limited', target: '/' + second.defaultLocale + '/courses', title: turkish.courseTitle, }, @@ -1509,6 +1518,7 @@ async function verifyProductFreshness(native) { locale: 'ar', path: details.lessonPath, state: 'unavailable', + privateCode: 'service_unavailable', target: catalog, title: details.courseTitle, }, @@ -1557,13 +1567,7 @@ async function verifyProductFreshness(native) { assert.equal(doc.body.textContent.includes('bad-cursor'), false); assert.equal(doc.title.includes('bad-cursor'), false); stage = scenario.name + ' private failure'; - absentFromWholeResponse(response, [ - 'fixture-private-', - 'lockey_', - 'service_unavailable', - 'rate_limited', - 'validation_failed', - ]); + absentFromWholeResponse(response, ['fixture-private-', 'lockey_', scenario.privateCode]); await checkpoint(scenario.name); await success(scenario.recovery, scenario.tenant, scenario.target, scenario.title); } diff --git a/frontend/apps/web/src/server/public-resource.test.ts b/frontend/apps/web/src/server/public-resource.test.ts index 30b71de9..c08da4c4 100644 --- a/frontend/apps/web/src/server/public-resource.test.ts +++ b/frontend/apps/web/src/server/public-resource.test.ts @@ -339,4 +339,13 @@ describe('consumer admission and missing-content navigation', () => { expect(navigation.redirect).not.toHaveBeenCalled(); expect(navigation.notFound).not.toHaveBeenCalled(); }); + + it('refuses a defensive missing-content outcome without an admitted locale', async () => { + // Content routes normally carry a locale; a broken caller must not redirect to /null/. + getRequest.mockResolvedValue({ ...request('/tr-TR/courses/private-slug'), locale: null }); + client.getCourse.mockResolvedValue(apiError(404, { code: 'not_found' })); + await expect(requirePublicResource()).rejects.toThrow('NEXT_NOT_FOUND'); + expect(navigation.notFound).toHaveBeenCalledOnce(); + expect(navigation.redirect).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/apps/web/src/server/public-resource.ts b/frontend/apps/web/src/server/public-resource.ts index a9c10ae3..a3c83fc4 100644 --- a/frontend/apps/web/src/server/public-resource.ts +++ b/frontend/apps/web/src/server/public-resource.ts @@ -101,6 +101,9 @@ export async function requirePublicResource(): Promise< > { const resource = await getPublicResource(); if (resource.kind === 'refused') notFound(); - if (resource.kind === 'missing') redirect(`/${resource.request.locale}/status/not-found`); + if (resource.kind === 'missing') { + if (resource.request.locale === null) notFound(); + redirect(`/${resource.request.locale}/status/not-found`); + } return resource; } From ed6f81e8e43bdf0c669208264dc14cb5c391b6f3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 09:33:33 +0300 Subject: [PATCH 24/58] docs(roadmap): record P6 remediation review approvals Record two independent approvals and bound the focused test evidence so reviewers can distinguish local regression checks from exact-head CI. ADR: 0027, 0051, 0052 --- docs/roadmap/phase-02d-walking-skeleton.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 59116301..ec9cfb85 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4743,8 +4743,14 @@ and skips/todos where applicable. The H4-only parser mutation fails the mixed heading control; the restored parser passes the full architecture suite. Format verification and diff checks pass. A local audit of the four changed Markdown files checks 1,017 relative links and 353 fragments without a missing target. -No broad backend-suite rerun is claimed by these focused local checks. Fresh -remediation reviews and exact-head PR CI follow the commit. +No broad backend-suite rerun is claimed by these focused local checks. + +Two fresh read-only remediation reviews approve `47f9c12..e871e88` on 2026-10-10: +GPT-6.1-sol at high effort, then GPT-6-astra at high effort. Both report no verified +actionable findings after standards, isolation, correctness, test-proof and +documentation checks. Neither reviewer claims a runtime-suite rerun. The +[PR #27 description](https://github.com/HodeTech/LearnStack/pull/27) owns the +exact-head CI evidence. ### P02d-1 decision pass (2026-09-14) From cc626e8e0a0f880fc4ad1d389d0046808bf51090 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 10 Oct 2026 11:20:03 +0300 Subject: [PATCH 25/58] fix(public): strengthen P6 proof and guidance boundaries Exercise the actual response, formatter and source fences with planted controls, and align shipped route/i18n guidance with the implementation. Keep approval-dependent bootstrap, Open Graph and maintenance changes separate in a Proposed decision package. Clarify page-local counts, refuse only owned cursor errors as resettable, and accept positive decimal limits up to the existing clamped bound. ADR: 0027, 0053, 0054 --- .claude/skills/add-frontend-route/SKILL.md | 50 ++- .claude/skills/add-i18n-key/SKILL.md | 30 +- .claude/skills/add-page-block/SKILL.md | 14 +- .claude/skills/run-tests-locally/SKILL.md | 4 +- CLAUDE.md | 5 +- README.md | 2 +- .../Common/ProblemDetailsFactory.cs | 3 +- .../Localization/LocalizedMessage.cs | 10 +- docs/architecture/12-localization.md | 12 +- docs/architecture/14-frontend-architecture.md | 9 +- ...0055-public-renderer-bootstrap-failures.md | 130 +++++++ docs/decisions/README.md | 1 + docs/glossary.md | 2 +- docs/modules/customization/README.md | 3 +- docs/modules/tenancy/README.md | 7 +- docs/roadmap/README.md | 3 +- docs/roadmap/phase-02d-walking-skeleton.md | 144 +++++++- .../roadmap/phase-06-renderer-admin-studio.md | 6 + docs/roadmap/phase-11-production-hardening.md | 3 + docs/standards/07-frontend-architecture.md | 11 +- .../21-architecture-tests-catalogue.md | 15 +- frontend/apps/web/.eslintrc.cjs | 10 +- frontend/apps/web/README.md | 16 +- .../scripts/public-rendering-assertions.d.mts | 60 ++++ .../scripts/public-rendering-assertions.mjs | 195 +++++++++++ .../web/scripts/verify-public-rendering.mjs | 191 +++-------- .../apps/web/src/app/(portal)/portal/page.tsx | 2 + .../apps/web/src/app/(studio)/studio/page.tsx | 2 + .../components/public/catalog-course.test.tsx | 11 +- .../src/components/public/foundation.test.tsx | 2 + .../apps/web/src/components/public/state.tsx | 7 +- frontend/apps/web/src/i18n/catalogues.test.ts | 123 +++++-- .../apps/web/src/i18n/messages/en/public.json | 4 +- .../apps/web/src/i18n/messages/tr/public.json | 4 +- frontend/apps/web/src/i18n/request.test.ts | 83 +++++ .../web/src/server/public-resource.test.ts | 37 +- .../apps/web/src/server/public-resource.ts | 11 +- .../apps/web/src/server/public-route.test.ts | 5 +- frontend/apps/web/src/server/public-route.ts | 14 +- .../apps/web/src/server/public-ui.test.ts | 55 +++ .../web/src/test/accessibility-lint.test.ts | 20 +- .../web/src/test/public-boundaries.test.ts | 279 ++++++++++++++- .../web/src/test/public-boundary-analysis.ts | 322 ++++++++++++++++-- .../test/public-rendering-assertions.test.ts | 205 +++++++++++ 44 files changed, 1835 insertions(+), 287 deletions(-) create mode 100644 docs/decisions/0055-public-renderer-bootstrap-failures.md create mode 100644 frontend/apps/web/scripts/public-rendering-assertions.d.mts create mode 100644 frontend/apps/web/scripts/public-rendering-assertions.mjs create mode 100644 frontend/apps/web/src/i18n/request.test.ts create mode 100644 frontend/apps/web/src/server/public-ui.test.ts create mode 100644 frontend/apps/web/src/test/public-rendering-assertions.test.ts diff --git a/.claude/skills/add-frontend-route/SKILL.md b/.claude/skills/add-frontend-route/SKILL.md index 04ec098d..df8a02b7 100644 --- a/.claude/skills/add-frontend-route/SKILL.md +++ b/.claude/skills/add-frontend-route/SKILL.md @@ -41,7 +41,7 @@ SDK contract per | Input | Required | Description | |-------|----------|-------------| -| Path | Yes | URL path (`/courses/[slug]`, `/dashboard/users`, `/lesson/[id]`). | +| Path | Yes | Public URL `/{locale}/courses/{slug}`; future authenticated paths follow their owning phase. | | Route group | Yes | `(public)` / `(studio)` / `(portal)`. | | Auth | Yes | Anonymous (public) / authenticated tenant / org-scoped. | | Permission key | If guarded | `{module}.{resource}.{action}` from the closed set. | @@ -59,6 +59,14 @@ SDK contract per ### Step 2: Create the route folder +The shipped public tree is `(public)/[locale]/courses/`, with course detail at +`[slug]/page.tsx` and lesson detail at `[slug]/lessons/[lessonSlug]/page.tsx`. +`courses` and `lessons` are fixed section segments. The only localized status +namespace is `[locale]/status/not-found`. A new public segment also needs an +explicit update to the closed `contentPath` / `publicEntry` admission policy and +its tests; adding a Next folder alone leaves it refused. Phase 06 owns broader +localized section names. + ``` frontend/apps/web/src/app/ (studio)/ @@ -75,24 +83,35 @@ unless the screen genuinely needs one. ### Step 3: Server Component shell ```tsx -// page.tsx (Server Component by default) -import { createConfiguredPublicClient } from '@/server/configured-public-client'; - -// The configured server caller supplies this transport; never a tenant-ID option. -export function loadCourses(envelope: string | null) { - const client = createConfiguredPublicClient(envelope); - if (!client) throw new Error('Invalid public ingress'); - return client.getCourses(); // Locale comes from the authenticated route. +// app/(public)/[locale]/courses/page.tsx +import { notFound } from 'next/navigation'; +import { PublicCatalog } from '@/components/public/catalog'; +import { PublicState } from '@/components/public/state'; +import { requirePublicResource } from '@/server/public-resource'; +import { getPublicUi } from '@/server/public-ui'; + +export default async function CoursesPage() { + const resource = await requirePublicResource(); + const ui = await getPublicUi(); + if (resource.kind === 'failure') { + return <PublicState state={resource.state} + recoveryPath={resource.request.route.path} + locale={ui.locale} direction={ui.direction} t={ui.t} />; + } + if (resource.kind !== 'catalog') notFound(); + return <PublicCatalog resource={resource} ui={ui} />; } ``` > **P02d-4 delivered.** `@learnstack/sdk/server` exports an injected > `createServerSdk(transport)` with four typed public GET wrappers; no global `sdk` -> object, tenant-ID option or module namespace exists. The example is a loader, -> not a complete route. P02d-5/G35 delivers the configured trusted transport in +> object, tenant-ID option or module namespace exists. P02d-5/G35 delivers the +> configured trusted transport in > [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); -> that caller precedes P02d-6 public page consumers. Read the verified envelope -> from request-local `headers()`; never create or expose a provenance stamp in a page. +> that caller precedes P02d-6 public page consumers. Pages use the shared resource +> loader; they do not read envelope headers or create another bootstrap/client. +> Keep loader helpers outside `page.tsx`; Next page exports are restricted to +> supported route exports such as the default component and `generateMetadata`. Rules: @@ -170,6 +189,7 @@ is authoritative. ### Step 6: Feature gating (entitlement-aware UI) +The hook/package below is a future feature-UI sketch, not a shipped SDK export. For features gated by plan-projected `FeatureKey`: ```tsx @@ -219,7 +239,9 @@ locale, document language and actual resolved-label language. The guarded frontend suite supplies ICU, argument and callsite checks. No `lint:i18n` command or screenshot/axe tooling exists. The [Step 1 delivery record](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) -owns validation; product-page and manual accessibility proof remains Steps 2–4. +owns foundation validation; the +[packet closeout](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) +records the delivered product proof and scoped manual accessibility checks. ### Step 8: Public-site SSR caching diff --git a/.claude/skills/add-i18n-key/SKILL.md b/.claude/skills/add-i18n-key/SKILL.md index 20706ed0..6ca14750 100644 --- a/.claude/skills/add-i18n-key/SKILL.md +++ b/.claude/skills/add-i18n-key/SKILL.md @@ -25,7 +25,9 @@ Manage user-facing translations in `apps/web` consistently per > `next-intl` 4.14.9 and the catalogue home below. Step 1 installs the runtime, > complete English/Turkish catalogues and guarded ICU/callsite checks. Its > [delivery record](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) -> owns validation and the remaining product/accessibility proof boundary. No +> owns foundation validation; the +> [packet closeout](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) +> records the scoped product and manual accessibility evidence. No > `pnpm lint:i18n`, `no-literal-strings`, `_deprecated.json`, screenshot or > `axe-core` task exists today; do not claim those checks run. @@ -71,7 +73,8 @@ frontend/apps/web/src/i18n/ ``` P02d-6 supplies only `public`; later features add a namespace beneath each locale. -Request configuration mounts each file beneath its namespace. JSON nesting +`catalogues.ts` statically imports the two public files, and `request.ts` explicitly +mounts them as `messages.public`. There is no automatic file discovery. JSON nesting supplies dotted identifiers: `public.catalog.course_count` is `catalog.course_count` within the `public` translator. @@ -136,6 +139,12 @@ same layout; they do not introduce an enrollment catalogue in P02d-6. ### Step 3: ICU MessageFormat for plural / select / number +For a new namespace, adding JSON files is insufficient. Wire their static imports +and locale map into `catalogues.ts`, mount the namespace in `request.ts`, and add +its typed translator and callsite-census coverage. Do not pass a catalogue to a +Client Component. For public keys with new ICU arguments, update +`PublicMessageArguments` alongside the messages and their callsites. + ```jsonc { "list": { @@ -148,8 +157,12 @@ same layout; they do not introduce an enrollment catalogue in P02d-6. Usage: ```tsx -const t = useTranslations("enrollment.list"); -return <p>{t("count", { count: learners.length })}</p>; +import { getPublicUi } from "@/server/public-ui"; + +export default async function CatalogCount({ count }: { count: number }) { + const { t } = await getPublicUi(); + return <p>{t("catalog.course_count", { count })}</p>; +} ``` ### Step 4: Variable interpolation @@ -209,6 +222,15 @@ I18n: enrollment.list.empty_state.title, enrollment.list.empty_state.cta_label ## Validation +Run the checked catalogue suite from `frontend/`: + +```bash +pnpm --filter @learnstack/web test src/i18n/catalogues.test.ts +``` + +Also run the complete guarded frontend suite before completion; the focused +command does not replace production HTML/RSC or source-boundary proofs. + P02d-6 implements these accepted obligations in the guarded frontend suite; the acceptance record alone is not passing evidence: diff --git a/.claude/skills/add-page-block/SKILL.md b/.claude/skills/add-page-block/SKILL.md index 96b3e262..55605b50 100644 --- a/.claude/skills/add-page-block/SKILL.md +++ b/.claude/skills/add-page-block/SKILL.md @@ -68,6 +68,11 @@ and [17-page-builder.md](../../../docs/architecture/17-page-builder.md). > `src/lib/customization/` are not renderer implementations. See the > [accepted package](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-accepted-answers). +`RegisterPrimitive`, `BlockRenderer`, `resolver.registerPrimitive` and +`useContentEntries` below are illustrative future APIs, not existing symbols to +import. G41 chooses app-local `components/public` for the shipped subset; it does +not create `packages/blocks` or the wider registry. + ### Step 2: Primitive block (path A) #### A.1 — Author the JSON Schema @@ -210,9 +215,9 @@ the page. Accepted P02d-6/G43 requires applicable jsx-a11y error rules with actual-config planted controls, localized product-page semantics and real manual keyboard, focus, reflow, contrast and screen-reader evidence. Step 3 implements the - applicable lint controls and synchronous lesson semantics; full product/manual - evidence remains Step 4. See the - [delivery record](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-3-ordered-lesson-presentation). + applicable lint controls and synchronous lesson semantics. The + [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. @@ -220,7 +225,8 @@ the page. ## Validation - `dotnet build` and `pnpm build` pass. -- Architecture test `Block_Schemas_Are_Immutable_After_Publish` is green. +- The future block-registry implementation must supply its schema-immutability + proof; `Block_Schemas_Are_Immutable_After_Publish` is not a shipped test today. - For a primitive: the block appears in the Studio block picker. - For a composite: tenant admins can reference it from their `TenantPageBlock` editor. diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index c1aa9b03..54570c19 100644 --- a/.claude/skills/run-tests-locally/SKILL.md +++ b/.claude/skills/run-tests-locally/SKILL.md @@ -169,7 +169,9 @@ It owns ports 3000/3011 while running and refuses occupied ports without stoppin an existing user process. P6 adds separate production product modes for foundation, pagination, presentation, concurrent isolation and freshness/failure states. Those HTTP/HTML/RSC proofs do not claim browser or screen-reader execution; P6's manual -accessibility closeout remains required and P7 owns the browser demo. +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. > **`pnpm test:a11y` and `pnpm test:e2e` do not exist yet.** `package.json` > defines `dev`, `build`, `start`, `lint`, `typecheck` and `test`, and neither diff --git a/CLAUDE.md b/CLAUDE.md index f3b063aa..cbb94bdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,8 +58,9 @@ the phase against its own exit decision: thirty catalogue entries name it as the that implemented them — module boundaries in all four legs, domain genericity across names, schema, slugs, keys and the frontend tree, the port and entitlement-key bans, ADR-0040's two behavioural properties, and three named isolation proofs as `learnstack_app`. The -standards were re-stated against what enforces them today (nineteen `Active`, three -`Adopted`), and four guards now hold the corpus to its own claims, so a status header, a +standards were re-stated against enforcement at Packet 10 closeout (nineteen +`Active`, three `Adopted`), and four guards now hold the corpus to its own claims, +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. diff --git a/README.md b/README.md index 1b9f30bb..51691114 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ required OpenAPI check is active, with all other live protection settings preser Trusted server rendering is delivered by P02d-5. P02d-6 implements localized catalog/course/lesson pages, ordered text fields, safe themes and metadata; its [product closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) -records completed automated verification/reviews and passing manual accessibility. +records automated verification/reviews and scoped browser/VoiceOver observations. P02d-6 implementation is complete; maintainer PR review and merge remain pending. The browser demo remains P02d-7. The [P02d-4 decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-4-decision-package-2026-10-03) diff --git a/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs b/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs index c7c0b956..f97acce4 100644 --- a/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs +++ b/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs @@ -17,7 +17,8 @@ namespace LearnStack.Api.Common; /// <remarks> /// <see cref="ProblemDetails.Title"/> intentionally carries the /// <c>lockey_*</c> localization key (matches the Standards 09 § API Surface -/// example). The frontend resolves the key against its i18n catalogue; the +/// example). Consumers own explicit supported mappings, separately from general +/// dotted UI catalogues (ADR-0027); arbitrary keys are not UI lookups. The /// wire value is stable across locales so support staff debugging in /// Insomnia / curl can match the lockey back to the catalogue entry. A /// future LocalizedMessage → text projector may compose a human-readable diff --git a/backend/src/LearnStack.SharedKernel/Localization/LocalizedMessage.cs b/backend/src/LearnStack.SharedKernel/Localization/LocalizedMessage.cs index 43480224..25dffd86 100644 --- a/backend/src/LearnStack.SharedKernel/Localization/LocalizedMessage.cs +++ b/backend/src/LearnStack.SharedKernel/Localization/LocalizedMessage.cs @@ -6,13 +6,13 @@ namespace LearnStack.SharedKernel.Localization; /// Localization-key carrier for every user-facing message LearnStack /// returns to the frontend. The backend never returns raw English text; /// it returns a <see cref="LocalizedMessage"/> whose <see cref="Key"/> -/// resolves to a translation on the client. +/// is stable wire data. Each consuming feature owns its supported UI mapping. /// </summary> /// <remarks> /// <para> /// The <c>lockey_</c> prefix invariant is enforced at the constructor: -/// every key must match the format the frontend's -/// <c>i18n</c> bundles ship under. Mis-prefixed keys fail loud at the +/// every backend message key uses this namespace, separately from dotted +/// general UI keys (ADR-0027). Mis-prefixed keys fail loud at the /// point of construction rather than silently resolving to "missing /// translation" at render time. Per Phase 02a Packet 2. /// </para> @@ -28,8 +28,8 @@ namespace LearnStack.SharedKernel.Localization; public sealed record LocalizedMessage { /// <summary> - /// The required prefix for every localization key. The frontend's - /// translation catalogues use the same prefix. + /// The required prefix for backend message keys. General frontend UI + /// catalogues use a separate dotted namespace. /// </summary> public const string RequiredPrefix = "lockey_"; diff --git a/docs/architecture/12-localization.md b/docs/architecture/12-localization.md index 3e86c807..c1aa3f1f 100644 --- a/docs/architecture/12-localization.md +++ b/docs/architecture/12-localization.md @@ -21,7 +21,7 @@ Initial renderer scope: requests a redesign. Accepted P02d-6 G39/G43 selects the mapping and proofs; Step 1 implements direction selection and fallback chrome; Step 2 adds catalog/course language attribution; Step 3 adds ordered lesson field labels. - Full product/manual proof remains Step 4. + Step 4 records the product proof and scoped manual accessibility observations. - P02d-6 accepts initial English and Turkish platform UI catalogues in [ADR-0027](../decisions/0027-frontend-i18n.md). Additional UI translations are added when authored; their absence must not narrow enabled content locales. @@ -283,8 +283,9 @@ redirect/slug registry. [Localization Standards § Education slug grammar](../standards/08-localization.md#education-slug-grammar) owns the accepted storage grammar. Its URL-segment restrictions leave translated content fully Unicode. The [Education spec](../modules/education/README.md#localization-and-url-identity) -owns the module's identity rules; public route templates and request normalization -remain later packet decisions. +owns the module's identity rules; P02d-4/6 deliver the public route templates and +request normalization recorded in +[Frontend Standards](../standards/07-frontend-architecture.md#routing). The routing consequences follow directly, and are behaviour rather than defects: @@ -343,8 +344,9 @@ Step 1 implements the pinned dependency/plugin, catalogues, request runtime and key/ICU/callsite controls. The [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation) owns foundation verification and review evidence. Step 2 adds catalog/course -consumers; Step 3 adds ordered lesson labels and metadata. Full product/manual -proof remains Step 4. Phase 04 inherits +consumers; Step 3 adds ordered lesson labels and metadata. The +[Step 4 closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) +records product proof and scoped manual accessibility evidence. Phase 04 inherits the foundation for CMS/Studio; Phase 06 owns the full Studio/portal consumers. diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index b0575822..debb5086 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -40,6 +40,12 @@ theme, freshness and content-fault product proofs. The [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) owns validation, completed reviews and passing manual accessibility closeout. +[ADR-0055](../decisions/0055-public-renderer-bootstrap-failures.md) is Proposed: +it separates temporary second-bootstrap failures from missing admission. The +[review decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-systematic-review-decision-package-2026-10-10) +also proposes Open Graph projection and a bounded Next security maintenance note. +These proposals change no current Accepted contract before maintainer approval. + > **P02d-4 Accepted design — 2026-10-03.** Step 2 delivers site bootstrap; > Steps 3–4 deliver Education and contract/SDK controls; P02d-5 owns the server consumer. > [ADR-0052](../decisions/0052-anonymous-public-read-boundary.md) selects host-resolved @@ -117,7 +123,8 @@ Splitting into separate apps is governed by [ADR 0009 — Frontend Single App Fi ## Tenant + Organization Resolution at the Edge **Accepted public boundary — 2026-10-03.** Site bootstrap is delivered in Step 2; -P02d-5 Step 3 delivers server transport; page consumers remain P02d-6. The frontend +P02d-5 Step 3 delivers server transport; P02d-6 delivers the public page consumers. +The frontend uses host-resolved site bootstrap, not an edge registry returning tenancy IDs. [ADR-0052](../decisions/0052-anonymous-public-read-boundary.md) owns the read boundary; [Frontend Standards](../standards/07-frontend-architecture.md#tenant-resolution) diff --git a/docs/decisions/0055-public-renderer-bootstrap-failures.md b/docs/decisions/0055-public-renderer-bootstrap-failures.md new file mode 100644 index 00000000..92cf0a1d --- /dev/null +++ b/docs/decisions/0055-public-renderer-bootstrap-failures.md @@ -0,0 +1,130 @@ +# ADR-0055: Distinguish Temporary Public Renderer Bootstrap Failures + +## Status + +Proposed — 2026-10-10. Maintainer approval is pending. + +**Date:** 2026-10-10 +**Deciders:** @cemil (approval pending) + +This proposal changes only P02d-6 G40's second, RSC bootstrap failure contract. +It accepts no gate and authorizes no dependent implementation. Existing Accepted +ADR decisions and the original G40 record remain unchanged until approval. + +## Decision Drivers + +- Middleware and RSC independently read current site configuration; no shared + representation cache or middleware-to-RSC configuration carrier is permitted. +- An API outage, quota refusal or invalid response after middleware succeeds + currently becomes a not-found outcome in the RSC admission loader. +- A temporary inability to admit a tenant document does not establish that the + requested resource is missing. No Education read or tenant theme is safe then. +- App Router Server Components do not own arbitrary HTTP status/response headers. + An exact 429/503 design would require a different response owner. + +## Considered Options + +1. **Distinguish temporary failure and use a sanitized pre-shell framework 500** + (recommended). Preserve live admission and framework response ownership while + avoiding a false 404. Require an actual production-wire proof before delivery. +2. **Keep every RSC bootstrap failure as 404**. Rejected because temporary + unavailability is incorrectly reported as resource absence. +3. **Render a neutral HTTP 200/noindex fallback after failed bootstrap**. + Rejected here because it extends the accepted content-error compromise to + failed admission and does not provide a temporary-server-error HTTP signal. +4. **Deliver exact 429/503 through a new response-owner design**. A valid larger + alternative, requiring its own concrete design and approval. Do not implement + it through an invented trusted DTO header, quota bypass, extra content + preflight or shared site cache. + +## Decision + +The public renderer distinguishes missing/denied admission from temporary second +bootstrap failure. Missing or denied admission keeps 404. Temporary RSC bootstrap +failure raises only a sanitized pre-shell framework failure and returns a neutral +HTTP 500, without a tenant document, Education read or configuration-derived +branding. Middleware-owned refusals retain their actual 404/429/503 and bounded +Retry-After. This decision does not claim RSC-owned 429/503 or Retry-After. + +Temporary outcomes include non-404 site-read failures, transport/invalid-response +outcomes, configuration exceptions and a site entry reporting 503 after a +successful read. Invalid provenance, the client's verification refusal, API 404 +and entry 404 remain missing/denied admission; they do not become server errors. + +The proposal preserves the approved normal three-call path, five-call followed +missing path, missing-detail 307 to the fixed localized 404, and admitted +content-error HTTP 200/noindex states. It changes no provenance, quota, transport +deadline, byte bound, site-data transfer or request-memo lifetime. + +## Context + +At `ed6f81e`, middleware classifies its bootstrap failures, while +`getPublicRequest` collapses both API failure and entry refusal to `null`. +`requirePublicResource` then calls `notFound()` from the root layout. The same +request can therefore pass the first bootstrap and report a false 404 when the +second one is rate limited or unavailable. + +The existing +[G40 package](../roadmap/phase-02d-walking-skeleton.md#routes-pagination-metadata-and-chrome) +already distinguishes admitted content failures from bootstrap refusals. Approval +adds a dated G40 replacement for the RSC part, rather than rewriting the original +accepted answer or claiming that Server Components can set arbitrary statuses. + +The neutral framework failure may use the framework's generic error document; +this proposal does not promise tenant branding, localized recovery copy or an +arbitrary response status from `error.tsx`. A future change to that response +experience needs a concrete response-owner design before implementation. + +## Consequences + +### Positive + +- Temporary site-read failure no longer declares a valid resource missing. +- Failed admission cannot load Education content or reuse stale tenant styling. +- Existing provenance, fresh reads and quota accounting remain intact. + +### Negative + +- A second-bootstrap 429 becomes neutral 500, rather than preserving its exact + status and Retry-After. This limitation is explicit and must be tested. +- The neutral failure document is less informative than the admitted translated + content-error views. No complete recovery or localization claim is made. + +### Neutral + +- Admitted content-error and cursor SEO policies retain their earlier approval. +- Public API responses and ADR-0052's API eligibility contract do not change. +- Studio/portal remain admitted scaffolds, with authentication/UI owned by their + existing phases; they inherit the same bootstrap failure classification. + +## Implementation Notes + +P02d-6 remediation owns the typed admission result, sanitized pre-shell failure, +carrier reconciliation and production regression. On acceptance, add the dated +G40 replacement and cite this ADR from Standards 07/09 and Frontend Architecture. +Do not modify earlier Accepted ADR bodies or their historical delivery notes. + +Delivery requires second-bootstrap fault injection for quota, unavailable, +transport and invalid-response outcomes after a successful middleware bootstrap. +Assert the actual original-document HTTP status, no-store behavior, no Education +read, no private diagnostics and no tenant-themed fallback. Include a recovered +next request and existing real missing/denied controls. If the pinned framework +cannot prove pre-shell 500, stop and revisit this proposal; do not silently ship +another status or a streamed 200. + +## Architecture Tests + +Extend the existing public page-state and isolation proofs after acceptance. +This Proposed document registers no passing test and changes no Implemented +catalogue status. Existing request-local memo and source-boundary controls remain +mandatory. + +## References + +- [ADR-0027 — Frontend UI Localization](0027-frontend-i18n.md) +- [ADR-0052 — Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md) +- [ADR-0053 — Trusted Public Server Rendering](0053-trusted-public-server-rendering.md) +- [ADR-0054 — Bounded Public Renderer Admission](0054-bounded-public-renderer-admission.md) +- [P02d-6 review decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-systematic-review-decision-package-2026-10-10) +- [Frontend Error Handling](../standards/09-error-handling.md#public-page-status-and-recovery) +- [Google — HTTP status and network errors](https://developers.google.com/crawling/docs/troubleshooting/http-status-codes) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 297de2ac..d6269bc7 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -76,6 +76,7 @@ an amendment is not a lifecycle status change. | # | Title | Topic | Target phase / decision point | |---|---|---|---| | 0049 | [Institution Sites and an Optional Course Marketplace](0049-institution-sites-and-course-marketplace.md) | Direction endorsed 2026-10-02; architecture still Proposed, no gate accepted | P02d-2 preparation hold released; remaining contracts before proposed Phase 09a's first consumers | +| 0055 | [Distinguish Temporary Public Renderer Bootstrap Failures](0055-public-renderer-bootstrap-failures.md) | Separate missing/denied admission from temporary second-bootstrap failure; neutral pre-shell 500 proposed | P02d-6 remediation; approval before dependent implementation | The maintainer endorsed the marketplace direction and accepted ADR-0050/0051 and the P02d-2 package on 2026-10-02. Their rows are now in Active ADRs; ADR-0049 remains diff --git a/docs/glossary.md b/docs/glossary.md index 624f1158..becf8322 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -199,7 +199,7 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | Term | Definition | |------|------------| -| **TenantBranding** | The tenant's presentation tokens. Not an aggregate of its own: the values are tenant settings held in `tenant_settings` ([Frontend Architecture Standards § Tenant Branding](standards/07-frontend-architecture.md#tenant-branding)). G16(a–e) is Accepted and delivered in P02d-2; the [Tenancy contract](modules/tenancy/README.md#p02d-2-accepted-locale-and-branding-contract) owns the keys and value grammar. Anonymous projection and attribution are delivered G16(f/g) in P02d-4 Step 2, and server-rendered injection remains G42, in [Phase 02d's decision register](roadmap/phase-02d-walking-skeleton.md#the-decision-register). | +| **TenantBranding** | The tenant's presentation tokens. Not an aggregate of its own: the values are tenant settings held in `tenant_settings` ([Frontend Architecture Standards § Tenant Branding](standards/07-frontend-architecture.md#tenant-branding)). G16(a–e) is Accepted and delivered in P02d-2; the [Tenancy contract](modules/tenancy/README.md#p02d-2-accepted-locale-and-branding-contract) owns the keys and value grammar. Anonymous projection and attribution are delivered G16(f/g) in P02d-4 Step 2, and P02d-6 Step 1 delivers G42 server-rendered injection, recorded in [Phase 02d's decision register](roadmap/phase-02d-walking-skeleton.md#the-decision-register). | | **`branding.theme`** | P02d-2 delivers the writer for a single tenant-wide TenantSetting document with four validated color fields; one root/version protects contrast during concurrent replacement. The [Tenancy spec](modules/tenancy/README.md#whole-theme-setting-and-public-boundary) owns the command-local registry; other generic setting keys remain legal. Anonymous projection is delivered by P02d-4 Step 2; P02d-6 Step 1 implements atomic document color injection. | | **OrganizationBranding** | A planned per-organization design-token override owned by [Phase 06](roadmap/phase-06-renderer-admin-studio.md). Its partial token merge is not implemented by P02d-2/3; the generic settings scope model does not authorize an organization override of `branding.theme`. | diff --git a/docs/modules/customization/README.md b/docs/modules/customization/README.md index 73dd9c9d..e2a3940c 100644 --- a/docs/modules/customization/README.md +++ b/docs/modules/customization/README.md @@ -198,7 +198,8 @@ nondeleted definitions are eligible; missing individual pins remain distinguisha without failing unrelated members or substituting another revision. Labels resolve per call with actual locale metadata from the caller's display-locale context. P02d-4 Step 3 delivers the public response/refusal contract; -page rendering remains P02d-6. The coherent loader supplies +P02d-6 Steps 2–3 deliver the catalog, course and lesson views. The coherent loader +supplies generation-keyed families; dirty or rollback-only scopes bypass their cache. The writer reader remains uncached. diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index ecd75022..c4e3eee8 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -182,9 +182,8 @@ replacement yields a concurrency conflict; competing creates use the existing tenant/scope/key uniqueness. No retry merges colors from different candidates. Invalid existing override projects as a whole null in P02d-4 Step 2; safe CSS defaults remain the renderer's responsibility. Never emit raw JSON or partial unsafe -colors. Whole-theme/null and attribution-only semantics are delivered; G42 still -owns injection. Authoring -this baseline theme is +colors. Whole-theme/null and attribution-only semantics are delivered; P02d-6 +Step 1 implements G42 atomic CSS injection. Authoring this baseline theme is not gated by `tenancy.white_label_branding` in P02d-2. Generic `TenantSetting.Value` carries `[PiiSensitive]`; audit capture redacts the whole @@ -203,7 +202,7 @@ No raw string-key/JSON export, settings HTTP surface or caller-supplied scope is admitted. The first production registration is tenant-wide `branding.theme`, reusing its four-color grammar and contrast validator above. It returns a complete typed palette or bounded absent/invalid outcome. P02d-4 public allowlisting is -delivered by Step 2; CSS injection remains P02d-6. +delivered by Step 2; P02d-6 Step 1 delivers atomic CSS injection. Registrations are values in an explicit server-owned `TenantSettingRegistry`. The composition extension installs its default only when no registry was already diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index e233eb03..5f262b2a 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -72,7 +72,8 @@ not deferred to the showcase phase. P02d-6's ADR-0027 and decision package are Accepted — 2026-10-09. Its localized catalog/course/lesson pages and product proofs are implemented; the [closeout record](phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) - records completed reviews and passing manual accessibility. Implementation is + records completed reviews and scoped browser/VoiceOver observations. + Implementation is complete; maintainer PR review and merge remain pending. P7 owns the browser/demo. - [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) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index ec9cfb85..a4944d4b 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -14,7 +14,7 @@ > | 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; manual accessibility passed; [packet closeout](#p02d-6-packet-closeout-2026-10-10); maintainer PR review/merge pending | +> | 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 | **Acceptance update — 2026-10-02.** P02d-1 remains merged. The maintainer accepted @@ -4211,8 +4211,8 @@ documentation checks; no product-runtime or accessibility proof is claimed. ### P02d-6 Step 1: Localization and document foundation -**Implementation in progress — 2026-10-10; both independent review rounds not -started.** Work stays on `development`. This record covers the foundation only; +**Foundation implemented — 2026-10-10; both independent review rounds complete.** +Work stays on `development`. This record covers the foundation only; actual catalog/course pages remain Step 2 and lesson presentation remains Step 3. It does not close P02d-6 or any product/manual accessibility criterion. @@ -4752,6 +4752,144 @@ documentation checks. Neither reviewer claims a runtime-suite rerun. The [PR #27 description](https://github.com/HodeTech/LearnStack/pull/27) owns the exact-head CI evidence. +#### P02d-6 systematic-review decision package (2026-10-10) + +**Proposed; maintainer approval pending.** The two external reports at `ed6f81e` +are review data, not new authority. Rechecks confirm guidance/status drift, +ordinary static source-fence gaps, missing dirty/clean response controls and a +second-bootstrap failure-classification gap. No accepted behavior is changed by +this preparation record. + +The existing G40 content-error HTTP 200/noindex and cursor-free canonical/noindex +policies are accepted choices, not implementation drift. Google's +[pagination guidance](https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading) +recommends individual canonicals; its +[status guidance](https://developers.google.com/crawling/docs/troubleshooting/http-status-codes) +explains temporary-error and soft-404 tradeoffs. These recommendations justify +recording the tradeoff, not silently reopening the approved policies. + +G10 explicitly defines editable base64url JSON seek cursors without confidentiality +or authentication. G40's opacity means the renderer does not interpret the token; +it does not hide anchor UUIDs from visitors. Scope/eligibility remains independently +enforced. Encryption, MAC rotation or server-side cursor state would need a new +decision and is not a correction to this PR. + +##### Decisions requested together + +1. **Second bootstrap:** approve proposed + [ADR-0055](../decisions/0055-public-renderer-bootstrap-failures.md). Actual + missing/denied admission remains 404; temporary RSC bootstrap failures use a + sanitized pre-shell neutral framework 500, with no Education read or tenant + theme. Middleware keeps real 404/429/503 and bounded Retry-After. Exact RSC + 429/503 requires a larger response-owner design; it is not promised here. +2. **Open Graph locale:** emit `language_TERRITORY` only when the admitted tag has + an explicit two-letter territory and no script/variant information would be + silently discarded. Omit unrepresentable locale/alternate properties rather + than inventing a territory. `tr-TR` becomes `tr_TR`; `en`, `tr`, `zh-Hant` and + numeric-region tags are omitted. Document language, route and hreflang keep + their exact BCP-47 tags. The [protocol](https://ogp.me/) makes these properties + optional; omission can leave consumers applying their own default. Approval + adds this bounded G40 projection and qualifies Standards 08's blanket rule. +3. **Security maintenance:** pin Next and eslint-config-next to 15.5.27, disable + the unused image optimizer, inspect/patch affected transitive versions within + compatible bounds, and re-prove native ingress and actual production HTML/RSC. + The fresh audit reports 46 findings (2 critical, 28 high, 15 moderate, 1 low), + not 46 reachable public flaws. The AVIF + [advisory](https://github.com/advisories/GHSA-2xp9-vwfh-vxw4) affects the current + dependency version; attacker-controlled image input is not demonstrated. + The [15.5.27 metadata](https://registry.npmjs.org/next/15.5.27) supplies a + compatible 15.x backport. A Next bump alone does not clear transitive findings; + record each remaining advisory's actual input path and maintenance disposition. + +The proposed ADR-0054 maintenance amendment, appended only after approval and +passing replacement proofs, reads: + +> **Amendment 7 — Next security maintenance (2026-10-10).** The current verified +> runtime pin is Next 15.5.27 with matching eslint-config-next. The signed raw +> target, full-URL RSC projection and equivalent redirect-query contract are +> unchanged. The unused image optimizer is disabled. P02d-6's remediation record +> owns the replacement native/production proofs and dependency triage; historical +> 15.5.18 observations remain unchanged. + +No speculative native-addon/install-script policy is introduced. Existing exact +pins, frozen lockfile and real builds remain mandatory. Dependency patching does +not establish air-gapped packaging support. + +##### Remediation plan and review boundaries + +1. Correct current guidance/status carriers and skill examples. Strengthen the + existing response assertions, source-retention/prefetch fences, production + formatter-path tests and applicable accessibility controls. Align decimal + limit parsing and closed cursor-error classification with existing contracts. + These corrections need no new behavior decision. +2. After approval, implement ADR-0055 and the G40 Open Graph projection; inject + second-bootstrap faults through the actual production fixture. Preserve + provenance, read-only/RLS, request counts and the approved content-error policy. +3. After maintenance-note approval, apply compatible security patches and close + unused image optimization; re-run affected full/native/real-API suites, audit, + formatting and local link/fragment checks. Record fresh exact-head PR CI. + +Each group is committed and receives two fresh independent review rounds, with +verified findings corrected before the next group. All work stays on development. +The maintainer's earlier browser/VoiceOver report remains attributed to its tested +commit; later defensive/proof changes do not imply a new manual pass. P02d-7 and +Phase 06/11 retain their explicitly named browser, full accessibility and hardening +work. This package makes no new passing runtime or merge-readiness claim. + +##### Remediation group 1 — existing-contract corrections + +**Implemented; independent review rounds pending — 2026-10-10.** No proposed +decision above is implemented by this group. ADR-0055 remains Proposed. + +The Step 1 status and current i18n/theme carriers now match delivery. Skills use +locale-bearing public folders, the shared resource loader, supported Next page +exports and explicit catalogue wiring. Future block/entitlement sketches are +labelled as targets. Phase 06 explicitly owns localized section names and full +Playwright/axe completion; Phase 11 explicitly owns frontend telemetry. + +The production response predicates are shared with dirty/clean companions: +inline Flight escaping, entity-encoded attributes, opposite/protected values, +whole/partial palette emission, attribution and localized status metadata. +Emitted viewport checks reject zoom restrictions; they are not browser zoom proof. +The two unused ICU sentinels detect unchanged complete catalogues, not arbitrary +partial copy leakage. Static import fences supply the separate client boundary. +The bounded source analyzer now covers retained local factories/collections, +module mutable bindings, static/global writes and Next Link/router imports. +Admitted Studio/portal scaffolds are render roots and explicitly noindex. + +Direct tests exercise the actual request configuration and public UI wrapper; +callsite census follows ordinary aliases/Promise tuples and requires a nonempty +production subject. Actual lint controls cover image, object, area and image-input +alt text. A prefixed style-breakout value tests the theme regex's start anchor. +Loading uses a status live region. Counts explicitly describe the current page. +Positive decimal limits saturate at the API bound without rejecting long valid +values; only exclusive owned-cursor errors with a supplied cursor select reset UI. + +The following review claims do not justify changing accepted behavior: + +- Cursor confidentiality, self-canonical pagination and content-error HTTP status + are already decided; their tradeoffs and pending approval boundaries are above. +- Studio/portal bootstrap is the admitted scaffold contract, not an implemented + authentication surface. Their full UI remains Phase 04/06/07. +- Missing error-label context is outside the current provider-wrapped public + tree. No whole catalogue or hardcoded fallback is added to its Client Component. + The assertion that `reset` can never recover is not reproduced; the supported + framework reset and catalog recovery link remain. +- Security headers remain Phase 11's explicit scope. Arbitrary reflective/eval + source analysis, a complete WCAG audit and browser-computed theme/zoom proof are + not newly claimed by the strengthened bounded controls. +- ADR-0027's Decision explicitly authorizes the contract below it; the Context + placement does not make that contract nonbinding. Accepted history is preserved. +- Packet 10's 19/3 standards count is historical; its timestamp is now explicit. + Phase 06's P6 review/merge-pending statement is still true for this open PR. + +Validation: the guarded frontend runner passes 1,196 cases (1,136 web, 60 SDK), +with zero skips/todos; workspace lint/typecheck pass. Five Release corpus tests +and seven ADR-workflow cases pass. The final focused Release integration run passes +20 cases: six real production Next/native HTTPS/API/PostgreSQL modes and fourteen +cleanup/log-control companions, with zero skips. No backend full-suite rerun or new +manual accessibility pass is inferred from these focused checks. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/roadmap/phase-06-renderer-admin-studio.md b/docs/roadmap/phase-06-renderer-admin-studio.md index 7c286ce2..94f0f8c0 100644 --- a/docs/roadmap/phase-06-renderer-admin-studio.md +++ b/docs/roadmap/phase-06-renderer-admin-studio.md @@ -62,6 +62,9 @@ The Next.js `apps/web` app supports: block ([ADR-0013](../decisions/0013-page-block-schema-versioning.md)). - Navigation rendering — header, footer, nested items, internal page and catalog references. +- Localized section names and their admission/redirect policy, extending P02d-6's + fixed `courses` / `lessons` segments under + [Localization Standards](../standards/08-localization.md#locale-model). - Course catalog page and course detail page, driven by tenant-defined blocks over the [Phase 05](phase-05-education-learning-content.md) catalog data. - SEO metadata: per-page title and description, canonical URLs, Open Graph, sitemap and @@ -284,6 +287,9 @@ browser suite against a mis-provisioned stack is worse evidence than no suite. - The browser end-to-end suite runs green against `infra/compose/e2e.yml` with the PostgreSQL init script and the SeaweedFS S3 identity file **present**, and two consecutive runs are independent of one another. +- The full Playwright/axe suite covers the critical public and Studio flows, + alongside the manual checks required by + [Accessibility Standards](../standards/16-accessibility.md#testing). - Both seed tenants — the English school and the yoga studio — produce visually and structurally different published sites from the same binary, now with navigation, SEO and full page composition rather than the skeleton pages Phase 02d ships, whose set is diff --git a/docs/roadmap/phase-11-production-hardening.md b/docs/roadmap/phase-11-production-hardening.md index 7cd1216f..1085bad5 100644 --- a/docs/roadmap/phase-11-production-hardening.md +++ b/docs/roadmap/phase-11-production-hardening.md @@ -354,6 +354,9 @@ bounds request *cost* once a request is inside. Neither substitutes for the othe ### Observability +- Frontend structured logging, Sentry and web-vitals telemetry, owned here by + [Frontend Observability](../standards/10-observability.md#frontend-observability). + P02d-5/6 provide no web-vitals hook. - Structured logs. - Correlation id end to end. - OpenTelemetry traces. diff --git a/docs/standards/07-frontend-architecture.md b/docs/standards/07-frontend-architecture.md index 9e41acf9..66ef0560 100644 --- a/docs/standards/07-frontend-architecture.md +++ b/docs/standards/07-frontend-architecture.md @@ -269,8 +269,15 @@ status redirect uses five total. No retry or shared DTO cache is added. P02d-5 remediation Step 4 follows header constructors and converted records through bounded declaration aliases. Inherited tsconfig paths and workspace exports must -match the resolver census; unsupported production `.mts`/`.cts` extensions fail -until the census/resolver supports them. Declaration-level `import type` and +match the resolver census; unsupported production `.js`/`.jsx`/`.mjs`/`.cjs`/ +`.mts`/`.cts` extensions fail until the census/resolver supports them. Admitted +Studio/portal scaffolds join the render-root census. Public navigation uses +document anchors; runtime Next Link/router imports are refused, including +transitive barrels. +The retention fence covers module mutable bindings, local factories/IIFEs, +collections, class-static and global writes. It is bounded source analysis, +not an interpreter for reflection, arbitrary evaluation or external packages. +Declaration-level `import type` and `export type` edges are erased. Inline type-only specifiers remain conservative source edges: TypeScript verbatim emission retains them; pinned Next 15.5.18 SWC erases them. Separate real compiler controls establish that distinction. diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 151339b9..e3bd8bbb 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -4221,7 +4221,9 @@ verification pass; the delivery record owns execution evidence. - **Status:** **Implemented** — `public-boundaries.test.ts` names this rule, checks the public layout policy and follows public/helper imports for shared storage, forbidden Next cache APIs and static/revalidation overrides. Clean/planted - controls accompany it; request-local React cache remains allowed. P02d-6 Step 1 + controls accompany it; request-local React cache remains allowed. P6 remediation + adds retained factory/collection, module/static/global storage and navigation + import controls, including admitted Studio/portal roots. P02d-6 Step 1 also constrains the complete AST shape of `request-memo.ts`: a private weak map keyed only by Next's exact request-store headers object, publish-before-load semantics and retained rejected work. Planted strong maps, copied/value/caller @@ -4254,6 +4256,9 @@ owns execution, review and manual evidence. - **Source:** ADR-0027; Accepted P02d-6 G39. - **Type:** frontend catalogue/callsite + production build. **Kind:** structural. - **Status:** **Implemented** — `frontend/apps/web/src/i18n/catalogues.test.ts`; + actual request-configuration callbacks in `frontend/apps/web/src/i18n/request.test.ts` + and typed translator argument forwarding in + `frontend/apps/web/src/server/public-ui.test.ts`; `PublicServerRenderingTests` production build, P02d-6. - **Phase:** 02d (P02d-6). @@ -4282,6 +4287,8 @@ owns execution, review and manual evidence. - **Status:** **Implemented** — `PublicServerRenderingTests` foundation/ product-isolation modes and `frontend/apps/web/src/components/public/foundation.test.tsx`, P02d-6. + `frontend/apps/web/src/test/public-rendering-assertions.test.ts` supplies + clean/planted controls against the production theme assertion itself. - **Phase:** 02d (P02d-6). ### `Public_Pages_Preserve_Approved_Response_States` @@ -4296,6 +4303,10 @@ owns execution, review and manual evidence. - **Status:** **Implemented** — `PublicServerRenderingTests` foundation/pagination/ presentation/product-freshness modes and `frontend/apps/web/src/server/public-resource.test.ts`, P02d-6. + `frontend/apps/web/src/test/public-rendering-assertions.test.ts` exercises the + shared production status and whole-response containment predicates, including + Next's nested HTML-safe Flight escaping. Raw ICU sentinels detect unchanged + complete catalogue payloads; source fences independently restrict imports. - **Phase:** 02d (P02d-6). ### `Public_Pages_Expose_Localized_Accessible_Semantics` @@ -4312,6 +4323,8 @@ owns execution, review and manual evidence. `frontend/apps/web/src/components/public/lesson.test.tsx`, P02d-6. Manual keyboard/focus/reflow/contrast and maintainer VoiceOver evidence remain separate in the packet closeout; these tests do not claim a full accessibility audit. + Emitted viewport metadata permits zoom, tested by the shared production + assertion controls; this does not prove browser zoom or WCAG conformance. - **Phase:** 02d (P02d-6). ## References diff --git a/frontend/apps/web/.eslintrc.cjs b/frontend/apps/web/.eslintrc.cjs index 28da56c7..6c500e0f 100644 --- a/frontend/apps/web/.eslintrc.cjs +++ b/frontend/apps/web/.eslintrc.cjs @@ -29,7 +29,15 @@ const jsxA11yErrors = Object.fromEntries( module.exports = { root: true, extends: [require.resolve('@learnstack/config/eslint'), 'next/core-web-vitals'], - rules: jsxA11yErrors, + rules: { + ...jsxA11yErrors, + // Next's inherited options narrow this rule to img. Restore every native + // recommended element while retaining its Next Image component mapping. + 'jsx-a11y/alt-text': [ + 'error', + { elements: ['img', 'object', 'area', 'input[type="image"]'], img: ['Image'] }, + ], + }, parserOptions: { project: ['./tsconfig.json'], tsconfigRootDir: __dirname, diff --git a/frontend/apps/web/README.md b/frontend/apps/web/README.md index a84bbaff..c1351016 100644 --- a/frontend/apps/web/README.md +++ b/frontend/apps/web/README.md @@ -12,20 +12,22 @@ Route groups (`(public)`, `(studio)`, `(portal)`) organize files without affecting URLs — each surface owns a distinct URL prefix so the three roots don't collide at `/`: -| Route group | URL prefix | Purpose | Phase that fills it in | -| ------------------ | ---------- | ------------------------------------------------ | ---------------------- | -| `(public)/` | `/` | Tenant-facing public site | 02d / 04 / 06 | -| `(studio)/studio/` | `/studio` | Admin + content studio | 04 / 06 | -| `(portal)/portal/` | `/portal` | Learner + instructor portal | 07 | -| `api/` | `/api/*` | Thin BFF route handlers (`/api/healthz` shipped) | 02a+ | +| Route group | URL prefix | Purpose | Phase that fills it in | +| ------------------------------------- | ---------------------------- | ------------------------------------------------ | ------------------------ | +| `(public)/[locale]/courses/` | `/{locale}/courses` | Implemented catalog, course and lesson pages | 02d; expanded by 04 / 06 | +| `(public)/[locale]/status/not-found/` | `/{locale}/status/not-found` | Localized fixed 404 recovery document | 02d | +| `(studio)/studio/` | `/studio` | Admin + content studio | 04 / 06 | +| `(portal)/portal/` | `/portal` | Learner + instructor portal | 07 | +| `api/` | `/api/*` | Thin BFF route handlers (`/api/healthz` shipped) | 02a+ | P02d-4 delivers the API reads; P02d-5 delivers live bootstrap and membership-first entry under [ADR-0053](../../../docs/decisions/0053-trusted-public-server-rendering.md). +`/` redirects through live default-locale admission; it is not a placeholder page. Exact scaffold roots continue only after bootstrap; enabled locale membership takes 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 passing manual accessibility. +records completed verification, reviews and scoped browser/VoiceOver observations. P6 awaits maintainer PR review and merge. There is **no `extensions/` folder for vertical-provided components** — per diff --git a/frontend/apps/web/scripts/public-rendering-assertions.d.mts b/frontend/apps/web/scripts/public-rendering-assertions.d.mts new file mode 100644 index 00000000..79b82d0c --- /dev/null +++ b/frontend/apps/web/scripts/public-rendering-assertions.d.mts @@ -0,0 +1,60 @@ +type Response = { + readonly body: string; + readonly status: number; + readonly headers: Readonly<Record<string, string | undefined>>; +}; +type Tenant = { readonly host: string; readonly name: string }; +type Product = { + readonly host: string; + readonly protectedCanaries: readonly string[]; + readonly allCanaries: readonly string[]; + readonly courseTitle: string; + readonly courseSummary: string; + readonly lessonTitle: string; + readonly content: { readonly fields: readonly { readonly value: string }[] }; +}; +export function absentFromSerializedText( + text: string, + values: readonly string[], + mark?: (index: number) => void, +): void; +export function absentFromWholeResponse( + response: Pick<Response, 'body'>, + values: readonly string[], + mark?: (index: number) => void, +): void; +export function visibleDocument(response: Pick<Response, 'body'>): Document; +export function documentViewport(doc: Document): void; +export function statusDocument( + response: Response, + tenant: Tenant, + locale: string, + uiLocale: string, + options: { + readonly tenants: readonly Tenant[]; + readonly catalogueSentinels?: readonly string[]; + readonly mark?: (part: string) => void; + }, +): Document; +export function productContainment( + response: Pick<Response, 'body'>, + tenant: Tenant, + configuration: { readonly tenants: readonly Tenant[]; readonly product: readonly Product[] }, + catalogueSentinels?: readonly string[], + mark?: (index: number) => void, +): void; +export function productTheme( + doc: Document, + details: { + readonly theme: { + readonly primary: string; + readonly background: string; + readonly foreground: string; + readonly muted: string; + }; + readonly showAttribution: boolean; + readonly locale: string; + }, + malformed?: boolean, + readStylesheet?: (path: string) => string, +): void; diff --git a/frontend/apps/web/scripts/public-rendering-assertions.mjs b/frontend/apps/web/scripts/public-rendering-assertions.mjs new file mode 100644 index 00000000..45f3bfcd --- /dev/null +++ b/frontend/apps/web/scripts/public-rendering-assertions.mjs @@ -0,0 +1,195 @@ +// Shared assertions used by the production fixture and its planted controls. +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const { JSDOM } = createRequire(import.meta.url)('jsdom'); + +// Next inlines JSON-serialized Flight strings inside HTML-safe JSON. Scan each +// supported serialization layer without executing scripts or decoding arbitrary JS. +function jsonText(value) { + return JSON.stringify(value).slice(1, -1); +} + +function htmlSafeJson(value) { + const escapes = { + '&': '\\u0026', + '>': '\\u003e', + '<': '\\u003c', + '\u2028': '\\u2028', + '\u2029': '\\u2029', + }; + return value.replace(/[&><\u2028\u2029]/g, (character) => escapes[character]); +} + +export function absentFromSerializedText(text, values, mark = () => {}) { + for (const [index, value] of values.entries()) { + mark(index); + assert.ok(value.length > 0, 'Leak markers are nonempty'); + const serialized = [value, jsonText(value), jsonText(jsonText(value))]; + const representations = new Set(serialized.flatMap((text) => [text, htmlSafeJson(text)])); + for (const representation of representations) + assert.equal(text.includes(representation), false, 'Complete HTML/Flight containment'); + } +} + +export function absentFromWholeResponse(response, values, mark = () => {}) { + absentFromSerializedText(response.body, values, mark); + const doc = new JSDOM(response.body).window.document; + const decoded = [doc.documentElement.textContent]; + for (const element of doc.querySelectorAll('*')) + for (const attribute of element.attributes) decoded.push(attribute.value); + for (const value of values) + assert.equal( + decoded.some((text) => text.includes(value)), + false, + 'Entity-decoded response containment', + ); +} + +export function visibleDocument(response) { + const dom = new JSDOM(response.body); + // Assertions below inspect the rendered document, never strings embedded in + // Flight/bootstrap scripts that may describe UI absent from visible HTML. + dom.window.document + .querySelectorAll('script, template, [hidden]') + .forEach((node) => node.remove()); + return dom.window.document; +} + +/** Inspect emitted viewport metadata, independently of browser reflow/zoom evidence. */ +export function documentViewport(doc) { + const viewports = doc.querySelectorAll('meta[name="viewport"]'); + assert.equal(viewports.length, 1, 'One emitted viewport declaration'); + const options = new Map( + viewports[0].content.split(',').map((part) => + part + .trim() + .toLowerCase() + .split(/\s*=\s*/), + ), + ); + assert.equal(options.get('width'), 'device-width', 'Viewport follows device width'); + assert.ok(!['no', '0'].includes(options.get('user-scalable')), 'Viewport must not disable zoom'); + const maximum = options.get('maximum-scale'); + assert.ok( + maximum === undefined || Number(maximum) >= 2, + 'Viewport must permit at least 200% zoom', + ); +} + +export function statusDocument(response, tenant, locale, uiLocale, options) { + const { tenants, catalogueSentinels = [], mark = () => {} } = options; + mark('status headers'); + assert.equal(response.status, 404, 'Public status response'); + mark('HTML content-type'); + assert.match(response.headers['content-type'], /^text\/html/); + mark('catalogue containment'); + absentFromWholeResponse(response, catalogueSentinels); + const doc = visibleDocument(response); + mark('viewport metadata'); + documentViewport(doc); + mark('document locale'); + assert.equal(doc.documentElement.lang, locale, 'Document retains admitted content locale'); + assert.equal(doc.documentElement.dir, locale === 'ar' ? 'rtl' : 'ltr'); + mark('visible heading'); + const heading = doc.querySelector('h1'); + assert.equal(doc.querySelectorAll('h1').length, 1, 'One visible localized status heading'); + assert.equal(doc.querySelectorAll('main').length, 1, 'One main landmark'); + const title = uiLocale === 'tr' ? 'Sayfa bulunamadı' : 'Page not found'; + assert.equal(heading.textContent, title, 'Visible status translation'); + mark('localized metadata'); + if (!doc.title.includes(title)) { + const titleNode = new JSDOM(response.body).window.document.querySelector('title'); + mark( + 'localized metadata ' + + (titleNode?.closest('[hidden]') ? 'hidden' : titleNode ? 'unmatched' : 'absent'), + ); + } + assert.ok(doc.title.includes(title), 'Localized document title'); + mark('UI locale'); + assert.equal(heading.closest('[lang]').getAttribute('lang'), uiLocale, 'UI fallback language'); + assert.equal(heading.closest('[dir]').getAttribute('dir'), 'ltr', 'UI fallback direction'); + mark('robots recovery'); + assert.ok(doc.querySelector('meta[name="robots"]').content.includes('noindex')); + assert.ok(doc.querySelector('a[href="/' + locale + '/courses"]'), 'Same-locale recovery'); + mark('tenant chrome'); + assert.ok(doc.body.textContent.includes(tenant.name), 'Actual tenant chrome'); + const other = tenants.find((candidate) => candidate.host !== tenant.host); + assert.equal(doc.body.textContent.includes(other.name), false, 'No opposite tenant chrome'); + absentFromWholeResponse(response, [ + other.name, + 'missing-foundation-course', + 'private-query-value', + ]); + return doc; +} + +export function productContainment( + response, + tenant, + configuration, + catalogueSentinels = [], + mark = () => {}, +) { + const own = configuration.product.find((candidate) => candidate.host === tenant.host); + const other = configuration.product.find((candidate) => candidate.host !== tenant.host); + const opposite = configuration.tenants.find((candidate) => candidate.host !== tenant.host); + assert.ok(own.protectedCanaries.length > 0 && other.allCanaries.length > 0); + absentFromWholeResponse( + response, + [ + opposite.name, + ...other.allCanaries, + other.courseTitle, + other.courseSummary, + other.lessonTitle, + ...other.content.fields.map((field) => field.value), + ...own.protectedCanaries, + ...catalogueSentinels, + ], + mark, + ); +} + +export function productTheme(doc, details, malformed = false, readStylesheet) { + const styles = [...doc.querySelectorAll('style,[style]')].filter((element) => + /--ls-(?:primary|bg|fg|muted)\s*:/.test( + element.tagName === 'STYLE' ? element.textContent : element.getAttribute('style'), + ), + ); + const { primary, background, foreground, muted } = details.theme; + assert.equal(styles.length, malformed ? 0 : 1, 'A malformed palette emits no partial override'); + if (!malformed) + assert.equal( + styles[0].textContent, + `:root{--ls-primary:${primary};--ls-bg:${background};--ls-fg:${foreground};--ls-muted:${muted};}`, + 'Exact complete seed palette is independent of entitlement', + ); + const footer = doc.querySelector('footer.public-footer'); + assert.equal( + footer !== null, + details.showAttribution, + 'Effective tenant entitlement alone selects attribution', + ); + if (footer) + assert.equal( + footer.textContent, + details.locale === 'tr-TR' ? 'LearnStack altyapısıyla' : 'Powered by LearnStack', + ); + if (malformed) { + const cssLinks = [...doc.querySelectorAll('link[rel="stylesheet"]')]; + assert.ok(cssLinks.length > 0, 'The fallback has a real compiled stylesheet'); + const css = cssLinks + .map((link) => { + const path = link.getAttribute('href').split('?')[0]; + assert.match(path, /^\/_next\/static\/css\/[a-zA-Z0-9._-]+\.css$/); + return readStylesheet(path); + }) + .join(''); + assert.match( + css, + /:root\{[^}]*--ls-primary:#1f6feb;[^}]*--ls-bg:#fff(?:fff)?;[^}]*--ls-fg:#0f172a;[^}]*--ls-muted:#64748b[;}]/, + 'All four existing CSS defaults remain available together', + ); + } +} diff --git a/frontend/apps/web/scripts/verify-public-rendering.mjs b/frontend/apps/web/scripts/verify-public-rendering.mjs index 15c99493..a4504f23 100644 --- a/frontend/apps/web/scripts/verify-public-rendering.mjs +++ b/frontend/apps/web/scripts/verify-public-rendering.mjs @@ -26,6 +26,15 @@ import { createPrivateScanner, fixtureEnvironment, } from './fixture-support.mjs'; +import { + absentFromSerializedText, + absentFromWholeResponse as assertAbsentFromWholeResponse, + documentViewport, + productContainment as assertProductContainment, + productTheme as assertProductTheme, + statusDocument as assertStatusDocument, + visibleDocument as document, +} from './public-rendering-assertions.mjs'; const sourceApp = fileURLToPath(new URL('../', import.meta.url)); const { JSDOM } = createRequire(join(sourceApp, 'package.json'))('jsdom'); @@ -39,7 +48,7 @@ let stage = 'configuration'; let configuration; let certificate; let privateScanner; -let catalogueCanaries = []; +let catalogueSentinels = []; function checkPrivate(value) { return privateScanner.contains(value); @@ -259,11 +268,7 @@ function scanClientAssets(directory, expectedMarker = false) { marker ||= content.includes(clientMarker); } assert.equal(checkPrivate(content), false, 'Static client private-data containment'); - assert.equal( - catalogueCanaries.some((value) => content.includes(value)), - false, - 'Complete UI catalogue cannot enter browser assets', - ); + absentFromSerializedText(content, catalogueSentinels); } } if (expectedMarker) assert.equal(marker, true, 'Built public Client Component marker'); @@ -323,61 +328,16 @@ export default function PublicLayout({ children }: { children: ReactNode }) { } `; -function document(response) { - const dom = new JSDOM(response.body); - // Assertions below inspect the rendered document, never strings embedded in - // Flight/bootstrap scripts that may describe UI absent from visible HTML. - dom.window.document - .querySelectorAll('script, template, [hidden]') - .forEach((node) => node.remove()); - return dom.window.document; -} - function statusDocument(response, tenant, locale, uiLocale) { const phase = stage; - stage = phase + ' status headers'; safeResponse(response, 404); - stage = phase + ' HTML content-type'; - assert.match(response.headers['content-type'], /^text\/html/); - stage = phase + ' catalogue containment'; - assert.equal( - catalogueCanaries.some((value) => response.body.includes(value)), - false, - 'Complete UI catalogue cannot enter Flight payload', - ); - const doc = document(response); - stage = phase + ' document locale'; - assert.equal(doc.documentElement.lang, locale, 'Document retains admitted content locale'); - assert.equal(doc.documentElement.dir, locale === 'ar' ? 'rtl' : 'ltr'); - stage = phase + ' visible heading'; - const heading = doc.querySelector('h1'); - assert.equal(doc.querySelectorAll('h1').length, 1, 'One visible localized status heading'); - assert.equal(doc.querySelectorAll('main').length, 1, 'One main landmark'); - const title = uiLocale === 'tr' ? 'Sayfa bulunamadı' : 'Page not found'; - assert.equal(heading.textContent, title, 'Visible status translation'); - stage = phase + ' localized metadata'; - if (!doc.title.includes(title)) { - const original = new JSDOM(response.body).window.document; - const titleNode = original.querySelector('title'); - stage += titleNode?.closest('[hidden]') ? ' hidden' : titleNode ? ' unmatched' : ' absent'; - } - assert.ok(doc.title.includes(title), 'Localized document title'); - stage = phase + ' UI locale'; - assert.equal(heading.closest('[lang]').getAttribute('lang'), uiLocale, 'UI fallback language'); - assert.equal(heading.closest('[dir]').getAttribute('dir'), 'ltr', 'UI fallback direction'); - stage = phase + ' robots recovery'; - assert.ok(doc.querySelector('meta[name="robots"]').content.includes('noindex')); - assert.ok(doc.querySelector('a[href="/' + locale + '/courses"]'), 'Same-locale recovery'); - stage = phase + ' tenant chrome'; - assert.ok(doc.body.textContent.includes(tenant.name), 'Actual tenant chrome'); - const other = configuration.tenants.find((candidate) => candidate.host !== tenant.host); - assert.equal(doc.body.textContent.includes(other.name), false, 'No opposite tenant chrome'); - assert.equal( - response.body.includes('missing-foundation-course'), - false, - 'No original resource echo', - ); - assert.equal(response.body.includes('private-query-value'), false, 'No original query echo'); + const doc = assertStatusDocument(response, tenant, locale, uiLocale, { + tenants: configuration.tenants, + catalogueSentinels, + mark: (part) => { + stage = phase + ' ' + part; + }, + }); stage = phase; return doc; } @@ -385,6 +345,7 @@ function statusDocument(response, tenant, locale, uiLocale) { function productDocument(response, tenant, locale, heading, path, direction = 'ltr') { safeResponse(response, 200); const doc = document(response); + documentViewport(doc); assert.equal(doc.documentElement.lang, locale, 'Admitted document language'); assert.equal(doc.documentElement.dir, direction, 'Admitted document direction'); assert.equal(doc.querySelectorAll('main').length, 1, 'One product main landmark'); @@ -584,12 +545,7 @@ async function verifyFoundation(native, nextBin) { 'Protected outline is absent', ); assert.ok(first.restrictedCanaries.length > 0, 'Nonempty protected seed controls'); - for (const canary of first.restrictedCanaries) - assert.equal( - restrictedResponse.body.includes(canary), - false, - 'Protected value absent from HTML and Flight', - ); + absentFromWholeResponse(restrictedResponse, first.restrictedCanaries); await checkpoint('foundation-restricted'); stage = 'foundation empty exact-locale catalog'; @@ -852,17 +808,12 @@ async function verifyPagination(native) { function lessonContainment(response, tenant) { const other = configuration.tenants.find((candidate) => candidate.host !== tenant.host); - for (const value of [ + absentFromWholeResponse(response, [ other.name, other.lessonTitle, ...other.content.fields.map((field) => field.value), - ]) - assert.equal(response.body.includes(value), false, 'Opposite tenant lesson is absent'); - assert.equal( - catalogueCanaries.some((value) => response.body.includes(value)), - false, - 'Complete UI catalogue cannot enter the lesson Flight payload', - ); + ...catalogueSentinels, + ]); } function lessonDocument(response, tenant, locale, title, path, content) { @@ -916,11 +867,7 @@ function lessonDocument(response, tenant, locale, title, path, content) { assert.ok(article.textContent.includes('This lesson content is currently unavailable.')); assert.equal(article.textContent.includes('This lesson has no content to display yet.'), false); assert.ok(doc.querySelector('meta[name="robots"]')?.content.includes('noindex')); - assert.equal( - response.body.includes('unavailable-private-body-canary'), - false, - 'Unavailable payload stays absent from HTML and Flight', - ); + absentFromWholeResponse(response, ['unavailable-private-body-canary']); return doc; } assert.equal( @@ -1155,18 +1102,9 @@ async function verifyPresentation(native) { // Next's redirect Flight tree can contain the request's own route segments. // They reveal no new content; titles/body values must still be absent, and // the destination below must omit even the original requested slug. - if (!path.split('/').includes(value)) - assert.equal( - refusal.body.includes(value), - false, - 'Refused lesson has no content Flight leak', - ); + if (!path.split('/').includes(value)) absentFromWholeResponse(refusal, [value]); stage = name + ' status containment ' + index; - assert.equal( - response.body.includes(value), - false, - 'Localized missing page has no lesson leak', - ); + absentFromWholeResponse(response, [value]); } await checkpoint(name); } @@ -1209,80 +1147,25 @@ function productDetails(tenant, locale) { } function absentFromWholeResponse(response, values) { - const decoded = new JSDOM(response.body).window.document.documentElement.textContent; const phase = stage; - for (const [index, value] of values.entries()) { + assertAbsentFromWholeResponse(response, values, (index) => { stage = phase + ' containment marker ' + index; - assert.ok(value.length > 0, 'Leak markers are nonempty'); - for (const representation of [value, JSON.stringify(value).slice(1, -1)]) - assert.equal( - response.body.includes(representation), - false, - 'Complete HTML/Flight containment', - ); - assert.equal(decoded.includes(value), false, 'Entity-decoded response containment'); - } + }); stage = phase; } function productContainment(response, tenant) { - const own = configuration.product.find((candidate) => candidate.host === tenant.host); - const other = configuration.product.find((candidate) => candidate.host !== tenant.host); - const opposite = configuration.tenants.find((candidate) => candidate.host !== tenant.host); - assert.ok(own.protectedCanaries.length > 0 && other.allCanaries.length > 0); - absentFromWholeResponse(response, [ - opposite.name, - ...other.allCanaries, - other.courseTitle, - other.courseSummary, - other.lessonTitle, - ...other.content.fields.map((field) => field.value), - ...own.protectedCanaries, - ...catalogueCanaries, - ]); + const phase = stage; + assertProductContainment(response, tenant, configuration, catalogueSentinels, (index) => { + stage = phase + ' containment marker ' + index; + }); + stage = phase; } function productTheme(doc, details, malformed = false) { - const styles = [...doc.querySelectorAll('style,[style]')].filter((element) => - /--ls-(?:primary|bg|fg|muted)\s*:/.test( - element.tagName === 'STYLE' ? element.textContent : element.getAttribute('style'), - ), - ); - const { primary, background, foreground, muted } = details.theme; - assert.equal(styles.length, malformed ? 0 : 1, 'A malformed palette emits no partial override'); - if (!malformed) - assert.equal( - styles[0].textContent, - `:root{--ls-primary:${primary};--ls-bg:${background};--ls-fg:${foreground};--ls-muted:${muted};}`, - 'Exact complete seed palette is independent of entitlement', - ); - const footer = doc.querySelector('footer.public-footer'); - assert.equal( - footer !== null, - details.showAttribution, - 'Effective tenant entitlement alone selects attribution', + assertProductTheme(doc, details, malformed, (path) => + readFileSync(join(app, '.next', path.slice('/_next/'.length)), 'utf8'), ); - if (footer) - assert.equal( - footer.textContent, - details.locale === 'tr-TR' ? 'LearnStack altyapısıyla' : 'Powered by LearnStack', - ); - if (malformed) { - const cssLinks = [...doc.querySelectorAll('link[rel="stylesheet"]')]; - assert.ok(cssLinks.length > 0, 'The fallback has a real compiled stylesheet'); - const css = cssLinks - .map((link) => { - const path = link.getAttribute('href').split('?')[0]; - assert.match(path, /^\/_next\/static\/css\/[a-zA-Z0-9._-]+\.css$/); - return readFileSync(join(app, '.next', path.slice('/_next/'.length)), 'utf8'); - }) - .join(''); - assert.match( - css, - /:root\{[^}]*--ls-primary:#1f6feb;[^}]*--ls-bg:#fff(?:fff)?;[^}]*--ls-fg:#0f172a;[^}]*--ls-muted:#64748b[;}]/, - 'All four existing CSS defaults remain available together', - ); - } } function productLesson(response, tenant, details, malformed = false) { @@ -1612,7 +1495,9 @@ try { product; const pagination = configuration.mode === 'foundation-pagination'; if (foundation) { - catalogueCanaries = ['en', 'tr'].map( + // These unused ICU values detect an unchanged complete catalogue; the source + // graph independently rejects catalogue imports into Client Components. + catalogueSentinels = ['en', 'tr'].map( (locale) => JSON.parse( readFileSync(join(sourceApp, 'src/i18n/messages', locale, 'public.json'), 'utf8'), diff --git a/frontend/apps/web/src/app/(portal)/portal/page.tsx b/frontend/apps/web/src/app/(portal)/portal/page.tsx index 93d29871..198eb20e 100644 --- a/frontend/apps/web/src/app/(portal)/portal/page.tsx +++ b/frontend/apps/web/src/app/(portal)/portal/page.tsx @@ -1,3 +1,5 @@ +export const metadata = { robots: { index: false, follow: false } }; + export default function PortalHomePage() { return ( <section className="mx-auto max-w-5xl px-6 py-12"> diff --git a/frontend/apps/web/src/app/(studio)/studio/page.tsx b/frontend/apps/web/src/app/(studio)/studio/page.tsx index 3c0d51d6..2e87882d 100644 --- a/frontend/apps/web/src/app/(studio)/studio/page.tsx +++ b/frontend/apps/web/src/app/(studio)/studio/page.tsx @@ -1,3 +1,5 @@ +export const metadata = { robots: { index: false, follow: false } }; + export default function StudioHomePage() { return ( <section className="mx-auto max-w-5xl px-6 py-12"> diff --git a/frontend/apps/web/src/components/public/catalog-course.test.tsx b/frontend/apps/web/src/components/public/catalog-course.test.tsx index e0cd314c..411efb2f 100644 --- a/frontend/apps/web/src/components/public/catalog-course.test.tsx +++ b/frontend/apps/web/src/components/public/catalog-course.test.tsx @@ -85,12 +85,10 @@ describe('public catalog and course semantics', () => { const resource = catalog({ items: [courseSummary, { ...courseSummary, slug: 'another', title: 'Another course' }], }); - const { container } = render( - <PublicCatalog resource={resource} ui={createPublicTranslator('tr-TR')} />, - ); + render(<PublicCatalog resource={resource} ui={createPublicTranslator('tr-TR')} />); expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Kurslar'); - expect(screen.getByText('2 kurs')).toHaveAttribute('lang', 'tr'); + expect(screen.getByText('Bu sayfada 2 kurs')).toHaveAttribute('lang', 'tr'); const list = screen.getByRole('list'); expect(list.tagName).toBe('UL'); expect( @@ -107,7 +105,6 @@ describe('public catalog and course semantics', () => { 'href', '/tr-TR/courses/another', ); - expect(container.querySelector('[data-prefetch], [data-nextjs-router]')).toBeNull(); }); it('renders an ordered outline in API order without using sort values as URLs or numbering', () => { @@ -125,7 +122,7 @@ describe('public catalog and course semantics', () => { expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(courseSummary.title); expect(screen.getAllByRole('heading', { level: 2 })).toHaveLength(1); expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('Kurs içeriği'); - expect(screen.getByText('2 ders')).toHaveAttribute('lang', 'tr'); + expect(screen.getByText('Bu sayfada 2 ders')).toHaveAttribute('lang', 'tr'); const list = screen.getByRole('list'); expect(list.tagName).toBe('OL'); expect( @@ -201,7 +198,7 @@ describe('public catalog and course semantics', () => { expect(screen.queryByText('No courses are available in this language yet.')).toBeNull(); expect(screen.queryByText('The lessons in this course are not publicly available.')).toBeNull(); expect(screen.queryByRole('list')).toBeNull(); - expect(screen.queryByText('0 lessons')).toBeNull(); + expect(screen.queryByText('0 lessons on this page')).toBeNull(); }); it('renders restricted marketing and a translated notice without inspecting a supplied outline', () => { diff --git a/frontend/apps/web/src/components/public/foundation.test.tsx b/frontend/apps/web/src/components/public/foundation.test.tsx index ab520330..486d5952 100644 --- a/frontend/apps/web/src/components/public/foundation.test.tsx +++ b/frontend/apps/web/src/components/public/foundation.test.tsx @@ -44,6 +44,7 @@ describe('P02d-6 document foundation', () => { { ...palette, background: 'red' }, { ...palette, foreground: '#ffffff;}' }, { ...palette, muted: '</style><script>private</script>' }, + { ...palette, muted: '</style><script>private</script>#64748b' }, { ...palette, url: 'https://private.invalid' }, { ...palette, font: 'private' }, ])('rejects the whole malformed theme without partial tokens: %j', (theme) => { @@ -123,6 +124,7 @@ describe('P02d-6 document foundation', () => { expect(container.querySelector('section')).toHaveAttribute('lang', 'tr'); expect(container.querySelector('section')).toHaveAttribute('dir', 'ltr'); expect(screen.queryAllByRole('link')).toHaveLength(state === 'loading' ? 0 : 1); + expect(screen.queryAllByRole('status')).toHaveLength(state === 'loading' ? 1 : 0); expect(container.textContent).not.toMatch(/lockey_|public\.|private/); }, ); diff --git a/frontend/apps/web/src/components/public/state.tsx b/frontend/apps/web/src/components/public/state.tsx index 62077b38..1c405d5a 100644 --- a/frontend/apps/web/src/components/public/state.tsx +++ b/frontend/apps/web/src/components/public/state.tsx @@ -33,7 +33,12 @@ export function PublicState({ state, recoveryPath, locale, direction, t }: State ? t('page.loading.description') : t('page.unavailable.description'); return ( - <section className="public-state" lang={locale} dir={direction}> + <section + className="public-state" + lang={locale} + dir={direction} + role={state === 'loading' ? 'status' : undefined} + > <h1>{title}</h1> <p className="public-muted">{description}</p> {state !== 'loading' && ( diff --git a/frontend/apps/web/src/i18n/catalogues.test.ts b/frontend/apps/web/src/i18n/catalogues.test.ts index 4b80158b..40faef08 100644 --- a/frontend/apps/web/src/i18n/catalogues.test.ts +++ b/frontend/apps/web/src/i18n/catalogues.test.ts @@ -7,6 +7,7 @@ import ts from 'typescript'; import { describe, expect, it, vi } from 'vitest'; import { createPublicTranslator, getPublicCatalogue, publicFormattingOptions } from './catalogues'; +import { uiLocales } from './locale'; import en from './messages/en/public.json'; import tr from './messages/tr/public.json'; @@ -64,7 +65,7 @@ function checkCatalogues(catalogues: Catalogue[], usedKeys: readonly string[]): } } -/** Census literal calls to the public translator, including destructured aliases. */ +/** Bounded census of direct calls, simple aliases and literal Promise.all tuples. */ function publicCallsiteKeys(source: string, filename = 'consumer.tsx'): string[] { const file = ts.createSourceFile( filename, @@ -85,20 +86,52 @@ function publicCallsiteKeys(source: string, filename = 'consumer.tsx'): string[] ts.forEachChild(node, collectImports); }; collectImports(file); + const unwrap = (expression: ts.Expression): ts.Expression => + ts.isAwaitExpression(expression) || ts.isParenthesizedExpression(expression) + ? unwrap(expression.expression) + : expression; + const propertyName = (expression: ts.Expression): string | undefined => + ts.isPropertyAccessExpression(expression) + ? expression.name.text + : ts.isElementAccessExpression(expression) && + ts.isStringLiteralLike(expression.argumentExpression) + ? expression.argumentExpression.text + : undefined; + const isTranslator = (expression: ts.Expression): boolean => { + const value = unwrap(expression); + return ( + (ts.isIdentifier(value) && translatorNames.has(value.text)) || + propertyName(value) === 't' || + (ts.isCallExpression(value) && + ts.isIdentifier(value.expression) && + translationFactories.has(value.expression.text)) + ); + }; const collectBindings = (node: ts.Node): void => { if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) { - const initializer = ts.isAwaitExpression(node.initializer) - ? node.initializer.expression - : node.initializer; + if (isTranslator(node.initializer)) translatorNames.add(node.name.text); + } + if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer) { + const initializer = unwrap(node.initializer); if ( ts.isCallExpression(initializer) && - ts.isIdentifier(initializer.expression) && - translationFactories.has(initializer.expression.text) + ts.isPropertyAccessExpression(initializer.expression) && + initializer.expression.expression.getText(file) === 'Promise' && + initializer.expression.name.text === 'all' && + initializer.arguments[0] && + ts.isArrayLiteralExpression(initializer.arguments[0]) ) { - translatorNames.add(node.name.text); - } - if (ts.isPropertyAccessExpression(initializer) && initializer.name.text === 't') { - translatorNames.add(node.name.text); + const values = initializer.arguments[0].elements; + node.name.elements.forEach((binding, index) => { + const value = values[index]; + if ( + ts.isBindingElement(binding) && + ts.isIdentifier(binding.name) && + value && + isTranslator(value) + ) + translatorNames.add(binding.name.text); + }); } } if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name)) { @@ -115,15 +148,13 @@ function publicCallsiteKeys(source: string, filename = 'consumer.tsx'): string[] collectBindings(file); const keys: string[] = []; const visit = (node: ts.Node): void => { + if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { + const member = propertyName(node); + if (member && ['rich', 'raw', 'has'].includes(member) && isTranslator(node.expression)) + throw new Error('Rich/raw/has UI translator methods are forbidden'); + } if (ts.isCallExpression(node)) { - const expression = node.expression; - const isTranslator = ts.isIdentifier(expression) - ? translatorNames.has(expression.text) - : (ts.isPropertyAccessExpression(expression) && expression.name.text === 't') || - (ts.isElementAccessExpression(expression) && - ts.isStringLiteralLike(expression.argumentExpression) && - expression.argumentExpression.text === 't'); - if (isTranslator) { + if (isTranslator(node.expression)) { const key = node.arguments[0]; if (!key || !ts.isStringLiteralLike(key)) throw new Error('UI callsite key must be literal'); @@ -141,7 +172,14 @@ function productionCallsiteKeys(directory: string): string[] { for (const entry of readdirSync(directory, { withFileTypes: true })) { const path = join(directory, entry.name); if (entry.isDirectory() && entry.name !== 'test') keys.push(...productionCallsiteKeys(path)); - if (entry.isFile() && /\.tsx?$/.test(entry.name) && !/\.(test|spec)\.tsx?$/.test(entry.name)) { + if ( + entry.isFile() && + /\.tsx?$/.test(entry.name) && + !/\.(test|spec)\.tsx?$/.test(entry.name) && + // This typed adapter forwards checked keys, rather than choosing copy. + // public-ui.test.ts proves its namespace and exact argument forwarding. + !path.endsWith(join('server', 'public-ui.ts')) + ) { keys.push(...publicCallsiteKeys(readFileSync(path, 'utf8'), path)); } } @@ -151,7 +189,44 @@ function productionCallsiteKeys(directory: string): string[] { describe('Ui_Catalogues_Cover_Public_Call_Sites', () => { it('validates complete bundled catalogues and every production callsite', () => { const root = join(process.cwd(), 'src'); - checkCatalogues([en, tr], productionCallsiteKeys(root)); + const bundles = uiLocales.map((locale) => { + const messages = JSON.parse( + readFileSync(join(root, 'i18n', 'messages', locale, 'public.json'), 'utf8'), + ) as Catalogue; + expect(getPublicCatalogue(locale)).toMatchObject({ locale, messages }); + return messages; + }); + const keys = productionCallsiteKeys(root); + expect(keys.length).toBeGreaterThan(0); + checkCatalogues(bundles, keys); + }); + + it.each([ + "const copy = t; copy('catalog.titel');", + "const first = ui.t; const copy = first; copy('catalog.titel');", + "const [locale, labels] = await Promise.all([getLocale(), getTranslations('public')]); labels('catalog.titel');", + "import {getTranslations as messages} from 'next-intl/server'; const [labels] = await Promise.all([messages('public')]); const copy = labels; copy('catalog.titel');", + ])('includes ordinary translator aliases in missing-key controls: %s', (source) => { + const keys = publicCallsiteKeys(source); + expect(keys).toEqual(['catalog.titel']); + expect(() => checkCatalogues([en, tr], keys)).toThrow('Used UI key'); + }); + + it.each([ + 'const copy = t; copy(problem.messageKey);', + "const [copy] = await Promise.all([getTranslations('public')]); copy(problem.messageKey);", + ])('refuses dynamic keys through ordinary aliases: %s', (source) => { + expect(() => publicCallsiteKeys(source)).toThrow('must be literal'); + }); + + it.each(['rich', 'raw', 'has'])('refuses public translator %s methods', (method) => { + for (const source of [ + `t.${method}('catalog.title');`, + `ui.t['${method}']('catalog.title');`, + `const copy = t; const method = copy.${method};`, + `const [copy] = await Promise.all([getTranslations('public')]); copy.${method}('catalog.title');`, + ]) + expect(() => publicCallsiteKeys(source)).toThrow('methods are forbidden'); }); it('rejects empty catalogues, empty messages, and empty groups', () => { @@ -241,11 +316,11 @@ describe('server-only public message formatting', () => { it('uses the selected UI language for pluralization and number formatting', () => { const { t } = createPublicTranslator('zh'); - expect(t('catalog.course_count', { count: 1 })).toBe('1 course'); - expect(t('catalog.course_count', { count: 2 })).toBe('2 courses'); - expect(t('catalog.course_count', { count: 1000 })).toBe('1,000 courses'); + expect(t('catalog.course_count', { count: 1 })).toBe('1 course on this page'); + expect(t('catalog.course_count', { count: 2 })).toBe('2 courses on this page'); + expect(t('catalog.course_count', { count: 1000 })).toBe('1,000 courses on this page'); expect(createPublicTranslator('tr-TR').t('course.lesson_count', { count: 1000 })).toBe( - '1.000 ders', + 'Bu sayfada 1.000 ders', ); }); diff --git a/frontend/apps/web/src/i18n/messages/en/public.json b/frontend/apps/web/src/i18n/messages/en/public.json index 356d2441..ced717bd 100644 --- a/frontend/apps/web/src/i18n/messages/en/public.json +++ b/frontend/apps/web/src/i18n/messages/en/public.json @@ -6,7 +6,7 @@ }, "catalog": { "title": "Courses", - "course_count": "{count, plural, one {# course} other {# courses}}", + "course_count": "{count, plural, one {# course on this page} other {# courses on this page}}", "empty": "No courses are available in this language yet.", "next": "Next courses", "restart": "Back to the first courses", @@ -14,7 +14,7 @@ }, "course": { "outline": "Course outline", - "lesson_count": "{count, plural, one {# lesson} other {# lessons}}", + "lesson_count": "{count, plural, one {# lesson on this page} other {# lessons on this page}}", "empty_outline": "No public lessons are available in this course yet.", "restricted_outline": "The lessons in this course are not publicly available.", "next": "Next lessons", diff --git a/frontend/apps/web/src/i18n/messages/tr/public.json b/frontend/apps/web/src/i18n/messages/tr/public.json index a5ba626b..b7be8948 100644 --- a/frontend/apps/web/src/i18n/messages/tr/public.json +++ b/frontend/apps/web/src/i18n/messages/tr/public.json @@ -6,7 +6,7 @@ }, "catalog": { "title": "Kurslar", - "course_count": "{count, plural, one {# kurs} other {# kurs}}", + "course_count": "{count, plural, one {Bu sayfada # kurs} other {Bu sayfada # kurs}}", "empty": "Bu dilde henüz sunulan bir kurs yok.", "next": "Sonraki kurslar", "restart": "İlk kurslara dön", @@ -14,7 +14,7 @@ }, "course": { "outline": "Kurs içeriği", - "lesson_count": "{count, plural, one {# ders} other {# ders}}", + "lesson_count": "{count, plural, one {Bu sayfada # ders} other {Bu sayfada # ders}}", "empty_outline": "Bu kursta henüz herkese açık bir ders yok.", "restricted_outline": "Bu kursun dersleri herkese açık değildir.", "next": "Sonraki dersler", diff --git a/frontend/apps/web/src/i18n/request.test.ts b/frontend/apps/web/src/i18n/request.test.ts new file mode 100644 index 00000000..56adb7e6 --- /dev/null +++ b/frontend/apps/web/src/i18n/request.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment node +import { createTranslator } from 'next-intl'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import * as catalogues from './catalogues'; +import en from './messages/en/public.json'; +import tr from './messages/tr/public.json'; +import requestConfig from './request'; + +const dependencies = vi.hoisted(() => ({ + request: vi.fn(), + notFound: vi.fn(() => { + throw new Error('Admission refused'); + }), +})); +vi.mock('@/server/public-request', () => ({ getPublicRequest: dependencies.request })); +vi.mock('next/navigation', () => ({ notFound: dependencies.notFound })); +// Execute the production callback directly; next-intl's request cache is outside +// this unit boundary. The real formatter consumes the returned config below. +vi.mock('next-intl/server', () => ({ getRequestConfig: (callback: unknown) => callback })); + +const configure = () => requestConfig({ requestLocale: Promise.resolve('forged-locale') }); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('verified public next-intl request configuration', () => { + it.each([ + ['tr-Latn-TR', 'tr', tr], + ['en-GB', 'en', en], + ['ar', 'en', en], + [null, 'en', en], + ])( + 'selects the public namespace for admitted content locale %s', + async (locale, uiLocale, messages) => { + dependencies.request.mockResolvedValue({ locale }); + const config = await configure(); + expect(dependencies.request).toHaveBeenCalledOnce(); + expect(dependencies.request).toHaveBeenCalledWith(); + expect(dependencies.notFound).not.toHaveBeenCalled(); + expect(config.locale).toBe(uiLocale); + expect(config.messages).toEqual({ public: messages }); + const translate = createTranslator({ + ...config, + locale: config.locale!, + messages: config.messages!, + namespace: 'public', + }); + expect(translate('catalog.title')).toBe(messages.catalog.title); + }, + ); + + it('refuses before selecting any catalogue or English scaffold', async () => { + dependencies.request.mockResolvedValue(null); + const select = vi.spyOn(catalogues, 'getPublicCatalogue'); + await expect(configure()).rejects.toThrow('Admission refused'); + expect(dependencies.notFound).toHaveBeenCalledOnce(); + expect(dependencies.notFound).toHaveBeenCalledWith(); + expect(select).not.toHaveBeenCalled(); + }); + + it.each(['tr-TR', 'ar'])('bounds formatter failures in the actual %s config', async (locale) => { + dependencies.request.mockResolvedValue({ locale }); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const config = await configure(); + const translate = createTranslator({ + ...config, + locale: config.locale!, + messages: config.messages!, + namespace: 'public', + }); + const fallback = + locale === 'tr-TR' ? tr.page.unavailable.description : en.page.unavailable.description; + expect(translate('catalog.course_count')).toBe(fallback); + expect(translate('lockey_private_wire_key', { secret: 'private-parameter' })).toBe(fallback); + expect(error).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/apps/web/src/server/public-resource.test.ts b/frontend/apps/web/src/server/public-resource.test.ts index c08da4c4..e87e5a9b 100644 --- a/frontend/apps/web/src/server/public-resource.test.ts +++ b/frontend/apps/web/src/server/public-resource.test.ts @@ -260,6 +260,41 @@ describe('public resource dispatch after shared live admission', () => { }); }); +describe.each([ + { target: '/tr-TR/courses', cursor: 'cursor', operation: client.getCourses }, + { + target: '/tr-TR/courses/foundation', + cursor: 'lessonCursor', + operation: client.getCourse, + }, +])('owned cursor failures: $target', ({ target, cursor, operation }) => { + it.each([ + [true, { owned: ['private diagnostic'] }, 'invalid_cursor'], + [false, { owned: ['private diagnostic'] }, 'unavailable'], + [true, { private: ['private diagnostic'] }, 'unavailable'], + [true, { owned: ['private diagnostic'], private: ['private diagnostic'] }, 'unavailable'], + [true, {}, 'unavailable'], + ] as const)( + 'maps only a present cursor and its exclusive field errors', + async (present, fields, state) => { + const admitted = request(`${target}${present ? `?${cursor}=opaque_1` : ''}`); + getRequest.mockResolvedValue(admitted); + operation.mockResolvedValue( + apiError(400, { + code: 'validation_failed', + fieldErrors: Object.fromEntries( + Object.entries(fields).map(([field, messages]) => [ + field === 'owned' ? cursor : field, + [...messages], + ]), + ), + }), + ); + expect(await getPublicResource()).toEqual({ kind: 'failure', request: admitted, state }); + }, + ); +}); + describe.each([ { target: '/tr-TR/courses', operation: client.getCourses }, { target: '/tr-TR/courses/foundation', operation: client.getCourse }, @@ -274,7 +309,7 @@ describe.each([ code: 'validation_failed', fieldErrors: { private: ['private diagnostic'] }, }), - target.includes('/lessons/') ? 'unavailable' : 'invalid_cursor', + 'unavailable', ], ['failure', apiError(503, { code: 'validation_failed', fieldErrors: {} }), 'unavailable'], [ diff --git a/frontend/apps/web/src/server/public-resource.ts b/frontend/apps/web/src/server/public-resource.ts index a3c83fc4..6c207edc 100644 --- a/frontend/apps/web/src/server/public-resource.ts +++ b/frontend/apps/web/src/server/public-resource.ts @@ -41,6 +41,7 @@ export type PublicResource = function failure<T>( result: Exclude<ApiResult<T>, { kind: 'success' }>, request: PublicRequest, + pagination?: PublicPagination, ): PublicResource { if (result.kind === 'api-error') { if (result.status === 404) return { kind: 'missing', request }; @@ -48,7 +49,11 @@ function failure<T>( if ( result.status === 400 && result.error.code === 'validation_failed' && - (request.route.kind === 'catalog' || request.route.kind === 'course') + pagination?.cursor !== undefined && + Object.keys(result.error.fieldErrors).length > 0 && + Object.keys(result.error.fieldErrors).every( + (field) => field === (request.route.kind === 'course' ? 'lessonCursor' : 'cursor'), + ) ) return { kind: 'failure', request, state: 'invalid_cursor' }; } @@ -81,7 +86,7 @@ export const getPublicResource = requestMemo(async (): Promise<PublicResource> = const result = await client.getCourses({ cursor: pagination.cursor, limit: pagination.limit }); return result.kind === 'success' ? { kind: 'catalog', request, data: result.data, pagination } - : failure(result, request); + : failure(result, request, pagination); } const result = await client.getCourse( { slug: route.slug }, @@ -92,7 +97,7 @@ export const getPublicResource = requestMemo(async (): Promise<PublicResource> = ); return result.kind === 'success' ? { kind: 'course', request, data: result.data, pagination } - : failure(result, request); + : failure(result, request, pagination); }); /** Each consumer honors admission; layouts do not serialize child execution. */ diff --git a/frontend/apps/web/src/server/public-route.test.ts b/frontend/apps/web/src/server/public-route.test.ts index 50793ab8..6dd36a00 100644 --- a/frontend/apps/web/src/server/public-route.test.ts +++ b/frontend/apps/web/src/server/public-route.test.ts @@ -47,6 +47,9 @@ describe.each([ ['100', '100'], ['101', '100'], ['9999999999', '100'], + ['10000000000', '100'], + ['9'.repeat(400), '100'], + ['0'.repeat(400) + '2', '2'], ])('normalizes positive decimal %s to the API bound %s', (value, expected) => { expect(publicPagination(`${target}?${limit}=${value}`, outline)).toEqual({ limit: expected, @@ -66,7 +69,7 @@ describe.each([ '1.5', '1e2', '12', - '10000000000', + '9'.repeat(400) + 'x', '%0A1', ])('refuses malformed owned limit %s', (value) => { expect(publicPagination(`${target}?${limit}=${value}`, outline)).toBeNull(); diff --git a/frontend/apps/web/src/server/public-route.ts b/frontend/apps/web/src/server/public-route.ts index 02ab177d..6ebd32bb 100644 --- a/frontend/apps/web/src/server/public-route.ts +++ b/frontend/apps/web/src/server/public-route.ts @@ -31,6 +31,14 @@ export type PublicPagination = { readonly isPaginated: boolean; }; +function boundedLimit(raw: string | undefined): number | null { + if (raw === undefined) return 20; + if (!/^[0-9]+$/.test(raw)) return null; + let limit = 0; + for (const digit of raw) limit = Math.min(100, limit * 10 + Number(digit)); + return limit === 0 ? null : limit; +} + /** Cursor contents remain opaque; the API owns decoding and scope validation. */ export function publicPagination(target: string, outline: boolean): PublicPagination | null { const parameters = new URLSearchParams( @@ -45,11 +53,11 @@ export function publicPagination(target: string, outline: boolean): PublicPagina const rawLimit = limits[0]; if (cursor !== undefined && (cursor.length > 1024 || !/^[A-Za-z0-9_-]+$/.test(cursor))) return null; - if (rawLimit !== undefined && (!/^[0-9]{1,10}$/.test(rawLimit) || Number(rawLimit) === 0)) - return null; + const limit = boundedLimit(rawLimit); + if (limit === null) return null; return { cursor, - limit: rawLimit === undefined ? '20' : String(Math.min(100, Number(rawLimit))), + limit: String(limit), isPaginated: cursor !== undefined, }; } diff --git a/frontend/apps/web/src/server/public-ui.test.ts b/frontend/apps/web/src/server/public-ui.test.ts new file mode 100644 index 00000000..1e3d69c0 --- /dev/null +++ b/frontend/apps/web/src/server/public-ui.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getPublicUi } from './public-ui'; + +const dependencies = vi.hoisted(() => ({ + locale: vi.fn(), + translations: vi.fn(), + translate: vi.fn(), +})); +vi.mock('next-intl/server', () => ({ + getLocale: dependencies.locale, + getTranslations: dependencies.translations, +})); + +beforeEach(() => { + vi.clearAllMocks(); + dependencies.translations.mockResolvedValue(dependencies.translate); + dependencies.translate.mockImplementation((key: string) => `translated:${key}`); +}); + +describe('public server UI wrapper', () => { + it.each([ + ['tr', 'ltr'], + ['en', 'ltr'], + ['ar', 'rtl'], + ])('uses the configured %s language and %s direction', async (locale, direction) => { + dependencies.locale.mockResolvedValue(locale); + const ui = await getPublicUi(); + expect(ui).toMatchObject({ locale, direction }); + expect(dependencies.locale).toHaveBeenCalledOnce(); + expect(dependencies.locale).toHaveBeenCalledWith(); + expect(dependencies.translations).toHaveBeenCalledOnce(); + expect(dependencies.translations).toHaveBeenCalledWith('public'); + expect(ui.t('catalog.title')).toBe('translated:catalog.title'); + expect(dependencies.translate).toHaveBeenCalledOnce(); + expect(dependencies.translate).toHaveBeenCalledWith('catalog.title', undefined); + expect(ui.t).not.toHaveProperty('rich'); + expect(ui.t).not.toHaveProperty('raw'); + expect(ui.t).not.toHaveProperty('has'); + }); + + it.each(['catalog.course_count', 'course.lesson_count'] as const)( + 'forwards the exact numeric argument object for %s', + async (key) => { + dependencies.locale.mockResolvedValue('tr'); + const ui = await getPublicUi(); + const values = { count: 1000 }; + expect(ui.t(key, values)).toBe(`translated:${key}`); + expect(dependencies.translate).toHaveBeenCalledOnce(); + expect(dependencies.translate).toHaveBeenCalledWith(key, values); + expect(dependencies.translate.mock.calls[0]?.[1]).toBe(values); + }, + ); +}); diff --git a/frontend/apps/web/src/test/accessibility-lint.test.ts b/frontend/apps/web/src/test/accessibility-lint.test.ts index 40a7cc5e..28d46536 100644 --- a/frontend/apps/web/src/test/accessibility-lint.test.ts +++ b/frontend/apps/web/src/test/accessibility-lint.test.ts @@ -100,7 +100,7 @@ const cases = [ describe('actual web JSX accessibility lint config', () => { const app = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); - const filePath = join(app, 'src', 'accessibility-probe.tsx'); + const filePath = join(app, 'src', 'components', 'public', 'catalog.tsx'); const eslint = new LegacyESLint({ cwd: app, resolvePluginsRelativeTo: resolve(app, '../../packages/config/eslint'), @@ -146,4 +146,22 @@ describe('actual web JSX accessibility lint config', () => { ); expect(await lint(clean), `${name} clean control`).toEqual([]); }); + + it.each([ + ['object', '<object data="/diagram.svg" />', '<object data="/diagram.svg" title="Diagram" />'], + ['area', '<area href="/course" />', '<area href="/course" alt="Course" />'], + [ + 'input image', + '<input type="image" src="/submit.png" />', + '<input type="image" src="/submit.png" alt="Submit" />', + ], + ['Next Image', '<Image src="/photo.png" />', '<Image src="/photo.png" alt="Mountains" />'], + ])('requires alternative text on %s through the actual config', async (_name, dirty, clean) => { + expect(await lint(dirty)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ ruleId: 'jsx-a11y/alt-text', severity: 2 }), + ]), + ); + expect(await lint(clean)).toEqual([]); + }); }); diff --git a/frontend/apps/web/src/test/public-boundaries.test.ts b/frontend/apps/web/src/test/public-boundaries.test.ts index e2456ef4..ec950275 100644 --- a/frontend/apps/web/src/test/public-boundaries.test.ts +++ b/frontend/apps/web/src/test/public-boundaries.test.ts @@ -19,6 +19,7 @@ import { I18N_REQUEST, INGRESS, MIDDLEWARE, + navigationFindings, privateServerFiles, publicLayoutPolicyFindings, PUBLIC_LAYOUT, @@ -76,9 +77,14 @@ function productionSources(): SourceCensus { const sources = productionSources(); const production = buildSourceGraph(sources); const publicRoots = [...production.files.keys()].filter((name) => name.includes('/app/(public)/')); +// Native ingress currently admits these exact scaffold paths alongside product +// routes. They share the root layout and must remain in the rendering census. +const scaffoldRoots = [...production.files.keys()].filter((name) => + /\/app\/\((?:studio|portal)\)\//.test(name), +); const ROOT_LAYOUT = 'apps/web/src/app/layout.tsx'; // next-intl loads this configuration through its plugin, outside static imports. -const renderRoots = [...publicRoots, ROOT_LAYOUT, MIDDLEWARE, I18N_REQUEST]; +const renderRoots = [...publicRoots, ...scaffoldRoots, ROOT_LAYOUT, MIDDLEWARE, I18N_REQUEST]; const catalogues = ['en', 'tr'].map((locale) => `apps/web/src/i18n/messages/${locale}/public.json`); const publicGraph = reachable(production, renderRoots); const clean = (findings: readonly Finding[], fix: string) => @@ -156,6 +162,14 @@ describe('ADR-0053 production public boundaries', () => { ); expect(publicRoots).toContain(PUBLIC_LAYOUT); expect(renderRoots).toContain(I18N_REQUEST); + expect(scaffoldRoots).toEqual( + expect.arrayContaining([ + 'apps/web/src/app/(studio)/layout.tsx', + 'apps/web/src/app/(studio)/studio/page.tsx', + 'apps/web/src/app/(portal)/layout.tsx', + 'apps/web/src/app/(portal)/portal/page.tsx', + ]), + ); expect(reachable(production, [I18N_REQUEST])).toEqual( expect.arrayContaining([ I18N_REQUEST, @@ -184,7 +198,7 @@ describe('ADR-0053 production public boundaries', () => { ]), ); clean(production.unresolved, 'Fix: keep every runtime local import in the production census.'); - clean(extensionFindings, 'Fix: model every production TypeScript runtime extension.'); + clean(extensionFindings, 'Fix: model every production runtime source extension.'); }); it('the source resolver covers inherited tsconfig aliases and workspace exports', () => { const { configs, packages } = resolutionCensus(); @@ -261,6 +275,9 @@ describe('ADR-0053 production public boundaries', () => { 'Fix: keep public rendering dynamic and no-store.', ); }); + it('the public render closure uses document anchors without client router imports', () => { + clean(navigationFindings(production, publicGraph), 'Fix: preserve G40 document navigation.'); + }); it('public pages and their transitive helpers do not read raw authority', () => { const helpers = publicGraph.filter((name) => name !== INGRESS); expect(helpers).toEqual( @@ -573,6 +590,7 @@ describe('public cache fence planted controls', () => { ], ['exposed collection', 'return pending;', 'return requests;'], ['untyped value', 'Promise<T>>();', 'Promise<unknown>>();'], + ['changed unary guard', 'if (!pending)', 'if (+pending)'], ])('refuses mutated request memo mechanism: %s', (_name, before, after) => { const original = sources[REQUEST_MEMO]!; expect(original).toContain(before); @@ -1152,6 +1170,250 @@ describe('remediation bounded header conversions', () => { }); }); +describe('remediation public representation lifetimes', () => { + it.each([ + ['Set', 'const entries = new Set();'], + ['WeakSet', 'const entries = new WeakSet();'], + ['bound collection', 'const { Set: Entries } = globalThis; const entries = new Entries();'], + ['factory Map', 'function create() { return new Map(); } const retained = create();'], + [ + 'aliased factory', + 'const create = () => new Set(); const alias = create; const retained = alias();', + ], + ['IIFE Map', 'const retained = (() => new Map())();'], + [ + 'nested factory', + 'function inner() { return new Map(); } function outer() { return inner(); } const retained = outer();', + ], + [ + 'object factory', + 'function create() { return { entries: new Map() }; } const retained = create();', + ], + [ + 'closure factory', + 'function create() { const entries = new Map(); return (key) => entries.get(key); } const retained = create();', + ], + [ + 'named closure factory', + 'function create() { const entries = new Map(); function read(key) { return entries.get(key); } return read; } const retained = create();', + ], + [ + 'method closure factory', + 'function create() { const entries = new Map(); return { read(key) { return entries.get(key); } }; } const retained = create();', + ], + [ + 'mutable closure', + 'function create() { let value; return () => value ??= client.getSite(); } const retained = create();', + ], + [ + 'neutral mutable binding', + 'let value; export async function read() { return value ??= await client.getCourse(); }', + ], + [ + 'module object write', + 'const holder = {}; export async function read() { holder.value = await client.getCourse(); }', + ], + [ + 'module object alias write', + 'const holder = {}; export async function read() { const alias = holder; alias.value = await client.getCourse(); }', + ], + [ + 'module array write', + 'const holder = []; export async function read() { holder.push(await client.getCourse()); }', + ], + ['static collection', 'class Holder { static entries = new Map(); }'], + [ + 'static factory collection', + 'function create() { return new WeakSet(); } class Holder { static entries = create(); }', + ], + [ + 'static response', + 'class Holder { static value; } export async function read() { Holder.value = await client.getCourse(); }', + ], + [ + 'static this write', + 'class Holder { static value; static async read() { this.value = await client.getCourse(); } }', + ], + [ + 'global response', + 'export async function read() { globalThis.value = await client.getCourse(); }', + ], + [ + 'aliased global response', + 'const root = globalThis; export async function read() { root["value"] = await client.getCourse(); }', + ], + ])('rejects %s in a transitive helper and the real render closure', (_label, source) => { + const helper = 'apps/web/src/lib/lifetime-control.ts'; + const graph = buildSourceGraph({ + [PUBLIC_LAYOUT]: 'import "@/lib/lifetime-control";', + [helper]: source, + }); + clean(graph.unresolved, 'The lifetime control uses supported static modules.'); + expect(cacheFindings(graph, reachable(graph, [PUBLIC_LAYOUT]))).toEqual( + expect.arrayContaining([expect.objectContaining({ file: helper })]), + ); + const realHelper = 'apps/web/src/server/public-entry.ts'; + const real = graphWith(realHelper, sources[realHelper]! + '\n' + source); + expect(reachable(real, renderRoots)).toContain(realHelper); + expect(cacheFindings(real, reachable(real, renderRoots))).toEqual( + expect.arrayContaining([expect.objectContaining({ file: realHelper })]), + ); + }); + + it.each([ + [ + 'local collections', + 'export function read() { const entries = new Map(); const keys = new Set(); const weak = new WeakSet(); entries.set("a", 1); keys.add("a"); return { entries, keys, weak }; }', + ], + [ + 'request factory result', + 'function create() { const entries = new Map(); return (key) => entries.get(key); } export function read() { return create(); }', + ], + [ + 'request mutable value', + 'export async function read() { let value; value = await client.getSite(); return value; }', + ], + [ + 'request object writes', + 'export async function read() { const holder = {}; const alias = holder; alias.value = await client.getCourse(); return holder; }', + ], + [ + 'request class instance', + 'class Holder { value; async read() { this.value = await client.getCourse(); } } export const read = () => new Holder();', + ], + [ + 'pure factory scratch collection', + 'function count() { const entries = new Set([1, 2]); return entries.size; } const countValue = count();', + ], + [ + 'pure immutable factory closure', + 'function create() { const prefix = "ok"; return () => prefix; } const read = create();', + ], + ['shadowed collection', 'const Map = class {}; const value = new Map();'], + [ + 'React cache closure', + 'import { cache as memo } from "react"; const read = memo(async () => { const entries = new Map(); entries.set("a", await client.getSite()); return entries; });', + ], + ])('keeps %s clean', (_label, source) => { + clean( + cacheFindings(buildSourceGraph({ [probe]: source }), [probe]), + 'Request-local work and immutable declarations do not retain public representations.', + ); + }); + + it('rejects a renamed copy of the request memo primitive retained by a module', () => { + const copy = 'apps/web/src/lib/copied-memo.ts'; + const graph = buildSourceGraph({ + [probe]: + 'import { requestMemo as memo } from "@/lib/copied-memo"; export const read = memo(load);', + [copy]: sources[REQUEST_MEMO]!, + }); + clean(graph.unresolved, 'The copied primitive is fully present in the graph.'); + expect(cacheFindings(graph, reachable(graph, [probe]))).toEqual( + expect.arrayContaining([expect.objectContaining({ file: probe })]), + ); + const canonical = buildSourceGraph({ + [probe]: + 'import { requestMemo as memo } from "@/server/request-memo"; export const read = memo(load);', + [REQUEST_MEMO]: sources[REQUEST_MEMO]!, + }); + clean( + cacheFindings(canonical, reachable(canonical, [probe])), + 'Only the exact canonical audited request memo receives the lifetime exception.', + ); + }); + + it('follows an imported namespace factory returning retained state', () => { + const helper = 'apps/web/src/lib/lifetime-factory.ts'; + const graph = buildSourceGraph({ + [probe]: + 'import * as factories from "@/lib/lifetime-factory"; const retained = factories.create();', + [helper]: 'export function create() { return new Map(); }', + }); + clean(graph.unresolved, 'The local factory implementation is included in the census.'); + expect(cacheFindings(graph, reachable(graph, [probe]))).toEqual( + expect.arrayContaining([expect.objectContaining({ file: probe })]), + ); + }); + + it('terminates on recursive factories without inventing retained state', () => { + const graph = buildSourceGraph({ + [probe]: 'function create() { return create(); } const retained = create();', + }); + clean( + cacheFindings(graph, [probe]), + 'Source recursion terminates within the bounded analysis.', + ); + }); + + it.each([ + 'apps/web/src/app/(studio)/studio/page.tsx', + 'apps/web/src/app/(portal)/portal/page.tsx', + ])('includes admitted scaffold %s in the production retention fence', (file) => { + const graph = graphWith(file, sources[file]! + '\nconst entries = new Set();'); + expect(reachable(graph, renderRoots)).toContain(file); + expect(cacheFindings(graph, reachable(graph, renderRoots))).toEqual( + expect.arrayContaining([expect.objectContaining({ file })]), + ); + }); +}); + +describe('remediation public document navigation', () => { + it.each([ + 'import Link from "next/link"; export const View = () => <Link href="/en/courses">Courses</Link>;', + 'import { default as CourseLink } from "next/link";', + 'export { default as Link } from "next/link";', + 'export * from "next/link";', + 'const links = import("next/link");', + 'const links = require("next/link");', + 'import Links = require("next/link");', + 'import { useRouter as router } from "next/navigation";', + 'import * as navigation from "next/navigation";', + 'export { useRouter as router } from "next/navigation";', + 'export * from "next/navigation";', + 'const navigation = import("next/navigation");', + 'import Router from "next/router";', + ])('rejects runtime router navigation: %s', (source) => { + const graph = buildSourceGraph({ [PUBLIC_LAYOUT]: source }); + expect(navigationFindings(graph, [PUBLIC_LAYOUT])).toEqual( + expect.arrayContaining([expect.objectContaining({ file: PUBLIC_LAYOUT })]), + ); + }); + + it('follows aliased local barrels into the actual product component closure', () => { + const component = 'apps/web/src/components/public/catalog.tsx'; + const barrel = 'apps/web/src/lib/navigation-control.ts'; + const graph = buildSourceGraph({ + ...sources, + [component]: + sources[component]! + '\nimport { CourseLink as Alias } from "@/lib/navigation-control";', + [barrel]: 'export { default as CourseLink } from "next/link";', + }); + clean(graph.unresolved, 'The aliased router barrel is in the production source graph.'); + expect(reachable(graph, renderRoots)).toContain(component); + expect(navigationFindings(graph, reachable(graph, renderRoots))).toEqual( + expect.arrayContaining([ + expect.objectContaining({ file: component }), + expect.objectContaining({ file: barrel }), + ]), + ); + }); + + it('permits ordinary anchors, server control flow and erased router types', () => { + const helper = 'apps/web/src/lib/server-navigation.ts'; + const graph = buildSourceGraph({ + [PUBLIC_LAYOUT]: + 'import { missing, move } from "@/lib/server-navigation"; import type Link from "next/link"; import type { useRouter } from "next/navigation"; export const View = () => <a href="/en/courses">Courses</a>;', + [helper]: 'export { notFound as missing, redirect as move } from "next/navigation";', + }); + clean(graph.unresolved, 'Allowed document navigation has a complete static graph.'); + clean( + navigationFindings(graph, reachable(graph, [PUBLIC_LAYOUT])), + 'Server refusal/redirect control flow does not retain client Router Cache.', + ); + }); +}); + describe('remediation source resolution census controls', () => { it.each([ 'import messages from "@/lib/control.json";', @@ -1286,13 +1548,22 @@ describe('remediation source resolution census controls', () => { // The independent config guard closes the alias that this bounded graph cannot model. expect(reachable(graph, [probe])).toEqual([probe]); }); - it('refuses unsupported production mts/cts while excluding declarations and test files', () => { - const names = ['apps/web/src/lib/runtime.mts', 'packages/sdk/src/runtime.cts']; + it('refuses unsupported production JS/JSX/mts/cts including routes while excluding test files', () => { + const names = [ + 'apps/web/src/lib/runtime.mts', + 'packages/sdk/src/runtime.cts', + 'apps/web/src/app/(public)/unsafe/page.js', + 'apps/web/src/app/(public)/unsafe/layout.jsx', + 'packages/sdk/src/runtime.mjs', + 'packages/ui/src/runtime.cjs', + ]; expect( unsupportedSourceFindings([ ...names, 'packages/sdk/src/schema.d.mts', 'apps/web/src/lib/probe.test.cts', + 'apps/web/src/lib/probe.test.js', + 'apps/web/src/lib/probe.spec.jsx', ]), ).toEqual( names.map((file) => ({ diff --git a/frontend/apps/web/src/test/public-boundary-analysis.ts b/frontend/apps/web/src/test/public-boundary-analysis.ts index 29551f6c..37aa5ddf 100644 --- a/frontend/apps/web/src/test/public-boundary-analysis.ts +++ b/frontend/apps/web/src/test/public-boundary-analysis.ts @@ -5,8 +5,9 @@ import ts from 'typescript'; /** * ADR-0053's source fences. This is a bounded AST analysis, not a JavaScript * interpreter: it follows static module paths, declaration aliases and constant - * strings. Runtime reassignment, eval and third-party implementation bodies are - * outside its claim. Unknown local module paths fail the graph census separately. + * strings, local factory returns and direct storage writes. Reflective mutation, + * eval and third-party implementation bodies are outside its claim. Unknown + * local module paths and unsupported production extensions fail census separately. */ export type SourceCensus = Readonly<Record<string, string>>; export type Finding = { readonly file: string; readonly line: number; readonly reason: string }; @@ -287,7 +288,11 @@ export function resolutionCensusFindings( export function unsupportedSourceFindings(names: readonly string[]): Finding[] { return names - .filter((name) => /\.(?:mts|cts)$/.test(name) && !/\.(?:test|spec|d)\.(?:mts|cts)$/.test(name)) + .filter( + (name) => + /\.(?:mts|cts|[cm]?js|jsx)$/.test(name) && + !/\.(?:test|spec|d)\.(?:[cm]?[jt]s|jsx)$/.test(name), + ) .map((file) => ({ file, line: 1, @@ -860,24 +865,8 @@ function globalCollection( checker: ts.TypeChecker, visited = new Set<ts.Node>(), ): boolean { - node = unwrapped(node); - if (visited.has(node)) return false; - visited.add(node); - const receiver = memberReceiver(node); - if ( - receiver && - ['Map', 'WeakMap'].includes(memberName(node, checker) ?? '') && - globalObject(receiver, checker) - ) - return true; - if (!ts.isIdentifier(node)) return false; - const bindings = declarations(node, checker); - if (['Map', 'WeakMap'].includes(node.text) && bindings.length === 0) return true; - return bindings.some( - (declaration) => - ts.isVariableDeclaration(declaration) && - declaration.initializer !== undefined && - globalCollection(declaration.initializer, checker, visited), + return ['Map', 'WeakMap', 'Set', 'WeakSet'].some((name) => + globalBuiltin(node, name, checker, new Set(visited)), ); } @@ -891,7 +880,11 @@ function syntaxShape(node: ts.Node): unknown { const declarationKind = ts.isVariableDeclarationList(node) ? node.flags & (ts.NodeFlags.Const | ts.NodeFlags.Let) : undefined; - return [node.kind, value, declarationKind, children]; + const unaryOperator = + ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node) + ? node.operator + : undefined; + return [node.kind, value, declarationKind, unaryOperator, children]; } // This narrowly audited primitive is intentionally syntax-constrained. The map @@ -923,15 +916,256 @@ const requestMemoContract = syntaxShape( ), ); +function auditedRequestMemo(file: ts.SourceFile): boolean { + return ( + file.fileName === REQUEST_MEMO && + JSON.stringify(syntaxShape(file)) === JSON.stringify(requestMemoContract) + ); +} + +function inside(node: ts.Node, ancestor: ts.Node): boolean { + for (let current: ts.Node | undefined = node; current; current = current.parent) + if (current === ancestor) return true; + return false; +} + +function mutableBinding(declaration: ts.VariableDeclaration): boolean { + return ( + ts.isVariableDeclarationList(declaration.parent) && + !(declaration.parent.flags & ts.NodeFlags.Const) + ); +} + +function staticMember(node: ts.Node): boolean { + return ( + (ts.canHaveModifiers(node) && + ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword)) ?? + false + ); +} + +/** Local calls and returned closures only; no general control-flow or heap interpretation. */ +function localFunctions( + node: ts.Node, + graph: SourceGraph, + visited = new Set<ts.Node>(), +): ts.FunctionLikeDeclaration[] { + node = unwrapped(node); + if (visited.has(node)) return []; + visited.add(node); + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node)) + return [node]; + const bindings = ts.isPropertyAccessExpression(node) + ? (graph.checker.getSymbolAtLocation(node.name)?.declarations ?? []) + : declarations(node, graph.checker); + return bindings.flatMap((declaration) => + ts.isFunctionDeclaration(declaration) + ? [declaration] + : ts.isVariableDeclaration(declaration) && declaration.initializer + ? localFunctions(declaration.initializer, graph, visited) + : [], + ); +} + +function retainedState( + node: ts.Node | undefined, + graph: SourceGraph, + visited = new Set<ts.Node>(), +): boolean { + if (!node) return false; + node = unwrapped(node); + if (visited.has(node)) return false; + visited.add(node); + const next = (value: ts.Node | undefined) => retainedState(value, graph, new Set(visited)); + if (ts.isNewExpression(node)) + return ( + globalCollection(node.expression, graph.checker) || + globalBuiltin(node.expression, 'Promise', graph.checker) + ); + if (ts.isIdentifier(node)) + return declarations(node, graph.checker).some( + (declaration) => + (ts.isVariableDeclaration(declaration) && next(declaration.initializer)) || + (ts.isFunctionDeclaration(declaration) && next(declaration)), + ); + if (ts.isObjectLiteralExpression(node)) + return node.properties.some((property) => + ts.isPropertyAssignment(property) + ? next(property.initializer) + : ts.isShorthandPropertyAssignment(property) + ? next(property.name) + : ts.isSpreadAssignment(property) + ? next(property.expression) + : ts.isMethodDeclaration(property) && next(property), + ); + if (ts.isArrayLiteralExpression(node)) return node.elements.some(next); + if (ts.isConditionalExpression(node)) return next(node.whenTrue) || next(node.whenFalse); + if (ts.isBinaryExpression(node)) return next(node.left) || next(node.right); + if ( + (ts.isArrowFunction(node) || + ts.isFunctionExpression(node) || + ts.isFunctionDeclaration(node) || + ts.isMethodDeclaration(node)) && + node.body + ) { + const closure = node; + const body = node.body; + let retained = false; + walk(body, (reference) => { + if (!ts.isIdentifier(reference)) return; + for (const declaration of declarations(reference, graph.checker)) { + if (!ts.isVariableDeclaration(declaration) || inside(declaration, closure)) continue; + const initializer = declaration.initializer && unwrapped(declaration.initializer); + if ( + mutableBinding(declaration) || + (initializer && + (ts.isObjectLiteralExpression(initializer) || + ts.isArrayLiteralExpression(initializer))) || + next(initializer) + ) + retained = true; + } + }); + return retained; + } + if (!ts.isCallExpression(node)) return false; + if (importedFunction(node.expression, graph, ['react'], ['cache'])) return false; + const factories = localFunctions(node.expression, graph); + if (factories.some((factory) => auditedRequestMemo(factory.getSourceFile()))) return false; + if ( + ['getSite', 'getCourses', 'getCourse', 'getLesson'].includes( + memberName(node.expression, graph.checker) ?? '', + ) || + globalBuiltin(node.expression, 'Promise.resolve', graph.checker) || + globalBuiltin(node.expression, 'Promise.all', graph.checker) + ) + return true; + return factories.some((factory) => { + if (!factory.body) return false; + if (!ts.isBlock(factory.body)) return next(factory.body); + let retained = false; + const returns = (child: ts.Node) => { + if (ts.isFunctionLike(child) || ts.isClassLike(child)) return; + if (ts.isReturnStatement(child) && next(child.expression)) retained = true; + ts.forEachChild(child, returns); + }; + returns(factory.body); + return retained; + }); +} + +/** Direct writes through aliases to module, static or global storage outlive requests. */ +function sharedStorage(node: ts.Node, graph: SourceGraph, visited = new Set<ts.Node>()): boolean { + node = unwrapped(node); + if (visited.has(node)) return false; + visited.add(node); + if (globalObject(node, graph.checker)) return true; + const receiver = memberReceiver(node); + if (receiver) { + if (sharedStorage(receiver, graph, new Set(visited))) return true; + if (receiver.kind === ts.SyntaxKind.ThisKeyword) { + for (let parent: ts.Node | undefined = node.parent; parent; parent = parent.parent) { + if (staticMember(parent) || ts.isClassStaticBlockDeclaration(parent)) return true; + if (ts.isClassLike(parent)) break; + } + } + } + return declarations(node, graph.checker).some((declaration) => { + if (ts.isClassDeclaration(declaration) && moduleScope(declaration)) return true; + if (ts.isPropertyDeclaration(declaration) && staticMember(declaration)) return true; + if (!ts.isVariableDeclaration(declaration)) return false; + return ( + moduleScope(declaration) || + (declaration.initializer !== undefined && + sharedStorage(declaration.initializer, graph, new Set(visited))) + ); + }); +} + +/** P6 G40 requires document anchors, so router-backed navigation is excluded by import. */ +export function navigationFindings(graph: SourceGraph, subjects: readonly string[]): Finding[] { + const findings: Finding[] = []; + for (const name of subjects) { + const file = graph.files.get(name); + if (!file) continue; + walk(file, (node) => { + let specifier: string | undefined; + if (ts.isImportDeclaration(node) && runtimeImport(node)) + specifier = constantString(node.moduleSpecifier, graph.checker); + else if (ts.isExportDeclaration(node) && runtimeExport(node)) + specifier = constantString(node.moduleSpecifier, graph.checker); + else if ( + ts.isImportEqualsDeclaration(node) && + !node.isTypeOnly && + ts.isExternalModuleReference(node.moduleReference) + ) + specifier = constantString(node.moduleReference.expression, graph.checker); + else if ( + ts.isCallExpression(node) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && node.expression.text === 'require')) + ) + specifier = constantString(node.arguments[0], graph.checker); + const routerOrigin = valueOrigins(node, graph, { + nodes: new Set(), + exports: new Set(), + }).some( + (origin) => + origin.kind === 'external' && + (['next/link', 'next/router'].includes(origin.specifier) || + (origin.specifier === 'next/navigation' && origin.name === 'useRouter')), + ); + // Namespace/dynamic next/navigation edges could obtain useRouter without a + // named binding. Permit only the named server control-flow imports below. + const unsafeNavigationEdge = + specifier === 'next/navigation' && + !( + ts.isImportDeclaration(node) && + node.importClause?.namedBindings && + ts.isNamedImports(node.importClause.namedBindings) && + !node.importClause.name && + node.importClause.namedBindings.elements.every( + (binding) => + binding.isTypeOnly || + ['notFound', 'redirect', 'permanentRedirect'].includes( + (binding.propertyName ?? binding.name).text, + ), + ) + ) && + !( + ts.isExportDeclaration(node) && + node.exportClause && + ts.isNamedExports(node.exportClause) && + node.exportClause.elements.every( + (binding) => + binding.isTypeOnly || + ['notFound', 'redirect', 'permanentRedirect'].includes( + (binding.propertyName ?? binding.name).text, + ), + ) + ); + if ( + (specifier !== undefined && ['next/link', 'next/router'].includes(specifier)) || + unsafeNavigationEdge || + routerOrigin + ) + findings.push( + finding( + node, + 'Fix: use ordinary document anchors; public navigation must not prefetch or retain Router Cache.', + ), + ); + }); + } + return findings; +} + export function cacheFindings(graph: SourceGraph, subjects: readonly string[]): Finding[] { const findings: Finding[] = []; for (const name of subjects) { const file = graph.files.get(name); if (!file) continue; - if ( - name === REQUEST_MEMO && - JSON.stringify(syntaxShape(file)) !== JSON.stringify(requestMemoContract) - ) + if (name === REQUEST_MEMO && !auditedRequestMemo(file)) findings.push( finding( file, @@ -939,6 +1173,42 @@ export function cacheFindings(graph: SourceGraph, subjects: readonly string[]): ), ); walk(file, (node) => { + if ( + (ts.isVariableDeclaration(node) && + moduleScope(node) && + (mutableBinding(node) || retainedState(node.initializer, graph))) || + (ts.isPropertyDeclaration(node) && + staticMember(node) && + retainedState(node.initializer, graph)) + ) + findings.push( + finding( + node, + 'Fix: keep mutable public state and retained factory results inside the request.', + ), + ); + if ( + (ts.isBinaryExpression(node) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment && + sharedStorage(node.left, graph)) || + ((ts.isPostfixUnaryExpression(node) || ts.isPrefixUnaryExpression(node)) && + [ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(node.operator) && + sharedStorage(node.operand, graph)) || + (ts.isDeleteExpression(node) && sharedStorage(node.expression, graph)) || + (ts.isCallExpression(node) && + ['set', 'add', 'delete', 'clear', 'push', 'pop', 'shift', 'unshift', 'splice'].includes( + memberName(node.expression, graph.checker) ?? '', + ) && + memberReceiver(node.expression) !== undefined && + sharedStorage(memberReceiver(node.expression)!, graph)) + ) + findings.push( + finding( + node, + 'Fix: do not write public request state to module, class-static or global storage.', + ), + ); if ( (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && diff --git a/frontend/apps/web/src/test/public-rendering-assertions.test.ts b/frontend/apps/web/src/test/public-rendering-assertions.test.ts new file mode 100644 index 00000000..59d9899e --- /dev/null +++ b/frontend/apps/web/src/test/public-rendering-assertions.test.ts @@ -0,0 +1,205 @@ +// @vitest-environment node +import { createRequire } from 'node:module'; + +import { describe, expect, it } from 'vitest'; + +import { + absentFromSerializedText, + absentFromWholeResponse, + documentViewport, + productContainment, + productTheme, + statusDocument, + visibleDocument, +} from '../../scripts/public-rendering-assertions.mjs'; + +const require = createRequire(import.meta.url); +const { JSDOM } = require('jsdom') as { + JSDOM: new (html: string) => { window: { document: Document } }; +}; +const { htmlEscapeJsonString } = require('next/dist/shared/lib/htmlescape') as { + htmlEscapeJsonString: (value: string) => string; +}; +const flight = (value: string) => + `<script>self.__next_f.push(${htmlEscapeJsonString(JSON.stringify([1, `0:${JSON.stringify({ value })}\n`]))})</script>`; +const response = (body: string) => ({ + status: 404, + headers: { 'content-type': 'text/html' }, + body, +}); +const first = { host: 'first.example', name: 'First institution' }; +const second = { host: 'second.example', name: 'Other & institution' }; +const product = (host: string) => ({ + host, + protectedCanaries: [`${host} protected & <secret>`], + allCanaries: [`${host} private`], + courseTitle: `${host} course`, + courseSummary: `${host} summary`, + lessonTitle: `${host} lesson`, + content: { fields: [{ value: `${host} value & <secret>` }] }, +}); +const configuration = { + tenants: [first, second], + product: [product(first.host), product(second.host)], +}; +const sentinel = '{count, plural, one {# course} other {# courses}}'; + +// The production fixture calls these same predicates; controls alter one seam +// while retaining a known-good document or response for the others. +describe('production rendering containment assertions', () => { + it.each([ + 'A & B < C > D', + 'quotes " and \\ slash', + 'mixed " \\ & < > \u2028 \u2029', + 'line\u2028separator\u2029end', + ])('rejects an inline Flight-only leak encoded by pinned Next: %j', (value) => { + const body = flight(value); + expect(() => absentFromWholeResponse(response(body), [value])).toThrow('containment'); + expect(() => absentFromSerializedText(body, [value])).toThrow('containment'); + expect(() => + absentFromWholeResponse(response(flight('independent public value')), [value]), + ).not.toThrow(); + }); + + it.each([ + '<p>private & <value></p>', + '<p title="private & <value>">Public text</p>', + '<script>private & <value></script>', + ])('rejects raw and entity-encoded leaks anywhere in HTML: %s', (body) => { + expect(() => absentFromWholeResponse(response(body), ['private & <value>'])).toThrow( + 'containment', + ); + expect(() => + absentFromWholeResponse(response('<p title="Public">Public text</p>'), ['private & <value>']), + ).not.toThrow(); + }); + + it('refuses an empty marker instead of making a vacuous assertion', () => { + expect(() => absentFromWholeResponse(response('clean'), [''])).toThrow('nonempty'); + }); + + it.each([ + second.name, + configuration.product[1]!.allCanaries[0]!, + configuration.product[1]!.content.fields[0]!.value, + configuration.product[0]!.protectedCanaries[0]!, + sentinel, + ])('rejects opposite, protected and unused catalogue values in Flight: %s', (value) => { + const check = (body: string) => + productContainment(response(body), first, configuration, [sentinel]); + expect(() => check(`<h1>${first.name}</h1>${flight(value)}`)).toThrow('containment'); + expect(() => check(`<h1>${first.name}</h1>${flight('public own content')}`)).not.toThrow(); + }); + + it('detects an unchanged complete catalogue through its unused ICU sentinel', () => { + const bundle = JSON.stringify({ catalog: { title: 'Courses', course_count: sentinel } }); + expect(() => absentFromSerializedText(flight(bundle), [sentinel])).toThrow('containment'); + expect(() => absentFromSerializedText(flight('Courses'), [sentinel])).not.toThrow(); + }); +}); + +const details = { + theme: { primary: '#123456', background: '#ffffff', foreground: '#111111', muted: '#555555' }, + showAttribution: true, + locale: 'en', +}; +const palette = ':root{--ls-primary:#123456;--ls-bg:#ffffff;--ls-fg:#111111;--ls-muted:#555555;}'; +const defaults = ':root{--ls-primary:#1f6feb;--ls-bg:#fff;--ls-fg:#0f172a;--ls-muted:#64748b;}'; +const themeDoc = ( + style = `<style>${palette}</style>`, + footer = '<footer class="public-footer">Powered by LearnStack</footer>', +) => new JSDOM(`<html><head>${style}</head><body>${footer}</body></html>`).window.document; + +describe('production theme assertion controls', () => { + it.each([ + '<style>:root{--ls-primary:#123456;}</style>', + `<style>${palette}</style><p style="--ls-muted:#555555">Text</p>`, + '<p style="--ls-primary:#123456;--ls-bg:#ffffff;--ls-fg:#111111;--ls-muted:#555555;">Text</p>', + ])('rejects partial, duplicate and inline palette emission: %s', (style) => { + expect(() => productTheme(themeDoc(style), details)).toThrow(); + expect(() => productTheme(themeDoc(), details)).not.toThrow(); + }); + + it('rejects attribution that disagrees with effective entitlement', () => { + expect(() => productTheme(themeDoc('', ''), details)).toThrow(); + expect(() => + productTheme(themeDoc(undefined, ''), { ...details, showAttribution: false }), + ).not.toThrow(); + }); + + it('requires whole compiled defaults and no override for malformed input', () => { + const link = '<link rel="stylesheet" href="/_next/static/css/theme.css">'; + expect(() => productTheme(themeDoc(link), details, true, () => defaults)).not.toThrow(); + expect(() => + productTheme( + themeDoc(link + '<style>:root{--ls-primary:#abcdef;}</style>'), + details, + true, + () => defaults, + ), + ).toThrow('partial override'); + expect(() => + productTheme(themeDoc(link), details, true, () => + defaults.replace('--ls-muted:#64748b;', ''), + ), + ).toThrow('defaults'); + }); +}); + +const statusHtml = `<html lang="ar" dir="rtl"><head><meta name="viewport" content="width=device-width, initial-scale=1"><title>Page not found | First institution
${first.name}

Page not found

Course catalog
`; +const checkStatus = (body: string, status = 404) => + statusDocument({ ...response(body), status }, first, 'ar', 'en', { + tenants: [first, second], + catalogueSentinels: [sentinel], + }); + +describe('production status assertion controls', () => { + it.each([ + statusHtml.replace('lang="ar"', 'lang="en"'), + statusHtml.replace('dir="rtl"', 'dir="ltr"'), + statusHtml.replace('

Page not found

', '

Page not found

Extra

'), + statusHtml.replace('

Page not found

', ''), + statusHtml.replace( + 'Page not found | First institution', + 'Wrong title', + ), + statusHtml.replace('content="noindex"', 'content="index"'), + statusHtml.replace('href="/ar/courses"', 'href="/en/courses"'), + statusHtml + flight('private-query-value'), + statusHtml + flight(second.name), + statusHtml + flight(sentinel), + ])('rejects a status-document mutation while the clean document passes', (body) => { + expect(() => checkStatus(body)).toThrow(); + expect(() => checkStatus(statusHtml)).not.toThrow(); + }); + + it('rejects HTTP 200 even when the visible status document is correct', () => { + expect(() => checkStatus(statusHtml, 200)).toThrow('Public status response'); + expect(() => checkStatus(statusHtml)).not.toThrow(); + }); + + it('does not count Flight or hidden headings as visible HTML', () => { + const doc = visibleDocument( + response(statusHtml + '' + flight('

Flight

')), + ); + expect(doc.querySelectorAll('h1')).toHaveLength(1); + }); +}); + +// Metadata checks cannot replace the packet's manual reflow and zoom smoke. +describe('emitted product viewport metadata', () => { + it.each([ + '', + '', + '', + '', + '', + ])('rejects absent or zoom-restricting metadata: %s', (metadata) => { + expect(() => documentViewport(themeDoc(metadata))).toThrow(); + expect(() => + documentViewport( + themeDoc(''), + ), + ).not.toThrow(); + }); +}); From 69b382a25dd4296503b79dedd12e0c96e6fd5ddc Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 11:28:55 +0300 Subject: [PATCH 26/58] fix(public): close retention and attribution proof gaps Follow shorthand value symbols and destructured shared-storage aliases so ordinary syntax cannot evade the bounded public retention fence. Exercise both attribution directions with a valid palette to prevent an earlier assertion from masking the intended negative control. ADR: 0027, 0053, 0054 --- docs/roadmap/phase-02d-walking-skeleton.md | 22 +++++++++++++---- .../web/src/test/public-boundaries.test.ts | 24 +++++++++++++++++++ .../web/src/test/public-boundary-analysis.ts | 22 ++++++++++++++++- .../test/public-rendering-assertions.test.ts | 8 ++++++- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index a4944d4b..ed82e76f 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4838,8 +4838,8 @@ work. This package makes no new passing runtime or merge-readiness claim. ##### Remediation group 1 — existing-contract corrections -**Implemented; independent review rounds pending — 2026-10-10.** No proposed -decision above is implemented by this group. ADR-0055 remains Proposed. +**Implemented; round 1 findings corrected; round 2 pending — 2026-10-10.** No +proposed decision above is implemented by this group. ADR-0055 remains Proposed. The Step 1 status and current i18n/theme carriers now match delivery. Skills use locale-bearing public folders, the shared resource loader, supported Next page @@ -4882,14 +4882,28 @@ The following review claims do not justify changing accepted behavior: placement does not make that contract nonbinding. Accepted history is preserved. - Packet 10's 19/3 standards count is historical; its timestamp is now explicit. Phase 06's P6 review/merge-pending statement is still true for this open PR. +- The review skill's conditional manual-evidence completion gate remains valid + for future work. Branding has no authored-language field; inventing a language + for the institution name would not establish correct pronunciation. -Validation: the guarded frontend runner passes 1,196 cases (1,136 web, 60 SDK), -with zero skips/todos; workspace lint/typecheck pass. Five Release corpus tests +Validation at `cc626e8`: the guarded frontend runner passes 1,197 cases (1,137 web, +60 SDK), with zero skips/todos; workspace lint/typecheck pass. Five Release corpus tests and seven ADR-workflow cases pass. The final focused Release integration run passes 20 cases: six real production Next/native HTTPS/API/PostgreSQL modes and fourteen cleanup/log-control companions, with zero skips. No backend full-suite rerun or new manual accessibility pass is inferred from these focused checks. +**Round 1 — fresh independent reviews of `ed6f81e..cc626e8`.** GPT-6-astra +(high) reviewed runtime/source-boundary behavior; GPT-6.1-sol (high) reviewed +proofs, guidance and documentation. Three findings were verified: shorthand +factory returns and destructured shared-storage aliases escaped the bounded +retention fence, and the attribution dirty control failed on its palette before +reaching attribution. Four new dirty cases fail before the retention fix; they +and two request-local clean cases pass afterward. The attribution control keeps +a valid palette and checks the specific failure in both entitlement directions. +Focused validation passes all 263 boundary cases and 36 response-helper cases; +web typecheck passes. Neither review claims a Docker/full-suite/manual rerun. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/frontend/apps/web/src/test/public-boundaries.test.ts b/frontend/apps/web/src/test/public-boundaries.test.ts index ec950275..681c63d0 100644 --- a/frontend/apps/web/src/test/public-boundaries.test.ts +++ b/frontend/apps/web/src/test/public-boundaries.test.ts @@ -1189,6 +1189,14 @@ describe('remediation public representation lifetimes', () => { 'object factory', 'function create() { return { entries: new Map() }; } const retained = create();', ], + [ + 'shorthand collection factory', + 'function create() { const entries = new Map(); return { entries }; } const retained = create();', + ], + [ + 'shorthand mutable closure factory', + 'function create() { let pending: Promise | undefined; const read = (load: () => Promise) => pending ??= load(); return { read }; } const retained = create(); export const read = retained.read;', + ], [ 'closure factory', 'function create() { const entries = new Map(); return (key) => entries.get(key); } const retained = create();', @@ -1217,6 +1225,14 @@ describe('remediation public representation lifetimes', () => { 'module object alias write', 'const holder = {}; export async function read() { const alias = holder; alias.value = await client.getCourse(); }', ], + [ + 'module object destructuring write', + 'const holder = { nested: {} as Record }; export function remember(value: unknown) { const { nested } = holder; nested.value = value; }', + ], + [ + 'module array destructuring write', + 'const holders = [{} as Record]; export function remember(value: unknown) { const [nested] = holders; nested.value = value; }', + ], [ 'module array write', 'const holder = []; export async function read() { holder.push(await client.getCourse()); }', @@ -1277,6 +1293,14 @@ describe('remediation public representation lifetimes', () => { 'request object writes', 'export async function read() { const holder = {}; const alias = holder; alias.value = await client.getCourse(); return holder; }', ], + [ + 'request object destructuring write', + 'export function remember(value: unknown) { const holder = { nested: {} as Record }; const { nested } = holder; nested.value = value; return holder; }', + ], + [ + 'request array destructuring write', + 'export function remember(value: unknown) { const holders = [{} as Record]; const [nested] = holders; nested.value = value; return holders; }', + ], [ 'request class instance', 'class Holder { value; async read() { this.value = await client.getCourse(); } } export const read = () => new Holder();', diff --git a/frontend/apps/web/src/test/public-boundary-analysis.ts b/frontend/apps/web/src/test/public-boundary-analysis.ts index 37aa5ddf..6b1c683e 100644 --- a/frontend/apps/web/src/test/public-boundary-analysis.ts +++ b/frontend/apps/web/src/test/public-boundary-analysis.ts @@ -63,7 +63,11 @@ function unwrapped(node: ts.Node): ts.Node { function declarations(node: ts.Node, checker: ts.TypeChecker): readonly ts.Declaration[] { if (!ts.isIdentifier(node)) return []; - let symbol = checker.getSymbolAtLocation(node); + // A shorthand property's name has a property symbol; follow its captured value. + let symbol = + ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node + ? checker.getShorthandAssignmentValueSymbol(node.parent) + : checker.getSymbolAtLocation(node); if (symbol && symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol); return symbol?.declarations ?? []; } @@ -1073,6 +1077,22 @@ function sharedStorage(node: ts.Node, graph: SourceGraph, visited = new Set { if (ts.isClassDeclaration(declaration) && moduleScope(declaration)) return true; if (ts.isPropertyDeclaration(declaration) && staticMember(declaration)) return true; + if (ts.isBindingElement(declaration)) { + // Named object/array bindings preserve the selected object's identity. + // Rest bindings allocate a new container and are not direct aliases. + let binding = declaration; + while (!binding.dotDotDotToken) { + const owner = binding.parent.parent; + if (ts.isVariableDeclaration(owner)) + return ( + owner.initializer !== undefined && + sharedStorage(owner.initializer, graph, new Set(visited)) + ); + if (!ts.isBindingElement(owner)) break; + binding = owner; + } + return false; + } if (!ts.isVariableDeclaration(declaration)) return false; return ( moduleScope(declaration) || 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 59d9899e..17477bd0 100644 --- a/frontend/apps/web/src/test/public-rendering-assertions.test.ts +++ b/frontend/apps/web/src/test/public-rendering-assertions.test.ts @@ -121,7 +121,13 @@ describe('production theme assertion controls', () => { }); it('rejects attribution that disagrees with effective entitlement', () => { - expect(() => productTheme(themeDoc('', ''), details)).toThrow(); + expect(() => productTheme(themeDoc(undefined, ''), details)).toThrow( + 'Effective tenant entitlement alone selects attribution', + ); + expect(() => productTheme(themeDoc(), { ...details, showAttribution: false })).toThrow( + 'Effective tenant entitlement alone selects attribution', + ); + expect(() => productTheme(themeDoc(), details)).not.toThrow(); expect(() => productTheme(themeDoc(undefined, ''), { ...details, showAttribution: false }), ).not.toThrow(); From bdb88bbf1608fba569f58e198a9a929213237c25 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 11:38:31 +0300 Subject: [PATCH 27/58] fix(public): trace member factories and selected captures Keep ordinary resolved object factories and destructured closure values inside the bounded retention analysis. Select the actual captured value so unrelated collection siblings do not produce false positives. Record the maintainer's separate Open Graph and security approvals; the exact bootstrap response-owner design remains unapproved. ADR: 0027, 0053, 0054 --- ...0055-public-renderer-bootstrap-failures.md | 4 + docs/roadmap/phase-02d-walking-skeleton.md | 77 +++++++----- .../web/src/test/public-boundaries.test.ts | 56 +++++++++ .../web/src/test/public-boundary-analysis.ts | 111 ++++++++++++++++-- 4 files changed, 210 insertions(+), 38 deletions(-) diff --git a/docs/decisions/0055-public-renderer-bootstrap-failures.md b/docs/decisions/0055-public-renderer-bootstrap-failures.md index 92cf0a1d..1d0efb00 100644 --- a/docs/decisions/0055-public-renderer-bootstrap-failures.md +++ b/docs/decisions/0055-public-renderer-bootstrap-failures.md @@ -4,6 +4,10 @@ Proposed — 2026-10-10. Maintainer approval is pending. +**Review disposition — 2026-10-10.** The maintainer did not approve the initial +neutral-500 proposal and requested a fuller design preserving exact 429/503. +The proposal below is being revised; it authorizes no implementation. + **Date:** 2026-10-10 **Deciders:** @cemil (approval pending) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index ed82e76f..7e76dde4 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4754,11 +4754,12 @@ exact-head CI evidence. #### P02d-6 systematic-review decision package (2026-10-10) -**Proposed; maintainer approval pending.** The two external reports at `ed6f81e` -are review data, not new authority. Rechecks confirm guidance/status drift, -ordinary static source-fence gaps, missing dirty/clean response controls and a -second-bootstrap failure-classification gap. No accepted behavior is changed by -this preparation record. +**Maintainer response recorded — 2026-10-10.** The two external reports at +`ed6f81e` are review data, not new authority. Rechecks confirm guidance/status +drift, ordinary static source-fence gaps, missing dirty/clean response controls +and a second-bootstrap failure-classification gap. The exact-response-owner +design remains Proposed; the Open Graph and security maintenance parts below +are separately approved. The existing G40 content-error HTTP 200/noindex and cursor-free canonical/noindex policies are accepted choices, not implementation drift. Google's @@ -4774,25 +4775,25 @@ it does not hide anchor UUIDs from visitors. Scope/eligibility remains independe enforced. Encryption, MAC rotation or server-side cursor state would need a new decision and is not a correction to this PR. -##### Decisions requested together - -1. **Second bootstrap:** approve proposed - [ADR-0055](../decisions/0055-public-renderer-bootstrap-failures.md). Actual - missing/denied admission remains 404; temporary RSC bootstrap failures use a - sanitized pre-shell neutral framework 500, with no Education read or tenant - theme. Middleware keeps real 404/429/503 and bounded Retry-After. Exact RSC - 429/503 requires a larger response-owner design; it is not promised here. -2. **Open Graph locale:** emit `language_TERRITORY` only when the admitted tag has - an explicit two-letter territory and no script/variant information would be - silently discarded. Omit unrepresentable locale/alternate properties rather +##### Maintainer decisions (2026-10-10) + +1. **Second bootstrap — design requested; not accepted.** The maintainer did not + approve the neutral 500 proposal and requested a fuller response-owner design + preserving exact 429/503. Revise proposed + [ADR-0055](../decisions/0055-public-renderer-bootstrap-failures.md) for review + before dependent implementation. No temporary failure contract changes yet. +2. **Open Graph locale — approved.** Emit `language_TERRITORY` only when the + admitted tag has an explicit two-letter territory and no script/variant + information would be silently discarded. Omit unrepresentable locale/alternate properties rather than inventing a territory. `tr-TR` becomes `tr_TR`; `en`, `tr`, `zh-Hant` and numeric-region tags are omitted. Document language, route and hreflang keep their exact BCP-47 tags. The [protocol](https://ogp.me/) makes these properties - optional; omission can leave consumers applying their own default. Approval - adds this bounded G40 projection and qualifies Standards 08's blanket rule. -3. **Security maintenance:** pin Next and eslint-config-next to 15.5.27, disable - the unused image optimizer, inspect/patch affected transitive versions within - compatible bounds, and re-prove native ingress and actual production HTML/RSC. + optional; omission can leave consumers applying their own default. This adds + the bounded G40 projection and qualifies Standards 08's blanket rule. +3. **Security maintenance — approved.** Pin Next and eslint-config-next to + 15.5.27, disable the unused image optimizer, inspect/patch affected transitive + versions within compatible bounds, and re-prove native ingress and actual + production HTML/RSC. The fresh audit reports 46 findings (2 critical, 28 high, 15 moderate, 1 low), not 46 reachable public flaws. The AVIF [advisory](https://github.com/advisories/GHSA-2xp9-vwfh-vxw4) affects the current @@ -4801,8 +4802,8 @@ decision and is not a correction to this PR. compatible 15.x backport. A Next bump alone does not clear transitive findings; record each remaining advisory's actual input path and maintenance disposition. -The proposed ADR-0054 maintenance amendment, appended only after approval and -passing replacement proofs, reads: +The approved ADR-0054 maintenance note is appended only after passing replacement +proofs. Its exact text is: > **Amendment 7 — Next security maintenance (2026-10-10).** The current verified > runtime pin is Next 15.5.27 with matching eslint-config-next. The signed raw @@ -4822,12 +4823,15 @@ not establish air-gapped packaging support. formatter-path tests and applicable accessibility controls. Align decimal limit parsing and closed cursor-error classification with existing contracts. These corrections need no new behavior decision. -2. After approval, implement ADR-0055 and the G40 Open Graph projection; inject - second-bootstrap faults through the actual production fixture. Preserve - provenance, read-only/RLS, request counts and the approved content-error policy. -3. After maintenance-note approval, apply compatible security patches and close - unused image optimization; re-run affected full/native/real-API suites, audit, - formatting and local link/fragment checks. Record fresh exact-head PR CI. +2. Implement the approved G40 Open Graph projection. Separately prepare the fuller + exact-bootstrap response-owner design; its implementation waits for approval. + The eventual approved design must prove second-bootstrap faults through the + actual production fixture without weakening provenance, read-only/RLS or the + admitted content-error policy. +3. Apply the approved compatible security patches and close unused image + optimization; re-run affected full/native/real-API suites, audit, formatting + and local link/fragment checks. Record fresh exact-head PR CI. Append the + approved maintenance note only after its replacement proofs pass. Each group is committed and receives two fresh independent review rounds, with verified findings corrected before the next group. All work stays on development. @@ -4838,8 +4842,9 @@ work. This package makes no new passing runtime or merge-readiness claim. ##### Remediation group 1 — existing-contract corrections -**Implemented; round 1 findings corrected; round 2 pending — 2026-10-10.** No -proposed decision above is implemented by this group. ADR-0055 remains Proposed. +**Implemented; both review rounds complete; focused fix verification pending — +2026-10-10.** No approval-gated decision above is implemented by this group. +ADR-0055 remains Proposed. The Step 1 status and current i18n/theme carriers now match delivery. Skills use locale-bearing public folders, the shared resource loader, supported Next page @@ -4904,6 +4909,16 @@ a valid palette and checks the specific failure in both entitlement directions. Focused validation passes all 263 boundary cases and 36 response-helper cases; web typecheck passes. Neither review claims a Docker/full-suite/manual rerun. +**Round 2 — fresh independent reviews of `ed6f81e..69b382a`.** GPT-6.1-sol +(high) reviewed runtime/source boundaries; GPT-6-astra (high) reviewed proofs and +documentation. The proof/documentation review approved. Two further bounded +retention gaps were verified: resolved local object member factories and +destructured closure captures. Seven additional dirty controls fail before the +fix, while seven request-local or pure-value controls remain clean. The fix follows +literal members and selected named/indexed binding values, without treating an +unrelated sibling collection as a captured value. All 277 boundary cases and web +typecheck pass. Focused independent verification of this final correction follows. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/frontend/apps/web/src/test/public-boundaries.test.ts b/frontend/apps/web/src/test/public-boundaries.test.ts index 681c63d0..ae7f37e1 100644 --- a/frontend/apps/web/src/test/public-boundaries.test.ts +++ b/frontend/apps/web/src/test/public-boundaries.test.ts @@ -1181,6 +1181,34 @@ describe('remediation public representation lifetimes', () => { 'const create = () => new Set(); const alias = create; const retained = alias();', ], ['IIFE Map', 'const retained = (() => new Map())();'], + [ + 'object method factory', + 'const factories = { create() { const entries = new Map(); return (key) => entries.get(key); } }; const retained = factories.create();', + ], + [ + 'arrow property factory', + 'const factories = { create: () => new Map() }; const retained = factories.create();', + ], + [ + 'aliased computed member factory', + 'const factories = { create: () => new Map() }; const owner = factories; const key = "create"; const create = owner[key]; const retained = create();', + ], + [ + 'object binding closure capture', + 'function create() { const { entries } = { entries: new Map() }; return (key) => entries.get(key); } const retained = create();', + ], + [ + 'array binding closure capture', + 'function create() { const [entries] = [new Map()]; return (key) => entries.get(key); } const retained = create();', + ], + [ + 'nested aliased binding closure capture', + 'function create() { const holder = { nested: { entries: new Map(), prefix: "ok" } }; const alias = holder; const { nested: { entries: values } } = alias; return (key) => values.get(key); } const retained = create();', + ], + [ + 'mutable primitive binding closure capture', + 'function create() { let { value } = { value: "initial" }; return (next) => value = next; } const retained = create();', + ], [ 'nested factory', 'function inner() { return new Map(); } function outer() { return inner(); } const retained = outer();', @@ -1285,6 +1313,34 @@ describe('remediation public representation lifetimes', () => { 'request factory result', 'function create() { const entries = new Map(); return (key) => entries.get(key); } export function read() { return create(); }', ], + [ + 'request object method factory', + 'const factories = { create() { const entries = new Map(); return (key) => entries.get(key); } }; export function read() { return factories.create(); }', + ], + [ + 'request arrow property factory', + 'const factories = { create: () => new Map() }; export function read() { return factories["create"](); }', + ], + [ + 'request binding closure capture', + 'function create() { const { entries } = { entries: new Map() }; return (key) => entries.get(key); } export function read() { return create(); }', + ], + [ + 'pure object method factory', + 'const factories = { create() { const entries = new Set([1, 2]); return entries.size; } }; const retained = factories.create();', + ], + [ + 'pure object binding beside collection', + 'function create() { const { prefix } = { prefix: "ok", entries: new Map() }; return () => prefix; } const retained = create();', + ], + [ + 'pure array binding beside collection', + 'function create() { const [prefix] = ["ok", new Map()]; return () => prefix; } const retained = create();', + ], + [ + 'pure nested aliased binding beside collection', + 'function create() { const holder = { nested: { prefix: "ok", entries: new Map() } }; const alias = holder; const { nested: { prefix } } = alias; return () => prefix; } const retained = create();', + ], [ 'request mutable value', 'export async function read() { let value; value = await client.getSite(); return value; }', diff --git a/frontend/apps/web/src/test/public-boundary-analysis.ts b/frontend/apps/web/src/test/public-boundary-analysis.ts index 6b1c683e..80bb8e48 100644 --- a/frontend/apps/web/src/test/public-boundary-analysis.ts +++ b/frontend/apps/web/src/test/public-boundary-analysis.ts @@ -933,7 +933,12 @@ function inside(node: ts.Node, ancestor: ts.Node): boolean { return false; } -function mutableBinding(declaration: ts.VariableDeclaration): boolean { +function mutableBinding(declaration: ts.VariableDeclaration | ts.BindingElement): boolean { + while (ts.isBindingElement(declaration)) { + const owner = declaration.parent.parent; + if (!ts.isVariableDeclaration(owner) && !ts.isBindingElement(owner)) return false; + declaration = owner; + } return ( ts.isVariableDeclarationList(declaration.parent) && !(declaration.parent.flags & ts.NodeFlags.Const) @@ -948,24 +953,108 @@ function staticMember(node: ts.Node): boolean { ); } +/** Select only the captured binding's value, never its unrelated container siblings. */ +function bindingInitializer( + binding: ts.BindingElement, + graph: SourceGraph, + visited = new Set(), +): ts.Node | undefined { + if (binding.dotDotDotToken || visited.has(binding)) return undefined; + visited.add(binding); + const owner = binding.parent.parent; + const source = ts.isVariableDeclaration(owner) + ? owner.initializer + : ts.isBindingElement(owner) + ? bindingInitializer(owner, graph, new Set(visited)) + : undefined; + if (!source) return binding.initializer; + const key = ts.isArrayBindingPattern(binding.parent) + ? String(binding.parent.elements.indexOf(binding)) + : binding.propertyName + ? propertyName(binding.propertyName, graph.checker) + : ts.isIdentifier(binding.name) + ? binding.name.text + : undefined; + return key === undefined + ? binding.initializer + : (selectedInitializer(source, key, graph, visited) ?? binding.initializer); +} + +function selectedInitializer( + node: ts.Node, + key: string, + graph: SourceGraph, + visited: Set, +): ts.Node | undefined { + node = localInitializer(node, graph, visited); + if (ts.isObjectLiteralExpression(node)) { + const property = [...node.properties] + .reverse() + .find( + (member) => + !ts.isSpreadAssignment(member) && propertyName(member.name, graph.checker) === key, + ); + if (property && ts.isPropertyAssignment(property)) return property.initializer; + if (property && ts.isShorthandPropertyAssignment(property)) return property.name; + if (property && ts.isMethodDeclaration(property)) return property; + } + if (ts.isArrayLiteralExpression(node) && /^(?:0|[1-9][0-9]*)$/.test(key)) { + const index = Number(key); + if (!node.elements.slice(0, index + 1).some(ts.isSpreadElement)) return node.elements[index]; + } + return undefined; +} + +/** Resolve declaration aliases and literal object/array selections only. */ +function localInitializer( + node: ts.Node, + graph: SourceGraph, + visited = new Set(), +): ts.Node { + node = unwrapped(node); + if (visited.has(node)) return node; + visited.add(node); + const receiver = memberReceiver(node); + const key = memberName(node, graph.checker); + if (receiver && key !== undefined) { + const selected = selectedInitializer(receiver, key, graph, new Set(visited)); + if (selected) return localInitializer(selected, graph, visited); + } + for (const declaration of declarations(node, graph.checker)) { + const initializer = ts.isVariableDeclaration(declaration) + ? declaration.initializer + : ts.isBindingElement(declaration) + ? bindingInitializer(declaration, graph, new Set(visited)) + : undefined; + if (initializer) return localInitializer(initializer, graph, visited); + } + return node; +} + /** Local calls and returned closures only; no general control-flow or heap interpretation. */ function localFunctions( node: ts.Node, graph: SourceGraph, visited = new Set(), ): ts.FunctionLikeDeclaration[] { - node = unwrapped(node); + node = localInitializer(node, graph); if (visited.has(node)) return []; visited.add(node); - if (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node)) + if ( + ts.isArrowFunction(node) || + ts.isFunctionExpression(node) || + ts.isFunctionDeclaration(node) || + ts.isMethodDeclaration(node) + ) return [node]; const bindings = ts.isPropertyAccessExpression(node) ? (graph.checker.getSymbolAtLocation(node.name)?.declarations ?? []) : declarations(node, graph.checker); return bindings.flatMap((declaration) => - ts.isFunctionDeclaration(declaration) + ts.isFunctionDeclaration(declaration) || ts.isMethodDeclaration(declaration) ? [declaration] - : ts.isVariableDeclaration(declaration) && declaration.initializer + : (ts.isVariableDeclaration(declaration) || ts.isPropertyAssignment(declaration)) && + declaration.initializer ? localFunctions(declaration.initializer, graph, visited) : [], ); @@ -990,6 +1079,7 @@ function retainedState( return declarations(node, graph.checker).some( (declaration) => (ts.isVariableDeclaration(declaration) && next(declaration.initializer)) || + (ts.isBindingElement(declaration) && next(bindingInitializer(declaration, graph))) || (ts.isFunctionDeclaration(declaration) && next(declaration)), ); if (ts.isObjectLiteralExpression(node)) @@ -1018,8 +1108,15 @@ function retainedState( walk(body, (reference) => { if (!ts.isIdentifier(reference)) return; for (const declaration of declarations(reference, graph.checker)) { - if (!ts.isVariableDeclaration(declaration) || inside(declaration, closure)) continue; - const initializer = declaration.initializer && unwrapped(declaration.initializer); + if ( + (!ts.isVariableDeclaration(declaration) && !ts.isBindingElement(declaration)) || + inside(declaration, closure) + ) + continue; + const value = ts.isBindingElement(declaration) + ? bindingInitializer(declaration, graph) + : declaration.initializer; + const initializer = value && localInitializer(value, graph); if ( mutableBinding(declaration) || (initializer && From ca03942d20dcc9afeb3a322e4b1dd8d6e2fb87fc Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 11:55:34 +0300 Subject: [PATCH 28/58] docs(decisions): propose middleware-owned public admission Replace the unapproved response gate with a concrete single-read design. Keep exact bootstrap refusals with their pre-render response owner and state the request-local snapshot, call-count and proof approval boundary. ADR: 0055 --- docs/architecture/14-frontend-architecture.md | 8 +- ...0055-public-renderer-bootstrap-failures.md | 309 ++++++++++++------ docs/decisions/README.md | 2 +- docs/roadmap/phase-02d-walking-skeleton.md | 37 ++- 4 files changed, 250 insertions(+), 106 deletions(-) diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index debb5086..ade2188f 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -41,10 +41,12 @@ theme, freshness and content-fault product proofs. The owns validation, completed reviews and passing manual accessibility closeout. [ADR-0055](../decisions/0055-public-renderer-bootstrap-failures.md) is Proposed: -it separates temporary second-bootstrap failures from missing admission. The +it replaces the duplicate RSC bootstrap with one middleware-owned live read and +an immutable same-request snapshot, preserving exact bootstrap 429/503 before +rendering. That ownership, DTO handoff and call-count change await approval. The [review decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-systematic-review-decision-package-2026-10-10) -also proposes Open Graph projection and a bounded Next security maintenance note. -These proposals change no current Accepted contract before maintainer approval. +separately records approval for Open Graph projection and bounded Next security +maintenance. ADR-0055 changes no Accepted contract before its own approval. > **P02d-4 Accepted design — 2026-10-03.** Step 2 delivers site bootstrap; > Steps 3–4 deliver Education and contract/SDK controls; P02d-5 owns the server consumer. diff --git a/docs/decisions/0055-public-renderer-bootstrap-failures.md b/docs/decisions/0055-public-renderer-bootstrap-failures.md index 1d0efb00..66d3268c 100644 --- a/docs/decisions/0055-public-renderer-bootstrap-failures.md +++ b/docs/decisions/0055-public-renderer-bootstrap-failures.md @@ -1,134 +1,253 @@ -# ADR-0055: Distinguish Temporary Public Renderer Bootstrap Failures +# ADR-0055: Own Public Bootstrap Admission in Middleware ## Status Proposed — 2026-10-10. Maintainer approval is pending. -**Review disposition — 2026-10-10.** The maintainer did not approve the initial -neutral-500 proposal and requested a fuller design preserving exact 429/503. -The proposal below is being revised; it authorizes no implementation. - **Date:** 2026-10-10 **Deciders:** @cemil (approval pending) -This proposal changes only P02d-6 G40's second, RSC bootstrap failure contract. -It accepts no gate and authorizes no dependent implementation. Existing Accepted -ADR decisions and the original G40 record remain unchanged until approval. +This replaces the unaccepted neutral-500 and native-response-gate proposals. +The maintainer must approve the single live read, request-local DTO handoff and +changed counts before code. Accepted decisions/history remain unchanged; separate +Open Graph/security-update approval does not accept this admission design. ## Decision Drivers -- Middleware and RSC independently read current site configuration; no shared - representation cache or middleware-to-RSC configuration carrier is permitted. -- An API outage, quota refusal or invalid response after middleware succeeds - currently becomes a not-found outcome in the RSC admission loader. -- A temporary inability to admit a tenant document does not establish that the - requested resource is missing. No Education read or tenant theme is safe then. -- App Router Server Components do not own arbitrary HTTP status/response headers. - An exact 429/503 design would require a different response owner. +- Bootstrap failure needs an HTTP response owner before RSC rendering begins. +- Preserve exact 404/429/503 and bounded Retry-After without intercepting Next's + streaming writes, adding a preflight or relying on arbitrary RSC status control. +- A second failing site read creates a refusal the RSC owner cannot express exactly. +- Keep API-owned tenant authority, authenticated ingress and fresh admission on + every new incoming request; no shared representation or serialized DTO carrier. +- Failed bootstrap permits no Education read or newly emitted tenant theme. +- Make ownership, freshness and quota changes explicit; do not preserve redundant reads. ## Considered Options -1. **Distinguish temporary failure and use a sanitized pre-shell framework 500** - (recommended). Preserve live admission and framework response ownership while - avoiding a false 404. Require an actual production-wire proof before delivery. -2. **Keep every RSC bootstrap failure as 404**. Rejected because temporary - unavailability is incorrectly reported as resource absence. -3. **Render a neutral HTTP 200/noindex fallback after failed bootstrap**. - Rejected here because it extends the accepted content-error compromise to - failed admission and does not provide a temporary-server-error HTTP signal. -4. **Deliver exact 429/503 through a new response-owner design**. A valid larger - alternative, requiring its own concrete design and approval. Do not implement - it through an invented trusted DTO header, quota bypass, extra content - preflight or shared site cache. +1. **One live middleware bootstrap with a request-local validated snapshot** + (recommended candidate). Middleware already owns a `NextResponse` before RSC. + RSC re-verifies provenance and consumes only that incoming request's snapshot. + Shared runtime/context propagation remains a mandatory feasibility gate. +2. **Native response gate around a second RSC read** (rejected). Intercepting + commitment, early hints, compression, backpressure and terminal stream behavior + adds a fragile version-sensitive response adapter to preserve a duplicate read. +3. **Native-owned bootstrap before Next** (viable larger alternative). The native + listener can emit exact refusals before delegation, but must acquire configured + transport/SDK build support and route/locale/redirect ownership currently in + middleware. It still needs a validated request-local handoff to RSC. Prefer this + only through a separate concrete decision if the recommended boundary fails. +4. **RSC exceptions or error components** (rejected). They cannot promise arbitrary + 429/503 and Retry-After; framework error rendering can already have committed 200. +5. **Extra preflight or two serial native reads** (rejected). Another read consumes + quota and creates another race. Retaining a count is not a reason for duplication. +6. **DTO header or shared site cache** (rejected). A serialized carrier adds a new + trust protocol; a shared representation changes freshness and isolation scope. ## Decision -The public renderer distinguishes missing/denied admission from temporary second -bootstrap failure. Missing or denied admission keeps 404. Temporary RSC bootstrap -failure raises only a sanitized pre-shell framework failure and returns a neutral -HTTP 500, without a tenant document, Education read or configuration-derived -branding. Middleware-owned refusals retain their actual 404/429/503 and bounded -Retry-After. This decision does not claim RSC-owned 429/503 or Retry-After. - -Temporary outcomes include non-404 site-read failures, transport/invalid-response -outcomes, configuration exceptions and a site entry reporting 503 after a -successful read. Invalid provenance, the client's verification refusal, API 404 -and entry 404 remain missing/denied admission; they do not become server errors. - -The proposal preserves the approved normal three-call path, five-call followed -missing path, missing-detail 307 to the fixed localized 404, and admitted -content-error HTTP 200/noindex states. It changes no provenance, quota, transport -deadline, byte bound, site-data transfer or request-memo lifetime. +LearnStack performs one live public site bootstrap in its existing Node middleware +HTTP response owner. After verified successful admission, middleware publishes a +bounded, immutable validated site snapshot into a native-created request-local +context. RSC independently re-verifies provenance and consumes that exact request's +snapshot without another site call. Middleware emits bootstrap 404/429/503 before +RSC starts. This proposed replacement changes the accepted duplicate-read and +request-count contracts and requires approval plus production feasibility proof. + +### Native context and middleware admission + +1. The native listener retains method admission, socket capture, carrier stripping + and provenance minting before Next. It creates a fresh `AsyncLocalStorage` + context bound to the captured canonical host, peer, method, signed raw target + and native request lifetime, then delegates through the existing Next handler. +2. Middleware verifies provenance and its existing explicit Next URL projection. + It also requires the active native context to match the verified original + host/peer/method/target. No client header, URL or trace ID looks up a context. +3. Middleware makes one live `getSite()` through the existing configured caller, + then evaluates the existing membership-first route/locale entry policy. + Failure returns its controlled response; redirects retain their current policy. +4. Only a successful continuation publishes the validated snapshot and advances + the native context from pending to ready. Refusal/redirect is terminal and + publishes no site snapshot. Missing, disposed, mismatched or conflicting + context cannot continue into RSC; a context invariant failure returns neutral 503. +5. Middleware retains its downstream header allowlist, including the verified + provenance envelope. The snapshot/context is never serialized into request or + response headers, HTML/Flight payloads, cookies or client assets. + +The native context contains only bounded ownership/binding state, cancellation +capability and the approved site snapshot. It is not a registry: there is no map +keyed by host, path, envelope, client identifier or framework request metadata. +Identical simultaneous requests have distinct native lifetimes and stores. + +### Snapshot and RSC consumption + +The snapshot contains only the validated public Site DTO, not a raw body, Response, +SDK client, provider error or tenant/organization ID. The existing ten-second total +transport deadline and **8 MiB decoded-response ceiling** apply before parsing. +Retain one snapshot, freeze nested values and do not retain the raw response. + +RSC re-verifies the authenticated provenance envelope and checks the active native +context's binding and ready state before consuming the snapshot. The signed raw +request target remains the route authority; Next's transformed metadata does not +select the host, tenant, organization or locale. The snapshot is a rendering value +from the API's host-resolved read, not a tenant-authority grant or an API bypass. +All Education calls still use the verified configured caller and independently +pass live API admission, public eligibility, READ ONLY and RLS enforcement. + +The existing request-local admission/resource loaders remain the shared consumer +for metadata, layout, page and i18n configuration. Their admission loader no longer +calls `getSite()`. Each consumer honors its result before Education or theme use; +layouts alone do not serialize child execution. Content clients carry the native +cancellation signal and retain the existing transport controls. + +A missing, disposed or mismatched RSC snapshot is an internal lifecycle error, +not API 404 or permission to fetch another site or reuse data. Fail closed before +Education/theme with a sanitized framework failure. Arbitrary framework-error +statuses are not guaranteed; production proofs must prevent context failures. + +### Freshness, accounting and response scope + +Every matched tenant-capable HTTP request starts with a new context and live +bootstrap; health/assets retain their exemptions. No site value survives for +another request, host, navigation or HMR render. Consumers share +one admitted snapshot: the second site revalidation is removed. Changes between +middleware and RSC appear on the next request; content API eligibility remains live. + +| Incoming request or followed chain | Public API calls | +|---|---:| +| Completed admitted product document GET | 2: site + shared content operation | +| Completed fixed status/scaffold document GET | 1: site only | +| Bootstrap refusal reaching the API | 1: site attempt, no Education | +| Missing-detail GET followed to fixed localized 404 | 3: original site/content + destination site | + +HEAD, RSC and prefetch have one mandatory site call; content is called only when +Next executes the shared resource loader. Prove their actual counts separately. +Invalid provenance/context rejected before transport makes no API call. A locale/ +entry redirect uses one site call; following it is a new counted request. Admitted +invalid-cursor states may omit content transport as under the existing policy. +Metadata/layout/page/UI reuse cannot add calls. Preserve 60-call visitor and +600-call peer budgets; these remain API-call budgets, not page quotas. + +The exact-status guarantee covers valid execution and bootstrap failures handled +by middleware before RSC. It does not promise 429/503 for arbitrary later framework +errors or rewrite an already streamed status. Content-error HTTP 200/noindex, +cursor SEO and missing-detail 307-to-fixed-404 remain accepted. + +### Refusal and Retry-After policy + +| Bootstrap outcome | Original HTTP response | +|---|---| +| Invalid provenance, client verification refusal, API 404 or entry 404 | Neutral 404 | +| Valid API 429 | Neutral 429; eligible bounded Retry-After | +| API 503 or other non-404/non-429 site failure | Neutral 503; eligible bounded Retry-After | +| Timeout, transport/invalid response, oversized body, configuration failure or entry 503 | Neutral 503; no invented Retry-After | +| Middleware context invariant failure | Neutral 503; no invented Retry-After | +| Client disconnect | Cancel work; no response-delivery guarantee | + +Refusals retain `Cache-Control: no-store`, fixed plain-text content type/copy and +bodyless HEAD. No site snapshot, Education operation or new tenant-themed failure +response follows failed bootstrap. No raw Problem Details, submitted/private value +or provider diagnostic enters response output or retained logs. + +One renderer sanitizer accepts optional parsed Retry-After integer delta-seconds +**0 through 60 inclusive**, only on 429/503 from the closed supported rate-limit/ +unavailable error cases. Preserve eligible values; omit malformed, HTTP-date, +negative and out-of-range values without clamping or inventing a retry duration. +The SDK's current safe-integer parser alone does not implement this proposed bound. + +### Lifecycle, runtime and navigation + +Cancellation reaches bootstrap/content transport, without promising forced +cancellation of arbitrary React work. Native `finish`/`close` or shutdown closes +the store exactly once, releases its snapshot/listeners and prevents late use. +Do not call ALS `disable()` per request or dispose on a delegating promise's return. + +Native startup installs the sole ALS holder as a versioned, non-enumerable +`globalThis[Symbol.for('learnstack.public-admission.v1')]`. Server-only facades +retrieve and validate that instance; bundles never create a fallback. Only native +ingress calls `run()` with a fresh store. This is a same-process Node candidate; +workers, Edge or serverless need a new decision. Prove actual singleton/context +reuse through middleware, RSC and error rendering on Next 15.5.27. Missing context +fails closed; no header/cache/extra-read fallback or Next API guarantee is implied. + +Product navigation retains plain anchors. Stock Next can turn a non-success Flight +response into full-document navigation, causing another genuine request and quota +charge; prove and count that chain separately. A recovered follow-up may differ. +Failed Flight cannot erase the old document's painted theme: the guarantee is no +new tenant-themed failure response, not removal of existing browser DOM. + +Fixed status and studio/portal share admission without new authentication. Preserve +tenant-free health/asset exemptions and missing-asset fallbacks, native GET/HEAD, +production upgrade closure and HMR rules; HMR must not reuse old snapshots. ## Context -At `ed6f81e`, middleware classifies its bootstrap failures, while -`getPublicRequest` collapses both API failure and entry refusal to `null`. -`requirePublicResource` then calls `notFound()` from the root layout. The same -request can therefore pass the first bootstrap and report a false 404 when the -second one is rate limited or unavailable. +At `69b382a`, middleware discards its site read; RSC reads again and collapses +failures to `null`, which `public-resource.ts` turns into `notFound()`. G40 spends +three calls on a normal document and five on a followed missing document. This +proposal replaces that ownership/count choice instead of intercepting streaming. -The existing -[G40 package](../roadmap/phase-02d-walking-skeleton.md#routes-pagination-metadata-and-chrome) -already distinguishes admitted content failures from bootstrap refusals. Approval -adds a dated G40 replacement for the RSC part, rather than rewriting the original -accepted answer or claiming that Server Components can set arbitrary statuses. - -The neutral framework failure may use the framework's generic error document; -this proposal does not promise tenant branding, localized recovery copy or an -arbitrary response status from `error.tsx`. A future change to that response -experience needs a concrete response-owner design before implementation. +Historical source inspection of Next 15.5.18 shows `pipe-readable.js` flushing +headers before body writes, `app-render.js` mapping ordinary errors to 500 and +Node middleware executing through its adapter. Official custom-server/streaming +and Node ALS documentation establish the available primitives, not this shared +context's correctness. Delivery requires production proofs on Next **15.5.27**; +15.5.18 source observations are not runtime evidence for the patched baseline. ## Consequences -### Positive - -- Temporary site-read failure no longer declares a valid resource missing. -- Failed admission cannot load Education content or reuse stale tenant styling. -- Existing provenance, fresh reads and quota accounting remain intact. - -### Negative - -- A second-bootstrap 429 becomes neutral 500, rather than preserving its exact - status and Retry-After. This limitation is explicit and must be tested. -- The neutral failure document is less informative than the admitted translated - content-error views. No complete recovery or localization claim is made. - -### Neutral - -- Admitted content-error and cursor SEO policies retain their earlier approval. -- Public API responses and ADR-0052's API eligibility contract do not change. -- Studio/portal remain admitted scaffolds, with authentication/UI owned by their - existing phases; they inherit the same bootstrap failure classification. +- **Positive:** bootstrap failure has an existing pre-render HTTP owner; one site + call eliminates the second-read failure race and reduces quota use. +- **Negative:** a new validated request-local DTO handoff and cross-bundle runtime + dependency require proof; the second within-request revalidation is removed. +- **Neutral:** no response-write interceptor, new listener, endpoint/schema/SDK wire + change, shared cache or authority change. Stock Flight fallback can add requests. ## Implementation Notes -P02d-6 remediation owns the typed admission result, sanitized pre-shell failure, -carrier reconciliation and production regression. On acceptance, add the dated -G40 replacement and cite this ADR from Standards 07/09 and Frontend Architecture. -Do not modify earlier Accepted ADR bodies or their historical delivery notes. +P02d-6 remediation owns the proposed replacement of G40's duplicate RSC bootstrap, +response ownership and three/five-call contract. Approval explicitly accepts the +request-local DTO handoff and two/three-call replacement. Record bounded dated +supersession/navigation in ADR-0053, ADR-0027 and the G40/current guidance as needed; +retain Accepted bodies and historical delivery records unchanged. -Delivery requires second-bootstrap fault injection for quota, unavailable, -transport and invalid-response outcomes after a successful middleware bootstrap. -Assert the actual original-document HTTP status, no-store behavior, no Education -read, no private diagnostics and no tenant-themed fallback. Include a recovered -next request and existing real missing/denied controls. If the pinned framework -cannot prove pre-shell 500, stop and revisit this proposal; do not silently ship -another status or a streamed 200. +Scope covers native context/lifecycle, runtime facade, middleware publication, +RSC/resource/i18n consumers and proofs. Failed feasibility requires a new decision, +not duplicated reads, bootstrap 500/200, shared data or DTO headers. Native-owned +bootstrap needs its own ownership/build design. ## Architecture Tests -Extend the existing public page-state and isolation proofs after acceptance. -This Proposed document registers no passing test and changes no Implemented -catalogue status. Existing request-local memo and source-boundary controls remain -mandatory. +These are mandatory obligations, not passing-test or Implemented-catalogue claims: + +- Actual Next 15.5.27 production context propagation and same-request snapshot reuse; + missing, duplicated, disposed or mismatched context fails closed before consumers. +- First-wire 404/429/503, no-store, bodyless HEAD and Retry-After boundaries/omissions + for API refusal, transport/timeout, malformed/oversized response and invalid site. +- No Education, new tenant theme, private marker or raw error after failed bootstrap; + planted broken admission/context controls must fail the proof. +- Exact two/one/three-call paths and zero additional site calls across concurrent + metadata/layout/page/UI/error consumers; recovered next request re-reads live data. +- Same/different hosts/locales and identical targets/envelopes under concurrency; + snapshot binding, deep immutability and no cross-request retention or lookup. +- Product/status/scaffold HTML, RSC, prefetch and navigation; separately counted + Flight-to-document fallback; retained content 200/noindex and missing 307-to-404. +- Abort/late publication, normal finish, early close, shutdown and keep-alive next + request; zero active contexts/snapshots after completion and no HMR reuse. +- Invalid host/locale/stamp, stock launcher bypass, exempt asset/health fallback, + native method/upgrade refusal, no build-time bootstrap and retained HMR behavior. ## References - [ADR-0027 — Frontend UI Localization](0027-frontend-i18n.md) +- [ADR-0036 — Tenant Resolution Trusted Inputs](0036-tenant-resolution-trusted-inputs.md) - [ADR-0052 — Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md) - [ADR-0053 — Trusted Public Server Rendering](0053-trusted-public-server-rendering.md) - [ADR-0054 — Bounded Public Renderer Admission](0054-bounded-public-renderer-admission.md) - [P02d-6 review decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-systematic-review-decision-package-2026-10-10) -- [Frontend Error Handling](../standards/09-error-handling.md#public-page-status-and-recovery) -- [Google — HTTP status and network errors](https://developers.google.com/crawling/docs/troubleshooting/http-status-codes) +- [Native launcher](../../frontend/apps/web/scripts/public-server.mjs) +- [Middleware](../../frontend/apps/web/src/middleware.ts) +- [RSC admission loader](../../frontend/apps/web/src/server/public-request.ts) +- [Next 15 custom server](https://nextjs.org/docs/15/app/guides/custom-server) +- [Next 15 streaming status codes](https://nextjs.org/docs/15/app/api-reference/file-conventions/loading#status-codes) +- [Node asynchronous context](https://nodejs.org/api/async_context.html#asynclocalstoragerunstore-callback-args) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index d6269bc7..fcc7ad7c 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -76,7 +76,7 @@ an amendment is not a lifecycle status change. | # | Title | Topic | Target phase / decision point | |---|---|---|---| | 0049 | [Institution Sites and an Optional Course Marketplace](0049-institution-sites-and-course-marketplace.md) | Direction endorsed 2026-10-02; architecture still Proposed, no gate accepted | P02d-2 preparation hold released; remaining contracts before proposed Phase 09a's first consumers | -| 0055 | [Distinguish Temporary Public Renderer Bootstrap Failures](0055-public-renderer-bootstrap-failures.md) | Separate missing/denied admission from temporary second-bootstrap failure; neutral pre-shell 500 proposed | P02d-6 remediation; approval before dependent implementation | +| 0055 | [Own Public Bootstrap Admission in Middleware](0055-public-renderer-bootstrap-failures.md) | One live middleware bootstrap with an immutable same-request snapshot; exact 429/503 and revised call accounting proposed | P02d-6 remediation; approval before dependent implementation | The maintainer endorsed the marketplace direction and accepted ADR-0050/0051 and the P02d-2 package on 2026-10-02. Their rows are now in Active ADRs; ADR-0049 remains diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 7e76dde4..4d9215d9 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4779,13 +4779,20 @@ decision and is not a correction to this PR. 1. **Second bootstrap — design requested; not accepted.** The maintainer did not approve the neutral 500 proposal and requested a fuller response-owner design - preserving exact 429/503. Revise proposed - [ADR-0055](../decisions/0055-public-renderer-bootstrap-failures.md) for review - before dependent implementation. No temporary failure contract changes yet. + preserving exact 429/503. Revised proposed + [ADR-0055](../decisions/0055-public-renderer-bootstrap-failures.md) selects one + live middleware-owned read and an immutable native request-local snapshot for + RSC. This removes the second-read failure and explicitly proposes two normal + document calls / three followed-missing calls in place of three/five. It also + proposes a closed Retry-After bound. These ownership, DTO handoff and accounting + changes await approval; no dependent code or current-contract change is made. + Independent design review rejected response-write interception as fragile; + the revised concrete ALS singleton design has no remaining architecture + finding, subject to actual Next 15.5.27 feasibility and isolation proofs. 2. **Open Graph locale — approved.** Emit `language_TERRITORY` only when the admitted tag has an explicit two-letter territory and no script/variant - information would be silently discarded. Omit unrepresentable locale/alternate properties rather - than inventing a territory. `tr-TR` becomes `tr_TR`; `en`, `tr`, `zh-Hant` and + information would be silently discarded. Omit unrepresentable locale/alternate + properties rather than inventing a territory. `tr-TR` becomes `tr_TR`; `en`, `tr`, `zh-Hant` and numeric-region tags are omitted. Document language, route and hreflang keep their exact BCP-47 tags. The [protocol](https://ogp.me/) makes these properties optional; omission can leave consumers applying their own default. This adds @@ -4842,7 +4849,7 @@ work. This package makes no new passing runtime or merge-readiness claim. ##### Remediation group 1 — existing-contract corrections -**Implemented; both review rounds complete; focused fix verification pending — +**Implemented; both review rounds and focused fix verification complete — 2026-10-10.** No approval-gated decision above is implemented by this group. ADR-0055 remains Proposed. @@ -4917,7 +4924,23 @@ destructured closure captures. Seven additional dirty controls fail before the fix, while seven request-local or pure-value controls remain clean. The fix follows literal members and selected named/indexed binding values, without treating an unrelated sibling collection as a captured value. All 277 boundary cases and web -typecheck pass. Focused independent verification of this final correction follows. +typecheck pass. Fresh independent verification of `69b382a..bdb88bb` approved +without actionable findings and independently passed all 277 boundary cases. +The verifier confirmed the seven dirty cases escape the prior analyzer and the +selected-binding clean controls remain clean. This closes group 1. + + +##### Remediation group 2 — approved Open Graph locale projection + +**Implementation in progress — 2026-10-10.** The approved G40 addendum above +separates Open Graph's optional locale format from BCP-47 identity. Only canonical +language plus an explicit two-letter territory is projected; script, variant, +extension, bare-language and numeric-region tags are omitted. Eligible and enabled +alternates keep their exact URLs and hreflang while unrepresentable Open Graph +alternates are omitted. No additional API reads or territory inference is added. +Unit projections and actual production-document checks cover both conversion and +omission. Validation and two independent review rounds are recorded below after +execution. ADR-0055 remains Proposed and has no dependent code in this group. ### P02d-1 decision pass (2026-09-14) From 75c3e61f11887724e8dd27fb8de0545e21e4bbd7 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 11:56:08 +0300 Subject: [PATCH 29/58] fix(public): project supported Open Graph locales Use only explicitly represented language and territory in Open Graph. Preserve BCP-47 document identity and eligible hreflang URLs, and omit unrepresentable locale properties without guessing a country. ADR: 0027 --- docs/roadmap/phase-02d-walking-skeleton.md | 11 +++- docs/standards/08-localization.md | 7 ++- .../web/scripts/verify-public-rendering.mjs | 9 +++ .../web/src/server/public-metadata.test.ts | 56 ++++++++++++++++--- .../apps/web/src/server/public-metadata.ts | 17 +++++- 5 files changed, 87 insertions(+), 13 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 4d9215d9..92561a98 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4932,15 +4932,20 @@ selected-binding clean controls remain clean. This closes group 1. ##### Remediation group 2 — approved Open Graph locale projection -**Implementation in progress — 2026-10-10.** The approved G40 addendum above +**Implemented; independent reviews pending — 2026-10-10.** The approved G40 addendum above separates Open Graph's optional locale format from BCP-47 identity. Only canonical language plus an explicit two-letter territory is projected; script, variant, extension, bare-language and numeric-region tags are omitted. Eligible and enabled alternates keep their exact URLs and hreflang while unrepresentable Open Graph alternates are omitted. No additional API reads or territory inference is added. Unit projections and actual production-document checks cover both conversion and -omission. Validation and two independent review rounds are recorded below after -execution. ADR-0055 remains Proposed and has no dependent code in this group. +omission. All 61 metadata cases and web typecheck pass. On the concurrently +patched Next 15.5.27 runtime, the guarded frontend suite passes 1,229 cases +(1,169 web / 60 SDK), zero skips/todos. The actual API/PostgreSQL production-render +suite passes all 20 cases, zero skips; its first run exposed an overly narrow +fixture-alternate expectation, corrected before this passing run. The production +build passes. Two independent review rounds follow the implementation commit. +ADR-0055 remains Proposed and has no dependent code in this group. ### P02d-1 decision pass (2026-09-14) diff --git a/docs/standards/08-localization.md b/docs/standards/08-localization.md index c011eb10..f43dbfb2 100644 --- a/docs/standards/08-localization.md +++ b/docs/standards/08-localization.md @@ -212,7 +212,12 @@ applying the fallback chain. - `` set per page. - `hreflang` annotations for every translated public page. - Canonical URL is the requested locale. -- `og:locale` and `og:locale:alternate` set. +- Open Graph locale properties use `language_TERRITORY` only for admitted tags + with an explicit two-letter territory and no script, variant or extension to + discard. Omit unrepresentable values; never guess a territory. HTML language, + URL and hreflang retain BCP-47. The + [approved G40 addendum](../roadmap/phase-02d-walking-skeleton.md#p02d-6-systematic-review-decision-package-2026-10-10) + records this bounded projection and the optional-property tradeoff. ## Formatting diff --git a/frontend/apps/web/scripts/verify-public-rendering.mjs b/frontend/apps/web/scripts/verify-public-rendering.mjs index a4504f23..a77f79a6 100644 --- a/frontend/apps/web/scripts/verify-public-rendering.mjs +++ b/frontend/apps/web/scripts/verify-public-rendering.mjs @@ -367,6 +367,15 @@ function productDocument(response, tenant, locale, heading, path, direction = 'l 'https://' + tenant.host + ':3000' + path, 'Eligible current locale has a canonical hreflang self-reference', ); + assert.equal( + doc.querySelector('meta[property="og:locale"]')?.getAttribute('content') ?? null, + locale === 'tr-TR' ? 'tr_TR' : null, + 'Open Graph retains an explicit territory, never inventing one for bare fixture locales', + ); + for (const alternate of doc.querySelectorAll('meta[property="og:locale:alternate"]')) { + assert.equal(alternate.content, 'tr_TR', 'Only the representable fixture alternate is emitted'); + assert.notEqual(locale, 'tr-TR', 'The current locale cannot also be an Open Graph alternate'); + } assert.ok(doc.body.textContent.includes(tenant.name), 'Live tenant chrome'); const other = configuration.tenants.find((candidate) => candidate.host !== tenant.host); assert.equal(doc.body.textContent.includes(other.name), false, 'No opposite tenant chrome'); diff --git a/frontend/apps/web/src/server/public-metadata.test.ts b/frontend/apps/web/src/server/public-metadata.test.ts index 8075fcc3..8b6539fc 100644 --- a/frontend/apps/web/src/server/public-metadata.test.ts +++ b/frontend/apps/web/src/server/public-metadata.test.ts @@ -87,6 +87,49 @@ function lesson(): Extract { const ui = createPublicTranslator('tr'); describe('public metadata projection', () => { + it.each([ + ['tr-TR', 'tr_TR'], + ['en-GB', 'en_GB'], + ['fil-PH', 'fil_PH'], + ['en', undefined], + ['tr', undefined], + ['zh-Hant', undefined], + ['zh-Hans-CN', undefined], + ['es-419', undefined], + ['sl-SI-rozaj', undefined], + ])('projects Open Graph %s without guessing or discarding subtags', (locale, expected) => { + const resource = { ...catalog(), request: request(`/${locale}/courses`, locale) }; + resource.request.site.enabledLocales = [locale, 'en']; + const metadata = publicMetadata(resource, createPublicTranslator(locale)); + if (expected === undefined) expect(metadata.openGraph).not.toHaveProperty('locale'); + else expect(metadata.openGraph).toHaveProperty('locale', expected); + expect(metadata.openGraph).not.toHaveProperty('alternateLocale'); + expect(metadata.alternates?.canonical).toBe(`https://school.example:3000/${locale}/courses`); + expect(metadata.alternates?.languages).toHaveProperty(locale); + }); + + it('projects only eligible, enabled and representable Open Graph alternates', () => { + const resource = { ...course(), request: request('/tr-TR/courses/temel', 'tr-TR') }; + resource.request.site.enabledLocales = ['tr-TR', 'en-GB', 'en', 'zh-Hans-CN', 'fr-FR']; + resource.data.alternates = [ + { locale: 'en-GB', slug: 'foundation' }, + { locale: 'en', slug: 'foundation' }, + { locale: 'zh-Hans-CN', slug: 'basic' }, + { locale: 'fr-FR', slug: '../private' }, + { locale: 'de-DE', slug: 'grundlagen' }, + ]; + const metadata = publicMetadata(resource, ui); + expect(metadata.openGraph).toMatchObject({ locale: 'tr_TR', alternateLocale: ['en_GB'] }); + expect(metadata.alternates?.languages).toEqual({ + 'tr-TR': 'https://school.example:3000/tr-TR/courses/temel', + 'en-GB': 'https://school.example:3000/en-GB/courses/foundation', + en: 'https://school.example:3000/en/courses/foundation', + 'zh-Hans-CN': 'https://school.example:3000/zh-Hans-CN/courses/basic', + }); + for (const operation of Object.values(resource.request.client)) + expect(operation).not.toHaveBeenCalled(); + }); + it('gives an empty first catalog an indexable localized title and every enabled locale', () => { expect(publicMetadata(catalog(), ui)).toEqual({ title: ui.t('catalog.title'), @@ -105,8 +148,6 @@ describe('public metadata projection', () => { title: ui.t('catalog.title'), siteName: 'School', url: 'https://school.example:3000/tr/courses', - locale: 'tr', - alternateLocale: ['en', 'ar', 'zh-Hans-CN'], }, }); }); @@ -122,7 +163,8 @@ describe('public metadata projection', () => { en: 'https://school.example:3000/en/courses/foundation', }, }); - expect(metadata.openGraph).toMatchObject({ locale: 'tr', alternateLocale: ['en'] }); + expect(metadata.openGraph).not.toHaveProperty('locale'); + expect(metadata.openGraph).not.toHaveProperty('alternateLocale'); expect(JSON.stringify(metadata)).not.toContain('/en/courses/temel'); expect(JSON.stringify(metadata)).not.toContain('/ar/'); }); @@ -197,7 +239,7 @@ describe('public metadata projection', () => { createPublicTranslator('ar'), ); expect(metadata.title).toBe('Courses'); - expect(metadata.openGraph).toMatchObject({ locale: 'ar' }); + expect(metadata.openGraph).not.toHaveProperty('locale'); expect(metadata.alternates?.canonical).toBe('https://school.example:3000/ar/courses'); }); @@ -222,7 +264,7 @@ describe('public metadata projection', () => { en: 'https://school.example:3000/en/courses/foundation/lessons/introduction', }, }, - openGraph: { title: 'Giriş', locale: 'tr', alternateLocale: ['en'] }, + openGraph: { title: 'Giriş' }, }); expect(metadata).not.toHaveProperty('description'); expect(JSON.stringify(metadata)).not.toMatch(/\/en\/courses\/temel|\/ar\//); @@ -248,7 +290,7 @@ describe('public metadata projection', () => { expect(metadata.alternates?.canonical).toBe( 'https://school.example:3000/tr/courses/temel/lessons/giris', ); - expect(metadata.openGraph).toMatchObject({ locale: 'tr' }); + expect(metadata.openGraph).not.toHaveProperty('locale'); expect(JSON.stringify(metadata)).not.toMatch(/private|evil|\?/); }); @@ -357,7 +399,7 @@ describe('metadata rejects unsafe URL inputs', () => { expect(metadata.robots).toEqual({ index: false, follow: false }); }); - it.each([null, 'TR', '../tr', 'fr'])( + it.each([null, 'TR', '../tr', 'fr', 'en-US-u-ca-gregory', 'en-US-x-private'])( 'fails closed for noncanonical or disabled content locale %s', (locale) => { const resource = catalog(); diff --git a/frontend/apps/web/src/server/public-metadata.ts b/frontend/apps/web/src/server/public-metadata.ts index f6d9a702..ed046268 100644 --- a/frontend/apps/web/src/server/public-metadata.ts +++ b/frontend/apps/web/src/server/public-metadata.ts @@ -31,6 +31,12 @@ function publicUrl(host: string, path: string | null): string | null { } } +// Open Graph cannot retain script/variant/numeric-region information. The +// admitted BCP-47 identity stays unchanged everywhere else; never infer a region. +function openGraphLocale(locale: string): string | undefined { + return /^[a-z]{2,3}-[A-Z]{2}$/.test(locale) ? locale.replace('-', '_') : undefined; +} + /** Pure projection of the shared resource read; UI fallback never supplies URL locale. */ export function publicMetadata(resource: Resource, { t }: PublicUi): Metadata { if (resource.kind === 'status') { @@ -98,6 +104,13 @@ export function publicMetadata(resource: Resource, { t }: PublicUi): Metadata { const alternate = publicUrl(context.host, alternatePath); if (alternate !== null) languages[language] = alternate; } + const graphLocale = openGraphLocale(locale); + const graphAlternates = Object.keys(languages) + .filter((language) => language !== locale) + .flatMap((language) => { + const projected = openGraphLocale(language); + return projected === undefined ? [] : [projected]; + }); return { title, ...(description === undefined ? {} : { description }), @@ -116,8 +129,8 @@ export function publicMetadata(resource: Resource, { t }: PublicUi): Metadata { ...(description === undefined ? {} : { description }), url: canonical, siteName: site.displayName, - locale, - alternateLocale: Object.keys(languages).filter((language) => language !== locale), + ...(graphLocale === undefined ? {} : { locale: graphLocale }), + ...(graphAlternates.length === 0 ? {} : { alternateLocale: graphAlternates }), }, }; } From f16d937fb22701555d0242838b50afff7e88a15d Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 12:00:34 +0300 Subject: [PATCH 30/58] test(public): require complete Open Graph locale output Compare complete serialized locale arrays with eligible hreflang values. Prove missing, spurious and duplicate alternates fail the same predicate used by the real production renderer fixture. ADR: 0027 --- docs/roadmap/phase-02d-walking-skeleton.md | 71 +++++++++++++++++++ .../scripts/public-rendering-assertions.d.mts | 1 + .../scripts/public-rendering-assertions.mjs | 19 +++++ .../web/scripts/verify-public-rendering.mjs | 11 +-- .../test/public-rendering-assertions.test.ts | 34 +++++++++ 5 files changed, 127 insertions(+), 9 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 92561a98..5b04c6b8 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4947,6 +4947,77 @@ fixture-alternate expectation, corrected before this passing run. The production build passes. Two independent review rounds follow the implementation commit. ADR-0055 remains Proposed and has no dependent code in this group. + + +**Round 1 — independent review of `ca03942..75c3e61`.** GPT-6.1-sol (high) +independently passed 61 metadata cases and web typecheck. It found no production +projection error, but verified that the runtime alternate loop accepted missing +and ineligible serialized alternates. The corrected shared predicate compares +complete current/alternate arrays against the representable eligible hreflang +set. Six committed dirty/clean controls cover missing, ineligible, duplicate, +malformed and invented territory output. All 103 metadata/response-helper cases +pass. The second fresh review and repeated actual production check follow. + +##### Remediation group 3 — approved security maintenance + +**Implementation and replacement proofs in progress — 2026-10-10.** The approved +paired Next/eslint-config-next 15.5.27 update disables unused image optimization. +Compatible lockfile patches cover PostCSS 8.5.23, sharp 0.35.5, js-yaml 4.3.2, +brace-expansion's existing major lines, nanoid 3.3.18, source-map-js 1.2.2, +browserslist 4.28.7, baseline-browser-mapping 2.11.0 and selector-parser 6.1.3. +Next pins an older PostCSS exactly, so only its dependency edge has a permanent +8.5.23 override. Redocly core 1.34.20 remains within the SDK's existing range and +updates its exact js-yaml dependency. Normal and frozen installation pass. + +Comparable fresh audits distinguish deployment from development tooling: + +| Scope | Before maintenance | After compatible patches | +|---|---|---| +| Production | 46 findings: 2 critical / 28 high / 15 moderate / 1 low | 2 findings: 1 high / 1 moderate | +| Full workspace | 59 findings: 5 critical / 32 high / 21 moderate / 1 low | 11 package/advisory records: 3 critical / 2 high / 6 moderate; 10 distinct GHSAs | + +The original 46-finding report was production scope. The full baseline uses the +pre-maintenance committed manifests/lockfile in a disposable directory. Neither +comparison claims every advisory is a reachable public flaw or a clean audit. +Current residual input paths and dispositions are: + +| Advisory | Current input and disposition | +|---|---| +| [GHSA-vfj7-8cjw-p6xm](https://github.com/micromatch/braces/issues/70) | Braces processes checked-in build/lint/watch globs through Tailwind/Next ESLint. No fixed release exists; retain visibility and reassess upstream or any request-supplied glob use. | +| [GHSA-rj75-hqrm-r3gf](https://github.com/postcss/postcss-selector-parser/security/advisories/GHSA-rj75-hqrm-r3gf) | Selector-parser 6.x processes checked-in CSS through Tailwind/PostCSS Nested. Fix 7.1.6 exceeds their declared range; no request-time selector input is identified. Track an upstream backport or separate compatible parent migration. | +| [GHSA-67mh-4wv8-2f99](https://github.com/evanw/esbuild/security/advisories/GHSA-67mh-4wv8-2f99) | Esbuild's serving feature through Vite/Vitest is not used by repository scripts. Proposed test-toolchain migration below removes the affected branch. | +| [GHSA-4w7w-66w2-5vf9](https://github.com/vitejs/vite/security/advisories/GHSA-4w7w-66w2-5vf9), [GHSA-v6wh-96g9-6wx3](https://github.com/vitejs/vite/security/advisories/GHSA-v6wh-96g9-6wx3), [GHSA-fx2h-pf6j-xcff](https://github.com/vitejs/vite/security/advisories/GHSA-fx2h-pf6j-xcff) | Vite development HTTP/editor/file-serving paths; repository scripts expose no Vite dev-server workflow. Windows-specific prerequisites are not a blanket exemption. Migration below replaces Vite 5. | +| [GHSA-5xrq-8626-4rwp](https://github.com/vitest-dev/vitest/security/advisories/GHSA-5xrq-8626-4rwp), [GHSA-82fw-gwwq-j7x9](https://github.com/vitest-dev/vitest/security/advisories/GHSA-82fw-gwwq-j7x9) | Vitest UI/API/mocker paths; scripts use run mode without UI/API/browser configuration or public mocker exports. These remain reported findings; migration below replaces both Vitest/mocker branches. | +| [GHSA-5gmw-xhrv-c9v3](https://github.com/tinylibs/tinypool/security/advisories/GHSA-5gmw-xhrv-c9v3), [GHSA-85c8-ppgw-ccpr](https://github.com/tinylibs/tinypool/security/advisories/GHSA-85c8-ppgw-ccpr) | Test-worker construction/options need prior prototype pollution and applicable attacker-controlled loading input; none is identified. Migration below removes tinypool rather than forcing an incompatible major into Vitest 2. | + +The P02d-6 remediation owns this triage and the approval request below. The +maintainer owns any remaining backport/parent-migration decision; applicable +Security Standards patch deadlines remain unchanged. No test-toolchain major is +silently included in the already approved compatible-patch package. + +###### Proposed test-toolchain migration — approval pending + +The separately reviewable candidate pins Vitest **4.1.11** and Vite **6.4.3** in +both web and SDK, resolving esbuild **0.25.12** within Vite's range. Vitest 4 removes +tinypool; no tinypool major override is proposed. React plugin 4.7.0, jsdom 26.1.0, +Node 22.23.1, TypeScript 5.6.3 and React 19 remain. Narrow compatible resolution +pins may keep Vitest on Vite 6 and Vite on the selected esbuild patch; Vite 8 must +not appear as an accidental second tree. + +The [official migration](https://v4.vitest.dev/guide/migration) changes pool and +mock-constructor behavior. The existing arrow `Intl.Locale` constructor mock +must become a constructible function/class and still prove its intended error. +Inspect mock cleanup; retain explicit Testing Library cleanup and `globals:false`. +The JSON reporter keeps the counters/status/file fields consumed by the guarded +runner; all its skip/todo/missing/omitted-file negative controls must still fail. + +Before completion: frozen install, resolved graph/advisory audit, guarded full +frontend suite, lint, typecheck and production build; two independent review +rounds and verified fixes. Preserve package/control deadlines and all existing +proof gates. This candidate has not been installed or implemented and accepts no +new architecture/ADR contract. Approval is requested because it exceeds the +compatible-transitive scope already approved. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/frontend/apps/web/scripts/public-rendering-assertions.d.mts b/frontend/apps/web/scripts/public-rendering-assertions.d.mts index 79b82d0c..637502c2 100644 --- a/frontend/apps/web/scripts/public-rendering-assertions.d.mts +++ b/frontend/apps/web/scripts/public-rendering-assertions.d.mts @@ -25,6 +25,7 @@ export function absentFromWholeResponse( ): void; export function visibleDocument(response: Pick): Document; export function documentViewport(doc: Document): void; +export function documentOpenGraphLocales(doc: Document, locale: string): void; export function statusDocument( response: Response, tenant: Tenant, diff --git a/frontend/apps/web/scripts/public-rendering-assertions.mjs b/frontend/apps/web/scripts/public-rendering-assertions.mjs index 45f3bfcd..dee1496c 100644 --- a/frontend/apps/web/scripts/public-rendering-assertions.mjs +++ b/frontend/apps/web/scripts/public-rendering-assertions.mjs @@ -77,6 +77,25 @@ export function documentViewport(doc) { ); } +/** Compare complete serialized OG locale arrays with the eligible hreflang set. */ +export function documentOpenGraphLocales(doc, locale) { + const project = (value) => (/^[a-z]{2,3}-[A-Z]{2}$/.test(value) ? [value.replace('-', '_')] : []); + assert.deepEqual( + [...doc.querySelectorAll('meta[property="og:locale"]')].map((node) => node.content), + project(locale), + 'Exact representable Open Graph locale', + ); + const expected = [...doc.querySelectorAll('link[rel="alternate"][hreflang]')] + .map((node) => node.hreflang) + .filter((language) => language !== locale) + .flatMap(project); + assert.deepEqual( + [...doc.querySelectorAll('meta[property="og:locale:alternate"]')].map((node) => node.content), + expected, + 'Exact eligible Open Graph alternates', + ); +} + export function statusDocument(response, tenant, locale, uiLocale, options) { const { tenants, catalogueSentinels = [], mark = () => {} } = options; mark('status headers'); diff --git a/frontend/apps/web/scripts/verify-public-rendering.mjs b/frontend/apps/web/scripts/verify-public-rendering.mjs index a77f79a6..94977015 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, + documentOpenGraphLocales, productContainment as assertProductContainment, productTheme as assertProductTheme, statusDocument as assertStatusDocument, @@ -367,15 +368,7 @@ function productDocument(response, tenant, locale, heading, path, direction = 'l 'https://' + tenant.host + ':3000' + path, 'Eligible current locale has a canonical hreflang self-reference', ); - assert.equal( - doc.querySelector('meta[property="og:locale"]')?.getAttribute('content') ?? null, - locale === 'tr-TR' ? 'tr_TR' : null, - 'Open Graph retains an explicit territory, never inventing one for bare fixture locales', - ); - for (const alternate of doc.querySelectorAll('meta[property="og:locale:alternate"]')) { - assert.equal(alternate.content, 'tr_TR', 'Only the representable fixture alternate is emitted'); - assert.notEqual(locale, 'tr-TR', 'The current locale cannot also be an Open Graph alternate'); - } + documentOpenGraphLocales(doc, locale); assert.ok(doc.body.textContent.includes(tenant.name), 'Live tenant chrome'); const other = configuration.tenants.find((candidate) => candidate.host !== tenant.host); assert.equal(doc.body.textContent.includes(other.name), false, 'No opposite tenant chrome'); 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 17477bd0..9aa8209d 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, + documentOpenGraphLocales, productContainment, productTheme, statusDocument, @@ -209,3 +210,36 @@ describe('emitted product viewport metadata', () => { ).not.toThrow(); }); }); + +const graphLocaleDoc = (head: string) => + new JSDOM(`${head}`).window.document; +const graphEligible = + ''; +const graphAlternate = ''; + +describe('production Open Graph locale assertion controls', () => { + it.each([ + graphEligible, + '' + graphAlternate, + graphEligible + graphAlternate + graphAlternate, + graphEligible + '', + graphEligible + graphAlternate + '', + ])('rejects missing, ineligible, duplicate or invented locales: %s', (head) => { + expect(() => documentOpenGraphLocales(graphLocaleDoc(head), 'en')).toThrow('Open Graph'); + expect(() => + documentOpenGraphLocales(graphLocaleDoc(graphEligible + graphAlternate), 'en'), + ).not.toThrow(); + }); + + it('requires the explicit current territory and omits unrepresentable alternates', () => { + const links = + graphEligible + ''; + expect(() => documentOpenGraphLocales(graphLocaleDoc(links), 'tr-TR')).toThrow('locale'); + expect(() => + documentOpenGraphLocales( + graphLocaleDoc(links + ''), + 'tr-TR', + ), + ).not.toThrow(); + }); +}); From 3f6f37ffdb4a7a666b00f730611f1b32e66b7050 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 12:03:32 +0300 Subject: [PATCH 31/58] fix(web): patch Next and close unused image optimization Update the paired Next pins and compatible transitive dependencies. Prove image-optimizer closure with valid decoded images and a disposable configuration mutant, preserving native ingress and lifecycle controls. Record replacement runtime evidence and scoped residual dependency triage. Test-toolchain majors and ADR-0055 remain separately proposed. ADR: 0054 --- .../0054-bounded-public-renderer-admission.md | 10 + docs/roadmap/phase-02d-walking-skeleton.md | 29 +- docs/standards/07-frontend-architecture.md | 4 +- frontend/apps/web/next.config.ts | 1 + frontend/apps/web/package.json | 4 +- frontend/apps/web/scripts/verify-ingress.mjs | 100 ++- .../web/src/test/public-boundaries.test.ts | 4 +- frontend/package.json | 5 + frontend/pnpm-lock.yaml | 586 +++++++++--------- 9 files changed, 449 insertions(+), 294 deletions(-) diff --git a/docs/decisions/0054-bounded-public-renderer-admission.md b/docs/decisions/0054-bounded-public-renderer-admission.md index 01e70421..a1e9d000 100644 --- a/docs/decisions/0054-bounded-public-renderer-admission.md +++ b/docs/decisions/0054-bounded-public-renderer-admission.md @@ -325,6 +325,16 @@ owns the final head, merge verification and CI evidence. The accepted decision, metadata and earlier delivery notes remain unchanged. This note records delivery only; P02d-6/7 and Phase 11 retain their named work. + +### Amendment 7 — Next security maintenance (2026-10-10) + +The current verified runtime pin is Next 15.5.27 with matching eslint-config-next. +The signed raw target, full-URL RSC projection and equivalent redirect-query +contract are unchanged. The unused image optimizer is disabled. P02d-6's +[remediation record](../roadmap/phase-02d-walking-skeleton.md#remediation-group-3--approved-security-maintenance) +owns the replacement native/production proofs and dependency triage; historical +15.5.18 observations remain unchanged. + ## References - [ADR-0053 — Trusted Public Server Rendering](0053-trusted-public-server-rendering.md) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 5b04c6b8..89310571 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4929,10 +4929,10 @@ without actionable findings and independently passed all 277 boundary cases. The verifier confirmed the seven dirty cases escape the prior analyzer and the selected-binding clean controls remain clean. This closes group 1. - ##### Remediation group 2 — approved Open Graph locale projection -**Implemented; independent reviews pending — 2026-10-10.** The approved G40 addendum above +**Implemented; both independent reviews complete — 2026-10-10.** The approved +G40 addendum above separates Open Graph's optional locale format from BCP-47 identity. Only canonical language plus an explicit two-letter territory is projected; script, variant, extension, bare-language and numeric-region tags are omitted. Eligible and enabled @@ -4948,7 +4948,6 @@ build passes. Two independent review rounds follow the implementation commit. ADR-0055 remains Proposed and has no dependent code in this group. - **Round 1 — independent review of `ca03942..75c3e61`.** GPT-6.1-sol (high) independently passed 61 metadata cases and web typecheck. It found no production projection error, but verified that the runtime alternate loop accepted missing @@ -4956,11 +4955,17 @@ and ineligible serialized alternates. The corrected shared predicate compares complete current/alternate arrays against the representable eligible hreflang set. Six committed dirty/clean controls cover missing, ineligible, duplicate, malformed and invented territory output. All 103 metadata/response-helper cases -pass. The second fresh review and repeated actual production check follow. +pass. + +**Round 2 — fresh independent review of `ca03942..f16d937`.** GPT-6-astra +(high) approved without verified findings and independently passed the same +103 cases. The actual API/PostgreSQL fixture then passed all 20 cases, zero skips, +with the complete serialized-array predicate active. This closes group 2. ##### Remediation group 3 — approved security maintenance -**Implementation and replacement proofs in progress — 2026-10-10.** The approved +**Implemented; replacement proofs pass; independent reviews pending — +2026-10-10.** The approved paired Next/eslint-config-next 15.5.27 update disables unused image optimization. Compatible lockfile patches cover PostCSS 8.5.23, sharp 0.35.5, js-yaml 4.3.2, brace-expansion's existing major lines, nanoid 3.3.18, source-map-js 1.2.2, @@ -4995,6 +5000,20 @@ maintainer owns any remaining backport/parent-migration decision; applicable Security Standards patch deadlines remain unchanged. No test-toolchain major is silently included in the already approved compatible-patch package. + +Replacement validation passes on Next 15.5.27: frozen install; production build; +workspace lint/typecheck; guarded frontend **1,235 cases** (1,175 web / 60 SDK), +zero skips/todos; and all **20** actual API/PostgreSQL production-render cases, +zero skips. All nine pinned SWC emission cases run against the installed patch. +Native ingress passes TLS, GET/HEAD, query projection/redirects, DEBUG containment, +production upgrade closure and retained HMR. The image proof decodes a valid raw +PNG, sees disabled GET/HEAD 404, enables only a disposable copied configuration, +then decodes optimized 200 and proves the same disabled predicate rejects it. +All **19** native lifecycle/negative controls pass. No shared build is mutated. +The approved ADR-0054 maintenance note is appended after these replacement proofs; +historical 15.5.18 observations remain unchanged. Two independent review rounds +follow the implementation commit. No new manual browser/VoiceOver pass is claimed. + ###### Proposed test-toolchain migration — approval pending The separately reviewable candidate pins Vitest **4.1.11** and Vite **6.4.3** in diff --git a/docs/standards/07-frontend-architecture.md b/docs/standards/07-frontend-architecture.md index 66ef0560..0da04ef3 100644 --- a/docs/standards/07-frontend-architecture.md +++ b/docs/standards/07-frontend-architecture.md @@ -126,7 +126,7 @@ evidence belongs to the [delivery record](../roadmap/phase-02d-walking-skeleton. admission decision in their owning Phase 02b BFF/auth or Phase 06 admin work. Disable `poweredByHeader` independently, including framework fallback responses. - Signed raw targets select route/locale identity. Match observed middleware URLs - only against pinned Next 15.5.18's explicit full-URL RSC/`_rsc` projection; do + only against pinned Next 15.5.27's explicit full-URL RSC/`_rsc` projection; do not introduce suffix aliases or select another lesson after normalization. - Redirects retain inert query values, duplicates and ordering; equivalent percent encoding is allowed. Only the verified live host, accepted HTTPS port and local @@ -279,7 +279,7 @@ collections, class-static and global writes. It is bounded source analysis, not an interpreter for reflection, arbitrary evaluation or external packages. Declaration-level `import type` and `export type` edges are erased. Inline type-only specifiers remain conservative -source edges: TypeScript verbatim emission retains them; pinned Next 15.5.18 SWC +source edges: TypeScript verbatim emission retains them; pinned Next 15.5.27 SWC erases them. Separate real compiler controls establish that distinction. An empty production Client Component census is source information; the configured production-build canary supplies independent asset containment evidence. diff --git a/frontend/apps/web/next.config.ts b/frontend/apps/web/next.config.ts index 111a69a5..6ce8758d 100644 --- a/frontend/apps/web/next.config.ts +++ b/frontend/apps/web/next.config.ts @@ -4,6 +4,7 @@ import createNextIntlPlugin from 'next-intl/plugin'; const nextConfig: NextConfig = { reactStrictMode: true, poweredByHeader: false, + images: { unoptimized: true }, transpilePackages: ['@learnstack/ui', '@learnstack/sdk'], skipMiddlewareUrlNormalize: true, skipTrailingSlashRedirect: true, diff --git a/frontend/apps/web/package.json b/frontend/apps/web/package.json index bbfb7bef..3e1c14f5 100644 --- a/frontend/apps/web/package.json +++ b/frontend/apps/web/package.json @@ -17,7 +17,7 @@ "dependencies": { "@learnstack/sdk": "workspace:*", "@learnstack/ui": "workspace:*", - "next": "15.5.18", + "next": "15.5.27", "next-intl": "4.14.9", "react": "19.0.0", "react-dom": "19.0.0", @@ -34,7 +34,7 @@ "@vitejs/plugin-react": "^4.7.0", "autoprefixer": "^10.4.20", "eslint": "^9.16.0", - "eslint-config-next": "15.5.18", + "eslint-config-next": "15.5.27", "jsdom": "^26.1.0", "postcss": "^8.4.49", "tailwindcss": "^3.4.15", diff --git a/frontend/apps/web/scripts/verify-ingress.mjs b/frontend/apps/web/scripts/verify-ingress.mjs index 1e1e9460..bd849e6f 100644 --- a/frontend/apps/web/scripts/verify-ingress.mjs +++ b/frontend/apps/web/scripts/verify-ingress.mjs @@ -8,6 +8,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + rmSync, symlinkSync, unlinkSync, writeFileSync, @@ -37,6 +38,10 @@ let certificate; let wrongCertificate; let api; let WebSocket; +let sharp; +let optimizerPixels; +const optimizerImagePath = '/_next/static/media/ingress-optimizer-proof.png'; +const optimizerPath = '/_next/image?url=' + encodeURIComponent(optimizerImagePath) + '&w=64&q=75'; const secret = randomBytes(32).toString('base64url'); const scanners = []; let verificationFailed = false; @@ -60,6 +65,7 @@ try { await checkpoint('root'); const require = createRequire(join(appRoot, 'package.json')); WebSocket = require('next/dist/compiled/ws'); + sharp = createRequire(require.resolve('next/package.json'))('sharp'); api = owner.ownServer( createServer((request, response) => { bootstrapCalls++; @@ -175,7 +181,21 @@ try { ].join('\n'), { mode: 0o600 }, ); - for (const name of ['.next', '.server', 'node_modules', 'src']) { + // Both the image fixture/cache and the later mutant belong to this copy. + // Never write into the caller's production build through a symlink. + cpSync(join(appRoot, '.next'), join(app, '.next'), { + recursive: true, + filter: (path) => path !== join(appRoot, '.next/cache'), + }); + const optimizerImage = await sharp({ + create: { width: 2, height: 2, channels: 4, background: '#216ba5' }, + }) + .png() + .toBuffer(); + optimizerPixels = await sharp(optimizerImage).ensureAlpha().raw().toBuffer(); + mkdirSync(join(app, '.next/static/media'), { recursive: true }); + writeFileSync(join(app, '.next/static/media/ingress-optimizer-proof.png'), optimizerImage); + for (const name of ['.server', 'node_modules', 'src']) { symlinkSync(join(appRoot, name), join(app, name), 'dir'); } for (const name of ['package.json', 'next.config.ts', 'tsconfig.json']) { @@ -385,13 +405,19 @@ function call(tls, port, path, headers = {}, method = 'GET', tlsOptions = {}) { timeout: 2000, }, (response) => { - let body = ''; + const chunks = []; response.on('data', (chunk) => { - body += chunk.toString(); + chunks.push(chunk); }); response.on('end', () => { clearTimeout(deadline); - resolve({ status: response.statusCode, headers: response.headers, body }); + const bytes = Buffer.concat(chunks); + resolve({ + status: response.statusCode, + headers: response.headers, + body: bytes.toString(), + bytes, + }); }); }, ); @@ -405,6 +431,24 @@ function call(tls, port, path, headers = {}, method = 'GET', tlsOptions = {}) { }); } +function assertOptimizerDisabled(response, method) { + assert.equal(response.status, 404, 'Image optimizer must remain disabled'); + if (method === 'HEAD') assert.equal(response.bytes.length, 0, 'Optimizer HEAD must be bodyless'); +} + +async function assertOptimizerImage(response) { + assert.equal(response.status, 200, 'Valid image positive control must succeed'); + assert.equal(response.headers['content-type'], 'image/png'); + // Fully decode the HTTP bytes: a success code or a PNG-like header is not enough. + const decoded = await sharp(response.bytes) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + assert.equal(decoded.info.width, 2); + assert.equal(decoded.info.height, 2); + assert.deepEqual(decoded.data, optimizerPixels, 'Image positive control pixels changed'); +} + async function ready(child, tls, port) { let lastStatus; const deadline = performance.now() + 20000; @@ -491,6 +535,14 @@ async function run() { assert.deepEqual(upgrades, [], 'Production upgrades reached an automatic sink listener'); assert.equal(bootstrapCalls, 0, 'Production upgrades reached bootstrap'); + await assertOptimizerImage( + await call(true, 3000, optimizerImagePath, { Host: 'tenant.example:3000' }), + ); + for (const method of ['GET', 'HEAD']) { + const response = await call(true, 3000, optimizerPath, { Host: 'tenant.example:3000' }, method); + assertOptimizerDisabled(response, method); + } + const buildManifest = JSON.parse( readFileSync(join(appRoot, '.next/build-manifest.json'), 'utf8'), ); @@ -666,10 +718,44 @@ async function run() { } assert.equal(diagnosticsLeaked(), false, 'Private material entered diagnostics'); - // Development gets its own build directory, never the shared production output. await stopTestChild(stock); await stopTestChild(native); - unlinkSync(join(app, '.next')); + // Next 15.5.27 loads next.config.ts for this custom production server; its + // serialized build config contributes only isExperimentalCompile. Mutate the + // copied runtime configuration and restart, without touching shared output. + const configPath = join(app, 'next.config.ts'); + const disabledConfig = readFileSync(configPath, 'utf8'); + const enabledConfig = disabledConfig.replace( + 'images: { unoptimized: true }', + 'images: { unoptimized: false }', + ); + assert.notEqual(enabledConfig, disabledConfig, 'Optimizer configuration mutant was not planted'); + writeFileSync(configPath, enabledConfig); + const optimizerMutant = start([join(app, 'scripts/public-server.mjs')]); + await ready(optimizerMutant, true, 3000); + for (const method of ['GET', 'HEAD']) { + const response = await call(true, 3000, optimizerPath, { Host: 'tenant.example:3000' }, method); + assert.equal(response.status, 200, 'Enabling the optimizer must serve the valid image'); + if (method === 'GET') await assertOptimizerImage(response); + if (method === 'HEAD') assert.equal(response.bytes.length, 0); + assert.throws( + () => assertOptimizerDisabled(response, method), + { + code: 'ERR_ASSERTION', + actual: 200, + expected: 404, + message: /^Image optimizer must remain disabled/, + }, + 'Disabled-optimizer assertion did not reject the enabled configuration', + ); + } + await observe(optimizerMutant); + assert.equal(diagnosticsLeaked(), false, 'Private material entered optimizer diagnostics'); + await stopTestChild(optimizerMutant); + writeFileSync(configPath, disabledConfig); + + // Development gets a fresh owned build directory. + rmSync(join(app, '.next'), { recursive: true, force: true }); // Next's development route discovery does not traverse a symlinked src tree. unlinkSync(join(app, 'src')); cpSync(join(appRoot, 'src'), join(app, 'src'), { recursive: true }); @@ -754,7 +840,7 @@ async function run() { await observe(development); assert.equal(diagnosticsLeaked(), false, 'Private material entered development diagnostics'); console.warn( - 'Native ingress: TLS, GET/HEAD admission, query redirects, production closure, DEBUG containment and development HMR passed.', + 'Native ingress: TLS, GET/HEAD admission, disabled image optimizer with configuration mutant, query redirects, production closure, DEBUG containment and development HMR passed.', ); } diff --git a/frontend/apps/web/src/test/public-boundaries.test.ts b/frontend/apps/web/src/test/public-boundaries.test.ts index ae7f37e1..6428cd8a 100644 --- a/frontend/apps/web/src/test/public-boundaries.test.ts +++ b/frontend/apps/web/src/test/public-boundaries.test.ts @@ -459,9 +459,9 @@ describe('server boundary planted controls', () => { ['mixed reexport', 'export { type Secret, value } from "private-edge";', true], ['side-effect import', 'import "private-edge";', true], ] as const)( - 'pinned Next 15.5.18 production SWC emission: %s', + 'pinned Next 15.5.27 production SWC emission: %s', async (_name, declaration, retained) => { - expect((require('next/package.json') as { version: string }).version).toBe('15.5.18'); + expect((require('next/package.json') as { version: string }).version).toBe('15.5.27'); const filename = join(frontend, probe); const config = ts.readConfigFile( join(frontend, 'packages/config/tsconfig/base.json'), diff --git a/frontend/package.json b/frontend/package.json index 0b5df068..df1964f2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,5 +17,10 @@ "devDependencies": { "typescript": "5.6.3", "prettier": "3.3.3" + }, + "pnpm": { + "overrides": { + "next@15.5.27>postcss": "8.5.23" + } } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 6c91d342..a40e3dec 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + next@15.5.27>postcss: 8.5.23 + importers: .: @@ -24,11 +27,11 @@ importers: specifier: workspace:* version: link:../../packages/ui next: - specifier: 15.5.18 - version: 15.5.18(@babel/core@7.29.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + 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) next-intl: specifier: 4.14.9 - version: 4.14.9(@types/react@19.2.14)(next@15.5.18(@babel/core@7.29.7)(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)(@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 @@ -65,19 +68,19 @@ importers: version: 4.7.0(vite@5.4.21(@types/node@22.19.19)) autoprefixer: specifier: ^10.4.20 - version: 10.5.0(postcss@8.5.15) + version: 10.5.0(postcss@8.5.23) eslint: specifier: ^9.16.0 version: 9.39.4(jiti@1.21.7) eslint-config-next: - specifier: 15.5.18 - version: 15.5.18(eslint@9.39.4(jiti@1.21.7))(typescript@5.6.3) + specifier: 15.5.27 + version: 15.5.27(eslint@9.39.4(jiti@1.21.7))(typescript@5.6.3) jsdom: specifier: ^26.1.0 version: 26.1.0 postcss: specifier: ^8.4.49 - version: 8.5.15 + version: 8.5.23 tailwindcss: specifier: ^3.4.15 version: 3.4.19 @@ -297,6 +300,9 @@ packages: '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -518,136 +524,145 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} + engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} + engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} + engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} + engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} + engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} + engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -673,56 +688,56 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@next/env@15.5.18': - resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==} + '@next/env@15.5.27': + resolution: {integrity: sha512-WC5hvVqiuFOcRyz6gKPeJkiVvSMf3scNeAV49CdWtVqxmUlzh8UKR6YHt9yiNkh8LngtzqSlVz07/Ma3ZAzchA==} - '@next/eslint-plugin-next@15.5.18': - resolution: {integrity: sha512-w4MYq8M26a8PNrfto0JosLf5/3ssln1rsyP96g2DkC8uFVymStM5DLSz5ElxxrPRg2XnTMnFo3kREFlhYvxhWw==} + '@next/eslint-plugin-next@15.5.27': + resolution: {integrity: sha512-a94xlzvOLaULh4Fzcvj9B2hwIOtPHJW75gghkXiJ8s1Jk0eo00UknrDVhx04Y+8ARu9ds13vy/jHg5u/PdVGqw==} - '@next/swc-darwin-arm64@15.5.18': - resolution: {integrity: sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ==} + '@next/swc-darwin-arm64@15.5.27': + resolution: {integrity: sha512-fBA9QurmK4+uj4wNhmRTuO5zMVRaBHCvp6H93zTG/5i6Yi6TewbvbesXB8YQNj8T99Cwrow87hHiFV6wL/hyMA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.18': - resolution: {integrity: sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og==} + '@next/swc-darwin-x64@15.5.27': + resolution: {integrity: sha512-FGZwJwry8qZNHxD/nfwrCwCHHrqx6epqItkkxbYcBQEQ2uw5S43bY83fQB5zbAsZE7TELMjoYkbhsQ4oKM7wwg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.18': - resolution: {integrity: sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw==} + '@next/swc-linux-arm64-gnu@15.5.27': + resolution: {integrity: sha512-GQulAaJYNF1RYuatlsw9/uzbKz4if2V7Ic9nVO+P0ljYPm4yBRTFIl6g5YkKTw40lMLbpy9edLoH44g3jSHMYA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.5.18': - resolution: {integrity: sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==} + '@next/swc-linux-arm64-musl@15.5.27': + resolution: {integrity: sha512-URFFsUmu8Pi0/1qdnUI7tx7keZw1Mt5zHfrIX1RfDG9Nih8DYxK54WZsDJUX2ZTlii1jgotL7qcbwKnSAES6xQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.5.18': - resolution: {integrity: sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==} + '@next/swc-linux-x64-gnu@15.5.27': + resolution: {integrity: sha512-aJp74z7uvU5jQhyH7lyk+4qSRcTdKbKT9NQE3jiO9kWYkoHZjKi0zzHDbCoZ+v2sJDEiJ/z58R0UlWMGWNF3HA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.5.18': - resolution: {integrity: sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==} + '@next/swc-linux-x64-musl@15.5.27': + resolution: {integrity: sha512-ILl2bQ8MbpvyMpY8AXljb3BXs0MJSC8HXZ6QV5AWpVS7ZELqNPtrHvhUk+SNarvjvICXX8bCS287TCi+gUFVYg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.5.18': - resolution: {integrity: sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==} + '@next/swc-win32-arm64-msvc@15.5.27': + resolution: {integrity: sha512-1MB9Rh5y8TOPN2x7naNTJNkCZQav9c70AI7EBYXxsEewwOffC4DjjwBwKf20NBvcA+u+5jDv7nVmHxunlP2d7Q==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.18': - resolution: {integrity: sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg==} + '@next/swc-win32-x64-msvc@15.5.27': + resolution: {integrity: sha512-XtYgpGOswgnyBWXzOEG6q9RhvnSw0CEZ0MbWqCsl5PISgt9EzvJvNW5mU5n1PsphX/lnamzxjgQXH2GUJL8nqg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -825,8 +840,8 @@ packages: '@redocly/config@0.22.0': resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} - '@redocly/openapi-core@1.34.19': - resolution: {integrity: sha512-o/0VgsBXgwcY1lyeqcVtSGdTQAPnVggo0fbFVPlxl5XVDKUcVH0OLRqt3CbkwByT5FU305E0iE0O7MzThjDblw==} + '@redocly/openapi-core@1.34.20': + resolution: {integrity: sha512-ypeBZ/6BKXR9+7/TtbKhbl4UgD7raHhPS12oknlKno2A8+lnFkxIwiE/Aklu6L2cd/ioH+fCWuMxi9/p3EyAPw==} engines: {node: '>=18.17.0', npm: '>=9.5.0'} '@rolldown/pluginutils@1.0.0-beta.27': @@ -1436,8 +1451,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.31: - resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==} + baseline-browser-mapping@2.11.0: + resolution: {integrity: sha512-oCu2wfipvX3AePSgmOuKkIywOu+8n9psz7hXYmk56ghpu3+7KzNIBopaOs4c9BrtdnTtW30unG9GTfHo7EwERQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -1445,22 +1460,22 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - brace-expansion@1.1.14: - resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} - brace-expansion@2.1.4: - resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1491,6 +1506,9 @@ packages: caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + caniuse-lite@1.0.30001815: + resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -1638,8 +1656,8 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - electron-to-chromium@1.5.359: - resolution: {integrity: sha512-8lPELWuYZIWk7NDvCNthtmMw/7Q5Wu25NpM4djFMHBmk8DubPAtL4YTOp7ou0e7HyJtwkVlWv8XMLURnrtgJQw==} + electron-to-chromium@1.5.452: + resolution: {integrity: sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==} emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -1696,8 +1714,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-next@15.5.18: - resolution: {integrity: sha512-HuoJU6uUPD00eyiud78IBnT4HLhztFj2V+ild2Uon5ZUrYZKe0Olu2QRD99e9IgL4/H1eg5Onka3BsfRW2U0Xw==} + eslint-config-next@15.5.27: + resolution: {integrity: sha512-SlpZ5Ch1bM+GNHxahIoCy5OVgbpIHzHoGQDjHo6vEhxvYMukWYVo++AEh9RWYaIxH1i38PO7ArzSdmTruswOew==} peerDependencies: eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 typescript: '>=3.3.1' @@ -2151,12 +2169,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - js-yaml@4.3.1: - resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true jsdom@26.1.0: @@ -2282,8 +2296,8 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2312,8 +2326,8 @@ packages: '@types/react': optional: true - next@15.5.18: - resolution: {integrity: sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==} + next@15.5.27: + resolution: {integrity: sha512-F82CrlPZ8GRxBH9RIIoR3qU1t5RT3dv1K8moe8SjlDEquiFipvEI13R1Om90TaUOEwShkWCMlg8+gvTNzfMhvg==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -2340,8 +2354,9 @@ packages: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} - node-releases@2.0.44: - resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==} + node-releases@2.0.58: + resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} + engines: {node: '>=18'} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} @@ -2503,19 +2518,15 @@ packages: peerDependencies: postcss: ^8.2.14 - postcss-selector-parser@6.1.2: - resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + postcss-selector-parser@6.1.3: + resolution: {integrity: sha512-cDoO18VWCIWRsSaws3C23b0HXRxlUknttfdNcbXnf3IGHAPRNLlXPHc6dYiatOkxW0W4uZh524Plaaw5a7WEtQ==} engines: {node: '>=4'} postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} - - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -2646,6 +2657,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} @@ -2661,9 +2677,14 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -2692,8 +2713,8 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} stable-hash@0.0.5: @@ -3107,7 +3128,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.2 + browserslist: 4.28.7 lru-cache: 5.1.1 semver: 6.3.1 @@ -3223,6 +3244,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 @@ -3328,7 +3354,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3380,98 +3406,108 @@ snapshots: '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.34.5': + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true - '@img/sharp-darwin-x64@0.34.5': + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@img/sharp-libvips-darwin-x64@1.2.4': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm64@1.2.4': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm@1.2.4': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.4': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.2.4': + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true - '@img/sharp-libvips-linux-s390x@1.2.4': + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true - '@img/sharp-libvips-linux-x64@1.2.4': + '@img/sharp-libvips-linux-x64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.4': + '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true - '@img/sharp-linux-arm64@0.34.5': + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true - '@img/sharp-linux-arm@0.34.5': + '@img/sharp-linux-arm@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.3.4 optional: true - '@img/sharp-linux-ppc64@0.34.5': + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true - '@img/sharp-linux-riscv64@0.34.5': + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true - '@img/sharp-linux-s390x@0.34.5': + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true - '@img/sharp-linux-x64@0.34.5': + '@img/sharp-linux-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.3.4 optional: true - '@img/sharp-linuxmusl-arm64@0.34.5': + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true - '@img/sharp-linuxmusl-x64@0.34.5': + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true - '@img/sharp-wasm32@0.34.5': + '@img/sharp-wasm32@0.35.5': dependencies: - '@emnapi/runtime': 1.10.0 + '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-win32-arm64@0.34.5': + '@img/sharp-webcontainers-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-win32-ia32@0.34.5': + '@img/sharp-win32-arm64@0.35.5': optional: true - '@img/sharp-win32-x64@0.34.5': + '@img/sharp-win32-ia32@0.35.5': + optional: true + + '@img/sharp-win32-x64@0.35.5': optional: true '@jridgewell/gen-mapping@0.3.13': @@ -3500,34 +3536,34 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true - '@next/env@15.5.18': {} + '@next/env@15.5.27': {} - '@next/eslint-plugin-next@15.5.18': + '@next/eslint-plugin-next@15.5.27': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@15.5.18': + '@next/swc-darwin-arm64@15.5.27': optional: true - '@next/swc-darwin-x64@15.5.18': + '@next/swc-darwin-x64@15.5.27': optional: true - '@next/swc-linux-arm64-gnu@15.5.18': + '@next/swc-linux-arm64-gnu@15.5.27': optional: true - '@next/swc-linux-arm64-musl@15.5.18': + '@next/swc-linux-arm64-musl@15.5.27': optional: true - '@next/swc-linux-x64-gnu@15.5.18': + '@next/swc-linux-x64-gnu@15.5.27': optional: true - '@next/swc-linux-x64-musl@15.5.18': + '@next/swc-linux-x64-musl@15.5.27': optional: true - '@next/swc-win32-arm64-msvc@15.5.18': + '@next/swc-win32-arm64-msvc@15.5.27': optional: true - '@next/swc-win32-x64-msvc@15.5.18': + '@next/swc-win32-x64-msvc@15.5.27': optional: true '@nodelib/fs.scandir@2.1.5': @@ -3609,14 +3645,14 @@ snapshots: '@redocly/config@0.22.0': {} - '@redocly/openapi-core@1.34.19(supports-color@10.2.2)': + '@redocly/openapi-core@1.34.20(supports-color@10.2.2)': dependencies: '@redocly/ajv': 8.11.2 '@redocly/config': 0.22.0 colorette: 1.4.0 https-proxy-agent: 7.0.6(supports-color@10.2.2) js-levenshtein: 1.1.6 - js-yaml: 4.3.1 + js-yaml: 4.3.2 minimatch: 5.1.9 pluralize: 8.0.0 yaml-ast-parser: 0.0.43 @@ -4170,13 +4206,13 @@ snapshots: async-function@1.0.0: {} - autoprefixer@10.5.0(postcss@8.5.15): + autoprefixer@10.5.0(postcss@8.5.23): dependencies: - browserslist: 4.28.2 + browserslist: 4.28.7 caniuse-lite: 1.0.30001793 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.15 + postcss: 8.5.23 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.7: @@ -4191,20 +4227,20 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.31: {} + baseline-browser-mapping@2.11.0: {} binary-extensions@2.3.0: {} - brace-expansion@1.1.14: + brace-expansion@1.1.21: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.4: + brace-expansion@2.1.7: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.6: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -4212,13 +4248,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.2: + browserslist@4.28.7: dependencies: - baseline-browser-mapping: 2.10.31 - caniuse-lite: 1.0.30001793 - electron-to-chromium: 1.5.359 - node-releases: 2.0.44 - update-browserslist-db: 1.2.3(browserslist@4.28.2) + baseline-browser-mapping: 2.11.0 + caniuse-lite: 1.0.30001815 + electron-to-chromium: 1.5.452 + node-releases: 2.0.58 + update-browserslist-db: 1.2.3(browserslist@4.28.7) cac@6.7.14: {} @@ -4245,6 +4281,8 @@ snapshots: caniuse-lite@1.0.30001793: {} + caniuse-lite@1.0.30001815: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -4384,7 +4422,7 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.359: {} + electron-to-chromium@1.5.452: {} emoji-regex@9.2.2: {} @@ -4523,9 +4561,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@15.5.18(eslint@9.39.4(jiti@1.21.7))(typescript@5.6.3): + eslint-config-next@15.5.27(eslint@9.39.4(jiti@1.21.7))(typescript@5.6.3): dependencies: - '@next/eslint-plugin-next': 15.5.18 + '@next/eslint-plugin-next': 15.5.27 '@rushstack/eslint-patch': 1.16.1 '@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@1.21.7))(typescript@5.6.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.6.3) '@typescript-eslint/parser': 8.59.4(eslint@9.39.4(jiti@1.21.7))(typescript@5.6.3) @@ -5063,11 +5101,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - js-yaml@4.3.1: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -5177,15 +5211,15 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.12 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.14 + brace-expansion: 1.1.21 minimatch@5.1.9: dependencies: - brace-expansion: 2.1.4 + brace-expansion: 2.1.7 minimist@1.2.8: {} @@ -5197,7 +5231,7 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.12: {} + nanoid@3.3.18: {} napi-postinstall@0.3.4: {} @@ -5209,7 +5243,7 @@ snapshots: next-intl-swc-plugin-extractor@4.14.9: {} - next-intl@4.14.9(@types/react@19.2.14)(next@15.5.18(@babel/core@7.29.7)(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)(@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 @@ -5219,7 +5253,7 @@ snapshots: '@swc/core': 1.16.13 icu-minify: 4.14.9 negotiator: 1.1.0 - next: 15.5.18(@babel/core@7.29.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.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-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) @@ -5228,27 +5262,28 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - next@15.5.18(@babel/core@7.29.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.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): dependencies: - '@next/env': 15.5.18 + '@next/env': 15.5.27 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001793 - postcss: 8.4.31 + postcss: 8.5.23 react: 19.0.0 react-dom: 19.0.0(react@19.0.0) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.0.0) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.18 - '@next/swc-darwin-x64': 15.5.18 - '@next/swc-linux-arm64-gnu': 15.5.18 - '@next/swc-linux-arm64-musl': 15.5.18 - '@next/swc-linux-x64-gnu': 15.5.18 - '@next/swc-linux-x64-musl': 15.5.18 - '@next/swc-win32-arm64-msvc': 15.5.18 - '@next/swc-win32-x64-msvc': 15.5.18 - sharp: 0.34.5 + '@next/swc-darwin-arm64': 15.5.27 + '@next/swc-darwin-x64': 15.5.27 + '@next/swc-linux-arm64-gnu': 15.5.27 + '@next/swc-linux-arm64-musl': 15.5.27 + '@next/swc-linux-x64-gnu': 15.5.27 + '@next/swc-linux-x64-musl': 15.5.27 + '@next/swc-win32-arm64-msvc': 15.5.27 + '@next/swc-win32-x64-msvc': 15.5.27 + sharp: 0.35.5(@types/node@22.19.19) transitivePeerDependencies: - '@babel/core' + - '@types/node' - babel-plugin-macros node-addon-api@7.1.1: {} @@ -5260,7 +5295,7 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 - node-releases@2.0.44: {} + node-releases@2.0.58: {} normalize-path@3.0.0: {} @@ -5312,7 +5347,7 @@ snapshots: openapi-typescript@7.13.0(typescript@5.6.3): dependencies: - '@redocly/openapi-core': 1.34.19(supports-color@10.2.2) + '@redocly/openapi-core': 1.34.20(supports-color@10.2.2) ansi-colors: 4.1.3 change-case: 5.4.4 parse-json: 8.3.0 @@ -5383,48 +5418,42 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-import@15.1.0(postcss@8.5.15): + postcss-import@15.1.0(postcss@8.5.23): dependencies: - postcss: 8.5.15 + postcss: 8.5.23 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.12 - postcss-js@4.1.0(postcss@8.5.15): + postcss-js@4.1.0(postcss@8.5.23): dependencies: camelcase-css: 2.0.1 - postcss: 8.5.15 + postcss: 8.5.23 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.23): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 - postcss: 8.5.15 + postcss: 8.5.23 - postcss-nested@6.2.0(postcss@8.5.15): + postcss-nested@6.2.0(postcss@8.5.23): dependencies: - postcss: 8.5.15 - postcss-selector-parser: 6.1.2 + postcss: 8.5.23 + postcss-selector-parser: 6.1.3 - postcss-selector-parser@6.1.2: + postcss-selector-parser@6.1.3: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 postcss-value-parser@4.2.0: {} - postcss@8.4.31: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - postcss@8.5.15: + postcss@8.5.23: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.18 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 prelude-ls@1.2.1: {} @@ -5584,6 +5613,9 @@ snapshots: semver@7.8.0: {} + semver@7.8.5: + optional: true + server-only@0.0.1: {} set-function-length@1.2.2: @@ -5608,36 +5640,38 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 - sharp@0.34.5: + sharp@0.35.5(@types/node@22.19.19): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.8.0 + semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 22.19.19 optional: true shebang-command@2.0.0: @@ -5676,7 +5710,7 @@ snapshots: siginfo@2.0.0: {} - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} stable-hash@0.0.5: {} @@ -5790,12 +5824,12 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.15 - postcss-import: 15.1.0(postcss@8.5.15) - postcss-js: 4.1.0(postcss@8.5.15) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15) - postcss-nested: 6.2.0(postcss@8.5.15) - postcss-selector-parser: 6.1.2 + postcss: 8.5.23 + postcss-import: 15.1.0(postcss@8.5.23) + postcss-js: 4.1.0(postcss@8.5.23) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.23) + postcss-nested: 6.2.0(postcss@8.5.23) + postcss-selector-parser: 6.1.3 resolve: 1.22.12 sucrase: 3.35.1 transitivePeerDependencies: @@ -5933,9 +5967,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.12.1 '@unrs/resolver-binding-win32-x64-msvc': 1.12.1 - update-browserslist-db@1.2.3(browserslist@4.28.2): + update-browserslist-db@1.2.3(browserslist@4.28.7): dependencies: - browserslist: 4.28.2 + browserslist: 4.28.7 escalade: 3.2.0 picocolors: 1.1.1 @@ -5978,7 +6012,7 @@ snapshots: vite@5.4.21(@types/node@22.19.19): dependencies: esbuild: 0.21.5 - postcss: 8.5.15 + postcss: 8.5.23 rollup: 4.60.4 optionalDependencies: '@types/node': 22.19.19 From 05a4313af7dc33ce6313c8ee157f7e62a8971d78 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 12:09:50 +0300 Subject: [PATCH 32/58] docs(roadmap): record first security remediation reviews Record independent dependency and runtime-proof approvals without claiming another runtime run. Keep the exact-status and test-toolchain decisions pending, and wrap the approved Open Graph decision prose. ADR: 0054 --- docs/roadmap/phase-02d-walking-skeleton.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 89310571..ed842d4f 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4792,8 +4792,9 @@ decision and is not a correction to this PR. 2. **Open Graph locale — approved.** Emit `language_TERRITORY` only when the admitted tag has an explicit two-letter territory and no script/variant information would be silently discarded. Omit unrepresentable locale/alternate - properties rather than inventing a territory. `tr-TR` becomes `tr_TR`; `en`, `tr`, `zh-Hant` and - numeric-region tags are omitted. Document language, route and hreflang keep + properties rather than inventing a territory. `tr-TR` becomes `tr_TR`; + `en`, `tr`, `zh-Hant` and numeric-region tags are omitted. Document language, + route and hreflang keep their exact BCP-47 tags. The [protocol](https://ogp.me/) makes these properties optional; omission can leave consumers applying their own default. This adds the bounded G40 projection and qualifies Standards 08's blanket rule. @@ -4964,7 +4965,7 @@ with the complete serialized-array predicate active. This closes group 2. ##### Remediation group 3 — approved security maintenance -**Implemented; replacement proofs pass; independent reviews pending — +**Implemented; replacement proofs and first independent review round pass — 2026-10-10.** The approved paired Next/eslint-config-next 15.5.27 update disables unused image optimization. Compatible lockfile patches cover PostCSS 8.5.23, sharp 0.35.5, js-yaml 4.3.2, @@ -5014,6 +5015,14 @@ The approved ADR-0054 maintenance note is appended after these replacement proof historical 15.5.18 observations remain unchanged. Two independent review rounds follow the implementation commit. No new manual browser/VoiceOver pass is claimed. +**Round 1 — fresh independent reviews of `f16d937..3f6f37f`.** GPT-6-astra +(high) reviewed dependency ranges, resolved versions, audit triage and ADR history; +GPT-6.1-sol (high) reviewed runtime proof controls, copied-build ownership and +cleanup. Both approved without verified actionable findings. These were read-only +reviews; syntax and diff checks pass, but neither claims a new runtime-suite run. +The full Release architecture suite also passes all 198 cases, zero skips; all +seven ADR-workflow cases pass. The second independent review round follows. + ###### Proposed test-toolchain migration — approval pending The separately reviewable candidate pins Vitest **4.1.11** and Vite **6.4.3** in From 46bc8a44f91917064141898bdcbc846ca78d38e5 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 12:13:51 +0300 Subject: [PATCH 33/58] docs(roadmap): close approved security remediation reviews Record fresh independent dependency and runtime-proof approvals and local documentation checks. Preserve the unapproved admission and test-toolchain decisions, with exact-head PR CI still pending. ADR: 0054 --- docs/roadmap/phase-02d-walking-skeleton.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index ed842d4f..65dbc0cb 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4965,7 +4965,7 @@ with the complete serialized-array predicate active. This closes group 2. ##### Remediation group 3 — approved security maintenance -**Implemented; replacement proofs and first independent review round pass — +**Implemented; replacement proofs and both independent review rounds pass — 2026-10-10.** The approved paired Next/eslint-config-next 15.5.27 update disables unused image optimization. Compatible lockfile patches cover PostCSS 8.5.23, sharp 0.35.5, js-yaml 4.3.2, @@ -5023,6 +5023,19 @@ reviews; syntax and diff checks pass, but neither claims a new runtime-suite run The full Release architecture suite also passes all 198 cases, zero skips; all seven ADR-workflow cases pass. The second independent review round follows. +**Round 2 — fresh independent reviews of `f16d937..05a4313`.** GPT-6.1-sol +(high) independently checked dependency ranges, lockfile deltas and saved audit +totals; GPT-6-astra (high) checked native proof falsifiability, runtime config +loading, cleanup and preserved gates. Both approved without verified actionable +findings. They passed syntax/diff checks and inspected installed packages/source; +neither performed another build or runtime-suite run. A local audit of the 22 +changed Markdown files checks 1,703 relative file targets and 567 fragments, with +inline/fenced examples excluded and no missing targets. Added prose respects +88 columns; CI's existing link gate still checks file existence only. This closes +the approved compatible-security group. ADR-0055 and the separate test-toolchain +migration remain pending approval and have no implementation in these commits. +Fresh exact-head PR CI remains the final validation step. + ###### Proposed test-toolchain migration — approval pending The separately reviewable candidate pins Vitest **4.1.11** and Vite **6.4.3** in From 2bf9d5725983025d8d2e31a55b591ff87e4996b1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 12:59:50 +0300 Subject: [PATCH 34/58] docs(decisions): reconcile public admission draft review Name the proposed Retry-After qualification and deferred loader reuse. Keep ADR-0055 Proposed until feasibility and maintainer approval, and record separate approval of the major test-toolchain migration. --- ...0055-public-renderer-bootstrap-failures.md | 34 ++++++++++++++++--- docs/decisions/README.md | 2 +- docs/roadmap/phase-02d-walking-skeleton.md | 31 ++++++++++++++--- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/docs/decisions/0055-public-renderer-bootstrap-failures.md b/docs/decisions/0055-public-renderer-bootstrap-failures.md index 66d3268c..e8785c51 100644 --- a/docs/decisions/0055-public-renderer-bootstrap-failures.md +++ b/docs/decisions/0055-public-renderer-bootstrap-failures.md @@ -1,4 +1,4 @@ -# ADR-0055: Own Public Bootstrap Admission in Middleware +# ADR-0055: Handle Public Renderer Bootstrap Failures in Middleware ## Status @@ -11,6 +11,9 @@ This replaces the unaccepted neutral-500 and native-response-gate proposals. The maintainer must approve the single live read, request-local DTO handoff and changed counts before code. Accepted decisions/history remain unchanged; separate Open Graph/security-update approval does not accept this admission design. +Keep this record Proposed until the Next 15.5.27 context-propagation feasibility +proof passes and the maintainer approves the revised decision. An isolated +feasibility experiment does not authorize production implementation. ## Decision Drivers @@ -155,6 +158,14 @@ unavailable error cases. Preserve eligible values; omit malformed, HTTP-date, negative and out-of-range values without clamping or inventing a retry duration. The SDK's current safe-integer parser alone does not implement this proposed bound. +If accepted, this qualifies ADR-0054's +[Anonymous accounting](0054-bounded-public-renderer-admission.md#anonymous-accounting) +instruction to preserve the selected refusal's Retry-After unchanged at the +renderer forwarding boundary only: forward an eligible value unchanged, otherwise +omit it. The API's visitor-first refusal selection and original response metadata +remain unchanged. Record this bounded renderer qualification explicitly in +ADR-0054's dated navigation note; it is not a replacement of API accounting. + ### Lifecycle, runtime and navigation Cancellation reaches bootstrap/content transport, without promising forced @@ -187,6 +198,14 @@ failures to `null`, which `public-resource.ts` turns into `notFound()`. G40 spen three calls on a normal document and five on a followed missing document. This proposal replaces that ownership/count choice instead of intercepting streaming. +This is the request-local bootstrap reuse explicitly left undelivered by +[ADR-0054's Context](0054-bounded-public-renderer-admission.md#context). +It changes the admission loader described in +[ADR-0027's execution boundary](0027-frontend-i18n.md#dependency-and-execution-boundary): +the loader consumes this request's validated live snapshot instead of calling +`getSite()` again. Provenance verification, signed-route locale selection and +enabled-locale membership checks remain required before UI or content consumers. + Historical source inspection of Next 15.5.18 shows `pipe-readable.js` flushing headers before body writes, `app-render.js` mapping ordinary errors to 500 and Node middleware executing through its adapter. Official custom-server/streaming @@ -208,8 +227,10 @@ context's correctness. Delivery requires production proofs on Next **15.5.27**; P02d-6 remediation owns the proposed replacement of G40's duplicate RSC bootstrap, response ownership and three/five-call contract. Approval explicitly accepts the request-local DTO handoff and two/three-call replacement. Record bounded dated -supersession/navigation in ADR-0053, ADR-0027 and the G40/current guidance as needed; -retain Accepted bodies and historical delivery records unchanged. +supersession/navigation in ADR-0053, ADR-0027 and the G40/current guidance, plus +ADR-0054's renderer Retry-After qualification and deferred-reuse delivery boundary; +retain Accepted bodies and historical delivery records unchanged. The acceptance +decision must be committed before dependent production implementation. Scope covers native context/lifecycle, runtime facade, middleware publication, RSC/resource/i18n consumers and proofs. Failed feasibility requires a new decision, @@ -218,7 +239,12 @@ bootstrap needs its own ownership/build design. ## Architecture Tests -These are mandatory obligations, not passing-test or Implemented-catalogue claims: +These are mandatory obligations, not passing-test or Implemented-catalogue claims. +At acceptance, register canonical rule names and their owning test paths as +Registered in the +[architecture-test catalogue](../standards/21-architecture-tests-catalogue.md) +before dependent production code. Mark a rule Implemented only after its +executable proof and planted failing/passing controls pass: - Actual Next 15.5.27 production context propagation and same-request snapshot reuse; missing, duplicated, disposed or mismatched context fails closed before consumers. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index fcc7ad7c..13968ea3 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -76,7 +76,7 @@ an amendment is not a lifecycle status change. | # | Title | Topic | Target phase / decision point | |---|---|---|---| | 0049 | [Institution Sites and an Optional Course Marketplace](0049-institution-sites-and-course-marketplace.md) | Direction endorsed 2026-10-02; architecture still Proposed, no gate accepted | P02d-2 preparation hold released; remaining contracts before proposed Phase 09a's first consumers | -| 0055 | [Own Public Bootstrap Admission in Middleware](0055-public-renderer-bootstrap-failures.md) | One live middleware bootstrap with an immutable same-request snapshot; exact 429/503 and revised call accounting proposed | P02d-6 remediation; approval before dependent implementation | +| 0055 | [Handle Public Renderer Bootstrap Failures in Middleware](0055-public-renderer-bootstrap-failures.md) | One live middleware bootstrap with an immutable same-request snapshot; exact 429/503 and revised call accounting proposed | P02d-6 remediation; feasibility proof and approval before dependent implementation | The maintainer endorsed the marketplace direction and accepted ADR-0050/0051 and the P02d-2 package on 2026-10-02. Their rows are now in Active ADRs; ADR-0049 remains diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 65dbc0cb..0669896a 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5036,7 +5036,7 @@ the approved compatible-security group. ADR-0055 and the separate test-toolchain migration remain pending approval and have no implementation in these commits. Fresh exact-head PR CI remains the final validation step. -###### Proposed test-toolchain migration — approval pending +###### Test-toolchain migration — approved (2026-10-10) The separately reviewable candidate pins Vitest **4.1.11** and Vite **6.4.3** in both web and SDK, resolving esbuild **0.25.12** within Vite's range. Vitest 4 removes @@ -5055,9 +5055,32 @@ runner; all its skip/todo/missing/omitted-file negative controls must still fail Before completion: frozen install, resolved graph/advisory audit, guarded full frontend suite, lint, typecheck and production build; two independent review rounds and verified fixes. Preserve package/control deadlines and all existing -proof gates. This candidate has not been installed or implemented and accepts no -new architecture/ADR contract. Approval is requested because it exceeds the -compatible-transitive scope already approved. +proof gates. The maintainer separately approved this major test-toolchain upgrade +on 2026-10-10; implementation and fresh two-round review follow that approval. +It accepts no new architecture/ADR contract. The earlier compatible-security +group's pending-approval statement records its own commit-time boundary. + +###### ADR-0055 draft review reconciliation (2026-10-10) + +The maintainer's review is a request for draft corrections, not ADR acceptance. +The revised Proposed record names ADR-0054's unchanged Retry-After instruction and +limits the proposed qualification to renderer forwarding; API refusal selection +and metadata stay unchanged. It also names ADR-0054's deferred request-local reuse +and ADR-0027's admission loader, aligns the title with the existing filename, and +requires canonical Registered catalogue rules before dependent production code. + +Next 15.5.27 native-to-middleware-to-RSC context propagation must be demonstrated +before acceptance. An isolated feasibility experiment is research, not permission +to ship the proposed runtime. Maintainer approval is still required after review. + +The draft first appeared in mixed fix commit `cc626e8`, was revised in `bdb88bb`, +and received its decision-scoped commit in `ca03942`. That draft packaging is +recorded rather than rewritten. No Accepted ADR-0055 decision or dependent runtime +exists; acceptance-before-production implementation has not been bypassed. + +ADR-0054's original Next 15.5.18 pin and retired test name were accurate at +acceptance. Preserve that history: its existing Amendment 7 identifies the +current 15.5.27 maintenance baseline. No accepted-body erratum is appropriate. ### P02d-1 decision pass (2026-09-14) From 0761d8494994876d9a3da873ebe567922a414953 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 13:04:14 +0300 Subject: [PATCH 35/58] chore(web): upgrade the approved test toolchain Pin Vitest 4 and Vite 6 with a compatible esbuild resolution. Preserve the guarded runner and prove the locale fallback throws its intended RangeError with a constructible mock. Frozen install, 1235 frontend cases, lint, typecheck, production build and 198 architecture cases pass. Full audit drops from 11 to 2 records. --- docs/roadmap/phase-02d-walking-skeleton.md | 28 + frontend/apps/web/package.json | 3 +- frontend/apps/web/src/i18n/locale.test.ts | 8 +- frontend/package.json | 4 +- frontend/packages/sdk/package.json | 3 +- frontend/pnpm-lock.yaml | 597 +++++++++++---------- 6 files changed, 347 insertions(+), 296 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 0669896a..122020a0 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5082,6 +5082,34 @@ ADR-0054's original Next 15.5.18 pin and retired test name were accurate at acceptance. Preserve that history: its existing Amendment 7 identifies the current 15.5.27 maintenance baseline. No accepted-body erratum is appropriate. +Two fresh independent reviews of `46bc8a4..2bf9d57` approve these draft corrections +without verified findings: GPT-6.1-sol (high), then GPT-6-astra (high). Both checked +the accepted sources, actual duplicate reads, historical pin/test and five changed +relative links/fragments. Neither claims a runtime feasibility proof. Seven +ADR-workflow tests pass. A local three-document audit resolves 442 relative +targets and their fragments; added prose and diff checks pass. + +##### Remediation group 4 — approved test-toolchain migration + +**Implementation recorded before independent review — 2026-10-10.** Both web +and SDK pin Vitest 4.1.11 and Vite 6.4.3. Narrow overrides keep Vitest on Vite 6 +and Vite on compatible esbuild 0.25.12; the existing Next/PostCSS override remains. +The resolved graph removes Vitest 2, Vite 5, tinypool and vite-node, without +introducing Vite 7/8. Runtime dependencies and application behavior are unchanged. + +The `Intl.Locale` mock uses a constructible function and proves that the intended +RangeError was thrown by identity, preventing an accidental constructor TypeError +from satisfying the fallback case. Configurations, explicit cleanup, globals, +runner/report validation, package/control deadlines and CI gates stay unchanged. + +Frozen installation and 46 focused locale/runner cases pass. The guarded frontend +suite passes all 1,235 cases (1,175 web / 60 SDK), zero skips/todos; workspace lint, +typecheck and production build pass. The Release architecture suite passes 198 +cases, zero skips. The full audit falls from 11 records / 10 distinct advisories +to two records / two advisories: zero critical, one high and one moderate. The +production audit retains the same two residuals already triaged in group 3; +neither audit is described as clean. Independent review rounds follow this commit. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/frontend/apps/web/package.json b/frontend/apps/web/package.json index 3e1c14f5..86a4ffe8 100644 --- a/frontend/apps/web/package.json +++ b/frontend/apps/web/package.json @@ -39,6 +39,7 @@ "postcss": "^8.4.49", "tailwindcss": "^3.4.15", "typescript": "5.6.3", - "vitest": "^2.1.8" + "vite": "6.4.3", + "vitest": "4.1.11" } } diff --git a/frontend/apps/web/src/i18n/locale.test.ts b/frontend/apps/web/src/i18n/locale.test.ts index c84a77f9..a41162fa 100644 --- a/frontend/apps/web/src/i18n/locale.test.ts +++ b/frontend/apps/web/src/i18n/locale.test.ts @@ -31,9 +31,13 @@ describe('public UI locale selection', () => { }); it('uses deterministic ltr when runtime locale information is unavailable', () => { - vi.spyOn(Intl, 'Locale').mockImplementation(() => { - throw new RangeError('unavailable runtime data'); + const unavailable = new RangeError('unavailable runtime data'); + const constructor = vi.spyOn(Intl, 'Locale').mockImplementation(function () { + throw unavailable; }); expect(textDirection('ar')).toBe('ltr'); + expect(constructor).toHaveBeenCalledExactlyOnceWith('ar'); + expect(constructor.mock.results[0]?.type).toBe('throw'); + expect(constructor.mock.results[0]?.value).toBe(unavailable); }); }); diff --git a/frontend/package.json b/frontend/package.json index df1964f2..a6cb41af 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,9 @@ }, "pnpm": { "overrides": { - "next@15.5.27>postcss": "8.5.23" + "next@15.5.27>postcss": "8.5.23", + "vitest@4.1.11>vite": "6.4.3", + "vite@6.4.3>esbuild": "0.25.12" } } } diff --git a/frontend/packages/sdk/package.json b/frontend/packages/sdk/package.json index 0c6de147..004e15a3 100644 --- a/frontend/packages/sdk/package.json +++ b/frontend/packages/sdk/package.json @@ -18,7 +18,8 @@ "@learnstack/config": "workspace:*", "openapi-typescript": "7.13.0", "typescript": "5.6.3", - "vitest": "2.1.9", + "vite": "6.4.3", + "vitest": "4.1.11", "eslint": "9.39.4" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index a40e3dec..1dbbda0d 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -6,6 +6,8 @@ settings: overrides: next@15.5.27>postcss: 8.5.23 + vitest@4.1.11>vite: 6.4.3 + vite@6.4.3>esbuild: 0.25.12 importers: @@ -65,7 +67,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.7.0 - version: 4.7.0(vite@5.4.21(@types/node@22.19.19)) + version: 4.7.0(vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7)) autoprefixer: specifier: ^10.4.20 version: 10.5.0(postcss@8.5.23) @@ -87,9 +89,12 @@ importers: typescript: specifier: 5.6.3 version: 5.6.3 + vite: + specifier: 6.4.3 + version: 6.4.3(@types/node@22.19.19)(jiti@1.21.7) vitest: - specifier: ^2.1.8 - version: 2.1.9(@types/node@22.19.19)(jsdom@26.1.0) + 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)) packages/config: dependencies: @@ -129,9 +134,12 @@ importers: typescript: specifier: 5.6.3 version: 5.6.3 + vite: + specifier: 6.4.3 + version: 6.4.3(@types/node@22.19.19)(jiti@1.21.7) vitest: - specifier: 2.1.9 - version: 2.1.9(@types/node@22.19.19)(jsdom@26.1.0) + 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)) packages/ui: dependencies: @@ -306,141 +314,159 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -981,6 +1007,9 @@ packages: '@schummar/icu-type-parser@1.21.5': resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/core-darwin-arm64@1.16.13': resolution: {integrity: sha512-JrMH2h7ohd4mOIneNRx/diqTmCDSdWfp9uOdQ8VJ2V2CA7jOJ1jWb+qp8RoZLqgiVmwDDC7eySKB1kbvZ+jVXQ==} engines: {node: '>=10'} @@ -1112,6 +1141,12 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1300,34 +1335,34 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitest/expect@2.1.9': - resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@vitest/mocker@2.1.9': - resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@2.1.9': - resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - '@vitest/runner@2.1.9': - resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - '@vitest/snapshot@2.1.9': - resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - '@vitest/spy@2.1.9': - resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/utils@2.1.9': - resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -1479,10 +1514,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1509,8 +1540,8 @@ packages: caniuse-lite@1.0.30001815: resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==} - chai@5.3.3: - resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} chalk@4.1.2: @@ -1520,10 +1551,6 @@ packages: change-case@5.4.4: resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} - check-error@2.1.3: - resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} - engines: {node: '>= 16'} - chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1613,10 +1640,6 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} - deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1682,8 +1705,8 @@ packages: resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -1701,9 +1724,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} hasBin: true escalade@3.2.0: @@ -2244,9 +2267,6 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - loupe@3.2.1: - resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -2401,6 +2421,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + openapi-typescript@7.13.0: resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} hasBin: true @@ -2445,12 +2469,8 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - - pathval@2.0.1: - resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} - engines: {node: '>= 14.16'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2723,8 +2743,8 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} @@ -2813,23 +2833,16 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} - tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} - - tinyrainbow@1.2.0: - resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} - engines: {node: '>=14.0.0'} - - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} engines: {node: '>=14.0.0'} tldts-core@6.1.86: @@ -2929,27 +2942,27 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - vite-node@2.1.9: - resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' less: '*' lightningcss: ^1.21.0 sass: '*' sass-embedded: '*' stylus: '*' sugarss: '*' - terser: ^5.4.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: '@types/node': optional: true + jiti: + optional: true less: optional: true lightningcss: @@ -2964,24 +2977,44 @@ packages: optional: true terser: optional: true + tsx: + optional: true + yaml: + optional: true - vitest@2.1.9: - resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} - engines: {node: ^18.0.0 || >=20.0.0} + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 2.1.9 - '@vitest/ui': 2.1.9 + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' + vite: 6.4.3 peerDependenciesMeta: '@edge-runtime/vm': optional: true + '@opentelemetry/api': + optional: true '@types/node': optional: true - '@vitest/browser': + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': optional: true '@vitest/ui': optional: true @@ -3254,73 +3287,82 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.21.5': + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': optional: true - '@esbuild/android-arm64@0.21.5': + '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/android-arm@0.21.5': + '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/android-x64@0.21.5': + '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.21.5': + '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/darwin-x64@0.21.5': + '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.21.5': + '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/freebsd-x64@0.21.5': + '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/linux-arm64@0.21.5': + '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-arm@0.21.5': + '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-ia32@0.21.5': + '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-loong64@0.21.5': + '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-mips64el@0.21.5': + '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-ppc64@0.21.5': + '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-riscv64@0.21.5': + '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/linux-s390x@0.21.5': + '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/linux-x64@0.21.5': + '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.21.5': + '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.21.5': + '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/sunos-x64@0.21.5': + '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/win32-arm64@0.21.5': + '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/win32-ia32@0.21.5': + '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-x64@0.21.5': + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': @@ -3742,6 +3784,8 @@ snapshots: '@schummar/icu-type-parser@1.21.5': {} + '@standard-schema/spec@1.1.0': {} + '@swc/core-darwin-arm64@1.16.13': optional: true @@ -3864,6 +3908,13 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/estree@1.0.9': {} @@ -4039,7 +4090,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.1': optional: true - '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.19.19))': + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) @@ -4047,49 +4098,50 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 5.4.21(@types/node@22.19.19) + vite: 6.4.3(@types/node@22.19.19)(jiti@1.21.7) transitivePeerDependencies: - supports-color - '@vitest/expect@2.1.9': + '@vitest/expect@4.1.11': dependencies: - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 - chai: 5.3.3 - tinyrainbow: 1.2.0 + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.3.0 + tinyrainbow: 3.2.0 - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.19))': + '@vitest/mocker@4.1.11(vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7))': dependencies: - '@vitest/spy': 2.1.9 + '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21(@types/node@22.19.19) + vite: 6.4.3(@types/node@22.19.19)(jiti@1.21.7) - '@vitest/pretty-format@2.1.9': + '@vitest/pretty-format@4.1.11': dependencies: - tinyrainbow: 1.2.0 + tinyrainbow: 3.2.0 - '@vitest/runner@2.1.9': + '@vitest/runner@4.1.11': dependencies: - '@vitest/utils': 2.1.9 - pathe: 1.1.2 + '@vitest/utils': 4.1.11 + pathe: 2.0.3 - '@vitest/snapshot@2.1.9': + '@vitest/snapshot@4.1.11': dependencies: - '@vitest/pretty-format': 2.1.9 + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 magic-string: 0.30.21 - pathe: 1.1.2 + pathe: 2.0.3 - '@vitest/spy@2.1.9': - dependencies: - tinyspy: 3.0.2 + '@vitest/spy@4.1.11': {} - '@vitest/utils@2.1.9': + '@vitest/utils@4.1.11': dependencies: - '@vitest/pretty-format': 2.1.9 - loupe: 3.2.1 - tinyrainbow: 1.2.0 + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.2.0 acorn-jsx@5.3.2(acorn@8.16.0): dependencies: @@ -4256,8 +4308,6 @@ snapshots: node-releases: 2.0.58 update-browserslist-db: 1.2.3(browserslist@4.28.7) - cac@6.7.14: {} - call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -4283,13 +4333,7 @@ snapshots: caniuse-lite@1.0.30001815: {} - chai@5.3.3: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.3 - deep-eql: 5.0.2 - loupe: 3.2.1 - pathval: 2.0.1 + chai@6.3.0: {} chalk@4.1.2: dependencies: @@ -4298,8 +4342,6 @@ snapshots: change-case@5.4.4: {} - check-error@2.1.3: {} - chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -4384,8 +4426,6 @@ snapshots: decimal.js@10.6.0: {} - deep-eql@5.0.2: {} - deep-is@0.1.4: {} define-data-property@1.1.4: @@ -4508,7 +4548,7 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 - es-module-lexer@1.7.0: {} + es-module-lexer@2.3.2: {} es-object-atoms@1.1.1: dependencies: @@ -4531,31 +4571,34 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - esbuild@0.21.5: + esbuild@0.25.12: optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 escalade@3.2.0: {} @@ -5184,8 +5227,6 @@ snapshots: dependencies: js-tokens: 4.0.0 - loupe@3.2.1: {} - lru-cache@10.4.3: {} lru-cache@5.1.1: @@ -5345,6 +5386,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.2.1: {} + openapi-typescript@7.13.0(typescript@5.6.3): dependencies: '@redocly/openapi-core': 1.34.20(supports-color@10.2.2) @@ -5398,9 +5441,7 @@ snapshots: path-parse@1.0.7: {} - pathe@1.1.2: {} - - pathval@2.0.1: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -5716,7 +5757,7 @@ snapshots: stackback@0.0.2: {} - std-env@3.10.0: {} + std-env@4.3.0: {} stop-iteration-iterator@1.1.0: dependencies: @@ -5846,18 +5887,14 @@ snapshots: tinybench@2.9.0: {} - tinyexec@0.3.2: {} + tinyexec@1.3.1: {} tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tinypool@1.1.1: {} - - tinyrainbow@1.2.0: {} - - tinyspy@3.0.2: {} + tinyrainbow@3.2.0: {} tldts-core@6.1.86: {} @@ -5991,68 +6028,46 @@ snapshots: util-deprecate@1.0.2: {} - vite-node@2.1.9(@types/node@22.19.19): - dependencies: - cac: 6.7.14 - debug: 4.4.3(supports-color@10.2.2) - es-module-lexer: 1.7.0 - pathe: 1.1.2 - vite: 5.4.21(@types/node@22.19.19) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - vite@5.4.21(@types/node@22.19.19): + vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7): dependencies: - esbuild: 0.21.5 + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 postcss: 8.5.23 rollup: 4.60.4 + tinyglobby: 0.2.16 optionalDependencies: '@types/node': 22.19.19 fsevents: 2.3.3 + jiti: 1.21.7 - vitest@2.1.9(@types/node@22.19.19)(jsdom@26.1.0): + 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)): dependencies: - '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.19)) - '@vitest/pretty-format': 2.1.9 - '@vitest/runner': 2.1.9 - '@vitest/snapshot': 2.1.9 - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 - chai: 5.3.3 - debug: 4.4.3(supports-color@10.2.2) + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 expect-type: 1.3.0 magic-string: 0.30.21 - pathe: 1.1.2 - std-env: 3.10.0 + obug: 2.2.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.3.0 tinybench: 2.9.0 - tinyexec: 0.3.2 - tinypool: 1.1.1 - tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@22.19.19) - vite-node: 2.1.9(@types/node@22.19.19) + tinyexec: 1.3.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.2.0 + vite: 6.4.3(@types/node@22.19.19)(jiti@1.21.7) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.19 jsdom: 26.1.0 transitivePeerDependencies: - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser w3c-xmlserializer@5.0.0: dependencies: From a97f6ee615299181acc2428f32b59067d0e34bc0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 13:06:41 +0300 Subject: [PATCH 36/58] docs(roadmap): record test toolchain first reviews Record independent dependency and runtime-proof approval without findings, including the separately executed focused controls. --- docs/roadmap/phase-02d-walking-skeleton.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 122020a0..c5c1f28e 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5110,6 +5110,15 @@ to two records / two advisories: zero critical, one high and one moderate. The production audit retains the same two residuals already triaged in group 3; neither audit is described as clean. Independent review rounds follow this commit. +**Round 1 — independent review of `2bf9d57..0761d84`.** GPT-6.1-sol (high) +reviews dependency compatibility and approves without verified findings. A fresh +resolved graph matches the saved graph and installed lock; surviving package +records and runtime dependency declarations are unchanged. GPT-6-astra (high) +reviews runtime/test proofs and also approves without findings. It independently +passes 110 focused locale, runner, caller, HMR and cleanup cases, verifies the +26-web/one-SDK file census, and checks the installed JSON reporter semantics. +Neither reviewer claims another full frontend/build/architecture execution. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved From 22a9775a83a65136f4f2381e08dca345da04f4bb Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 13:10:15 +0300 Subject: [PATCH 37/58] docs(roadmap): close test toolchain review rounds Record fresh dependency and shuffled runtime-proof verification without findings. Preserve the separate Proposed admission decision boundary. --- docs/roadmap/phase-02d-walking-skeleton.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index c5c1f28e..e19ddda9 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5119,6 +5119,17 @@ passes 110 focused locale, runner, caller, HMR and cleanup cases, verifies the 26-web/one-SDK file census, and checks the installed JSON reporter semantics. Neither reviewer claims another full frontend/build/architecture execution. +**Round 2 — fresh independent review of `2bf9d57..a97f6ee`.** GPT-6-astra +(high) approves the dependency/documentation lens without verified findings, +independently matching the installed lock and fresh graph to the committed tree. +GPT-6.1-sol (high) approves runtime/proof integrity without findings. It passes +178 focused cases, including 68 shuffled DOM/request/mock cases, and validates +both reports with the actual repository validator. Its direct installed-spy +control distinguishes the intended RangeError from the old arrow's TypeError +and proves restoration of native locale behavior. No full-suite, build, install +or audit rerun is attributed to these reviewers. This closes group 4; fresh +exact-head CI follows. ADR-0055 remains Proposed and unimplemented. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved From 2deb7b5cb55e2a6485cc1681a0d4b1dd8880af7c Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 13:11:08 +0300 Subject: [PATCH 38/58] docs(decisions): record isolated admission feasibility Record replayed Next context propagation with explicit synthetic-proof limits and the valid HEAD late-consumer caveat. Keep production proofs and maintainer acceptance pending. --- ...0055-public-renderer-bootstrap-failures.md | 31 +++++++++++++++++++ docs/roadmap/phase-02d-walking-skeleton.md | 27 ++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/docs/decisions/0055-public-renderer-bootstrap-failures.md b/docs/decisions/0055-public-renderer-bootstrap-failures.md index e8785c51..3a6ad1dd 100644 --- a/docs/decisions/0055-public-renderer-bootstrap-failures.md +++ b/docs/decisions/0055-public-renderer-bootstrap-failures.md @@ -172,6 +172,14 @@ Cancellation reaches bootstrap/content transport, without promising forced cancellation of arbitrary React work. Native `finish`/`close` or shutdown closes the store exactly once, releases its snapshot/listeners and prevents late use. Do not call ALS `disable()` per request or dispose on a delegating promise's return. +Response completion does not prove React work has ended. Check disposal before +publication or downstream work and again after awaits. Clearing the store's +snapshot reference does not synchronously reclaim references held by already +running consumers. Late continuations must fail closed without republishing, +starting another bootstrap or Education read, or exposing the context/DTO in +diagnostics. Production proofs must cover valid HEAD and late component work. +Expected refusal after completion is distinct from an active-request context +defect and cannot change the already completed HTTP response. Native startup installs the sole ALS holder as a versioned, non-enumerable `globalThis[Symbol.for('learnstack.public-admission.v1')]`. Server-only facades @@ -213,6 +221,29 @@ and Node ALS documentation establish the available primitives, not this shared context's correctness. Delivery requires production proofs on Next **15.5.27**; 15.5.18 source observations are not runtime evidence for the patched baseline. +### Isolated feasibility observation (2026-10-10) + +An isolated native HTTPS / production Next 15.5.27 experiment on Node 22.23.1, +independently replayed by the root agent, confirms the same ALS holder and store +references through bundled Node middleware, metadata, layout and page, including +after awaits. It covers 37 requests: eight overlapping identical requests each +for HTML, Flight and prefetch; eight host/locale HTML/Flight combinations; HEAD; +and HTML/Flight not-found/error cases. Prefetch executes middleware without dynamic +RSC consumers in this fixture. Unbound and duplicate-ALS controls return 503 with +no snapshot publication or RSC consumer. Publication is synthetic, not `getSite()`. + +On a valid HEAD, an already-started not-found component resumes after native +`finish`. ALS still carries the correct disposed store; its snapshot is cleared +and the facade refuses late access. The wire remains bodyless 200. This supports +the disposal checks above; it does not prove all React work or local references +have ended. The fixture's bounded error is logged by Next, so production must +also verify neutral diagnostics during this path. + +This establishes the same-process propagation premise only. Actual provenance, +route binding, API/RLS, call accounting, abort/shutdown, keep-alive, HMR and browser +navigation remain production proof obligations. The experiment does not accept +this decision or satisfy those obligations. Maintainer approval remains pending. + ## Consequences - **Positive:** bootstrap failure has an existing pre-render HTTP owner; one site diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index e19ddda9..ff165b81 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5130,6 +5130,33 @@ and proves restoration of native locale behavior. No full-suite, build, install or audit rerun is attributed to these reviewers. This closes group 4; fresh exact-head CI follows. ADR-0055 remains Proposed and unimplemented. +##### ADR-0055 isolated feasibility observation (2026-10-10) + +The root independently replays a disposable native HTTPS / production Next +15.5.27 fixture on Node 22.23.1. All 37 requests pass the propagation matrix: +eight simultaneous identical HTML, eight Flight, eight prefetch, eight host/locale +HTML/Flight combinations, HEAD and HTML/Flight missing/error cases. Native, +middleware and RSC compare actual holder/store references; request IDs are +observations, not context lookup keys. Unbound and duplicate-ALS controls each +refuse with 503, without snapshot publication or RSC consumers. Prefetch runs +middleware without dynamic RSC consumers in this synthetic fixture. + +Valid HEAD exposes a lifecycle caveat: Next can resume an already-started +not-found component after response finish. The store is correctly disposed, its +snapshot cleared, and late access refused; Next logs the bounded fixture error. +ADR-0055 now explicitly requires disposal checks after awaits and neutral +production diagnostics. Response finish does not imply synchronous reclamation +of already captured local references or completion of all React work. + +The experiment uses synthetic site publication. It proves neither real API/ +provenance/RLS nor the proposed two/three-call accounting, cancellation, active +shutdown, keep-alive, HMR or browser navigation. Certificates, children and owned +build output are cleaned; a local reproducible source/results bundle is retained +for maintainer inspection. No experiment source enters the production tree. +This supports feasibility of the proposed same-process design only. ADR-0055 +remains Proposed; dependent production implementation still requires approval +and all named production proofs. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved From 5b5f5a9e49d289cf5b7a47b92477837e65999d89 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 13:11:36 +0300 Subject: [PATCH 39/58] docs(decisions): distinguish late admission refusal Limit the RSC lifecycle-error assertion to active requests so expected post-finish HEAD work is not simultaneously promised away. --- .../0055-public-renderer-bootstrap-failures.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/decisions/0055-public-renderer-bootstrap-failures.md b/docs/decisions/0055-public-renderer-bootstrap-failures.md index 3a6ad1dd..3b421802 100644 --- a/docs/decisions/0055-public-renderer-bootstrap-failures.md +++ b/docs/decisions/0055-public-renderer-bootstrap-failures.md @@ -103,10 +103,12 @@ calls `getSite()`. Each consumer honors its result before Education or theme use layouts alone do not serialize child execution. Content clients carry the native cancellation signal and retain the existing transport controls. -A missing, disposed or mismatched RSC snapshot is an internal lifecycle error, -not API 404 or permission to fetch another site or reuse data. Fail closed before -Education/theme with a sanitized framework failure. Arbitrary framework-error -statuses are not guaranteed; production proofs must prevent context failures. +While the native request remains active, a missing, disposed or mismatched RSC +snapshot is an internal lifecycle error, not API 404 or permission to fetch another +site or reuse data. Fail closed before Education/theme with a sanitized framework +failure. Arbitrary framework-error statuses are not guaranteed; production proofs +must prevent these active-request context failures. Expected late refusal follows +the separate completion policy below. ### Freshness, accounting and response scope From b29f751779b52e574f8375794eaf93d2fb0a82ef Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 13:14:10 +0300 Subject: [PATCH 40/58] docs(roadmap): close isolated admission feasibility review Record the verified active-request wording correction, fresh second review and independently replayed matrix without claiming production proofs or maintainer acceptance. --- docs/roadmap/phase-02d-walking-skeleton.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index ff165b81..c0f65188 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5157,6 +5157,17 @@ This supports feasibility of the proposed same-process design only. ADR-0055 remains Proposed; dependent production implementation still requires approval and all named production proofs. +**Independent feasibility review — two rounds.** GPT-6-astra (xhigh) checks +the fixture/driver, retained diagnostics and root replay. It verifies one wording +gap: the RSC context-error paragraph also covered expected post-finish HEAD work. +Commit `5b5f5a9` limits that paragraph to active requests; the reviewer rechecks +and approves the narrow feasibility claim without remaining findings. It does +not characterize observation-only summary fields as comprehensive mutation tests. +GPT-6.1-sol (high) then independently reviews `22a9775..5b5f5a9`, confirms the +matrix and active/late distinction, and approves without findings. Both reviews +inspect evidence rather than run another build. The root's separate replay passes +the same matrix and both negative controls. No review constitutes ADR acceptance. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved From 6e05fd8d3fa5817accd2f0e1500753b20f7df2bb Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 14:19:08 +0300 Subject: [PATCH 41/58] docs(decisions): accept single-bootstrap public admission Record maintainer approval before dependent runtime changes, with bounded supersession, registered proof obligations and a three-step review plan. ADR: 0055, 0053, 0054, 0027 --- docs/architecture/14-frontend-architecture.md | 16 ++- docs/decisions/0027-frontend-i18n.md | 16 +++ .../0053-trusted-public-server-rendering.md | 19 ++++ .../0054-bounded-public-renderer-admission.md | 19 ++++ ...0055-public-renderer-bootstrap-failures.md | 60 +++++++----- docs/decisions/README.md | 2 +- docs/roadmap/phase-02d-walking-skeleton.md | 68 +++++++++++++ docs/standards/07-frontend-architecture.md | 33 ++++++- docs/standards/09-error-handling.md | 26 ++++- .../21-architecture-tests-catalogue.md | 97 ++++++++++++++++++- 10 files changed, 319 insertions(+), 37 deletions(-) diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index ade2188f..936c6324 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -40,13 +40,17 @@ theme, freshness and content-fault product proofs. The [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) owns validation, completed reviews and passing manual accessibility closeout. -[ADR-0055](../decisions/0055-public-renderer-bootstrap-failures.md) is Proposed: +[ADR-0055](../decisions/0055-public-renderer-bootstrap-failures.md) is +**Accepted — 2026-10-10; replacement implementation pending**: it replaces the duplicate RSC bootstrap with one middleware-owned live read and an immutable same-request snapshot, preserving exact bootstrap 429/503 before -rendering. That ownership, DTO handoff and call-count change await approval. The +rendering. The +[three-step plan](../roadmap/phase-02d-walking-skeleton.md#adr-0055-acceptance-and-implementation-plan-2026-10-10) +owns implementation and two independent review rounds per step. The [review decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-systematic-review-decision-package-2026-10-10) separately records approval for Open Graph projection and bounded Next security -maintenance. ADR-0055 changes no Accepted contract before its own approval. +maintenance. The isolated context-propagation experiment establishes feasibility; +it is not production security/lifecycle proof or runtime delivery. > **P02d-4 Accepted design — 2026-10-03.** Step 2 delivers site bootstrap; > Steps 3–4 deliver Education and contract/SDK controls; P02d-5 owns the server consumer. @@ -210,8 +214,10 @@ The three locale-prefixed catalog/course/lesson routes use minimal tenant chrome ordinary same-host relative anchors and no automatic prefetch. Both opaque pagination surfaces parse owned parameters only from the signed raw target. A new document re-reads API state; metadata/layout/page share request-local admission and -content loaders. Normal documents cost three API calls; a followed missing-resource -redirect costs five. No retries or shared validated-DTO cache are added. +content loaders. ADR-0055 accepts one live middleware bootstrap and a native +same-request immutable snapshot: two API calls per completed product document and +three per followed missing-detail chain. Runtime retains its earlier three/five +calls until the replacement is delivered. No retry or shared DTO cache is added. Missing/hidden resources return local **307** to the same host's fixed `/{locale}/status/not-found`, followed by a branded **404** document; the browser diff --git a/docs/decisions/0027-frontend-i18n.md b/docs/decisions/0027-frontend-i18n.md index 2ae0dfa7..33bf02d1 100644 --- a/docs/decisions/0027-frontend-i18n.md +++ b/docs/decisions/0027-frontend-i18n.md @@ -321,6 +321,22 @@ This delivery note changes no accepted decision and claims no full WCAG audit, Arabic screen-reader check or zoom-conformance result. P02d-7 still owns the browser demo/Lighthouse and Phase 06 owns full Playwright/axe expansion. +### Amendment 4 — Request-local bootstrap supersession (2026-10-10) + +The maintainer accepted +[ADR-0055](0055-public-renderer-bootstrap-failures.md). It replaces only this +record's admission-loader execution contract: middleware performs the sole live +site bootstrap, then publishes an immutable validated snapshot into its native +request context. The shared admission loader re-verifies provenance and consumes +that same request's snapshot without a second `getSite()` call. Signed-route +locale selection, enabled-locale checks, UI fallback and catalogue ownership are +unchanged. The original acceptance metadata, body and prior amendments remain +historical; this is bounded supersession navigation, not a correction. + +Acceptance delivers no runtime implementation or passing production proof. The +[three-step plan](../roadmap/phase-02d-walking-skeleton.md#adr-0055-acceptance-and-implementation-plan-2026-10-10) +owns implementation, two independent review rounds per step and delivery status. + ## References - [ADR-0008 — Localization Schema](0008-localization-schema.md) diff --git a/docs/decisions/0053-trusted-public-server-rendering.md b/docs/decisions/0053-trusted-public-server-rendering.md index 91aa696e..d96a4df4 100644 --- a/docs/decisions/0053-trusted-public-server-rendering.md +++ b/docs/decisions/0053-trusted-public-server-rendering.md @@ -351,6 +351,25 @@ owns the final head, merge verification and CI evidence. Original acceptance metadata and prior amendments remain historical. This delivery note changes no decision; P02d-6 product pages and P02d-7 browser/demo/Lighthouse remain separate. +### Amendment 9 — Single-bootstrap ownership supersession (2026-10-10) + +The maintainer accepted +[ADR-0055](0055-public-renderer-bootstrap-failures.md). It replaces this record's +duplicate middleware/RSC site-read contract with one live middleware bootstrap +and an immutable validated snapshot bound to the native request lifetime. RSC +re-verifies provenance before consuming that request's snapshot; it performs no +second site read. Middleware owns exact bootstrap 404/429/503 before rendering. +Normal product documents use two API calls; followed missing-detail chains use +three. Content eligibility remains live at the API, and a new request reads a new +site snapshot. API tenant authority, signed route identity, no-store transport, +numeric quotas and content-error/missing-detail status policies are unchanged. + +This is bounded supersession navigation. The original body, metadata and earlier +delivery notes remain historical. Acceptance delivers no implementation or +passing production proof; the +[three-step plan](../roadmap/phase-02d-walking-skeleton.md#adr-0055-acceptance-and-implementation-plan-2026-10-10) +owns execution and review evidence. + ## References - [ADR-0036](0036-tenant-resolution-trusted-inputs.md) diff --git a/docs/decisions/0054-bounded-public-renderer-admission.md b/docs/decisions/0054-bounded-public-renderer-admission.md index a1e9d000..d6a5feec 100644 --- a/docs/decisions/0054-bounded-public-renderer-admission.md +++ b/docs/decisions/0054-bounded-public-renderer-admission.md @@ -335,6 +335,25 @@ contract are unchanged. The unused image optimizer is disabled. P02d-6's owns the replacement native/production proofs and dependency triage; historical 15.5.18 observations remain unchanged. +### Amendment 8 — Renderer bootstrap and Retry-After qualification (2026-10-10) + +The maintainer accepted +[ADR-0055](0055-public-renderer-bootstrap-failures.md). It selects the request-local +bootstrap reuse left undelivered in this record's Context. Middleware performs one +live site read and owns bootstrap refusal responses; RSC consumes the immutable +same-request snapshot. Implementation and production proofs remain pending under +the [three-step plan](../roadmap/phase-02d-walking-skeleton.md#adr-0055-acceptance-and-implementation-plan-2026-10-10). + +ADR-0055 qualifies only the renderer forwarding boundary of +[Anonymous accounting](#anonymous-accounting)'s instruction to use the selected +refusal's Retry-After unchanged. The renderer forwards an eligible parsed integer +from zero through sixty seconds unchanged for its closed supported 429/503 cases; +it omits malformed, negative, HTTP-date or out-of-range values without clamping or +inventing a duration. API refusal selection, original API metadata, visitor/peer +accounting and numeric quotas remain unchanged. This is bounded supersession +navigation, not a correction; original metadata, body and earlier amendments stay +historical. Acceptance is not runtime delivery evidence. + ## References - [ADR-0053 — Trusted Public Server Rendering](0053-trusted-public-server-rendering.md) diff --git a/docs/decisions/0055-public-renderer-bootstrap-failures.md b/docs/decisions/0055-public-renderer-bootstrap-failures.md index 3b421802..ca8507ed 100644 --- a/docs/decisions/0055-public-renderer-bootstrap-failures.md +++ b/docs/decisions/0055-public-renderer-bootstrap-failures.md @@ -2,18 +2,18 @@ ## Status -Proposed — 2026-10-10. Maintainer approval is pending. +Accepted — 2026-10-10. The maintainer approved the revised decision after the +isolated Next 15.5.27 context-propagation experiment and its two review rounds. **Date:** 2026-10-10 -**Deciders:** @cemil (approval pending) +**Deciders:** @cemil This replaces the unaccepted neutral-500 and native-response-gate proposals. -The maintainer must approve the single live read, request-local DTO handoff and -changed counts before code. Accepted decisions/history remain unchanged; separate -Open Graph/security-update approval does not accept this admission design. -Keep this record Proposed until the Next 15.5.27 context-propagation feasibility -proof passes and the maintainer approves the revised decision. An isolated -feasibility experiment does not authorize production implementation. +Approval accepts the single live read, request-local DTO handoff, revised counts +and bounded renderer Retry-After policy. It is separate from the earlier Open +Graph/security approvals. The isolated experiment establishes feasibility only; +production implementation, its security/lifecycle proofs and both review rounds +per step remain pending. The decision-only acceptance commit precedes that code. ## Decision Drivers @@ -29,9 +29,10 @@ feasibility experiment does not authorize production implementation. ## Considered Options 1. **One live middleware bootstrap with a request-local validated snapshot** - (recommended candidate). Middleware already owns a `NextResponse` before RSC. + (chosen). Middleware already owns a `NextResponse` before RSC. RSC re-verifies provenance and consumes only that incoming request's snapshot. - Shared runtime/context propagation remains a mandatory feasibility gate. + Shared runtime/context propagation is supported by the isolated feasibility + experiment; actual production integration remains a mandatory proof gate. 2. **Native response gate around a second RSC read** (rejected). Intercepting commitment, early hints, compression, backpressure and terminal stream behavior adds a fragile version-sensitive response adapter to preserve a duplicate read. @@ -39,7 +40,7 @@ feasibility experiment does not authorize production implementation. listener can emit exact refusals before delegation, but must acquire configured transport/SDK build support and route/locale/redirect ownership currently in middleware. It still needs a validated request-local handoff to RSC. Prefer this - only through a separate concrete decision if the recommended boundary fails. + only through a separate concrete decision if the chosen boundary fails. 4. **RSC exceptions or error components** (rejected). They cannot promise arbitrary 429/503 and Retry-After; framework error rendering can already have committed 200. 5. **Extra preflight or two serial native reads** (rejected). Another read consumes @@ -54,8 +55,9 @@ HTTP response owner. After verified successful admission, middleware publishes a bounded, immutable validated site snapshot into a native-created request-local context. RSC independently re-verifies provenance and consumes that exact request's snapshot without another site call. Middleware emits bootstrap 404/429/503 before -RSC starts. This proposed replacement changes the accepted duplicate-read and -request-count contracts and requires approval plus production feasibility proof. +RSC starts. This bounded replacement supersedes the duplicate-read and request-count +contracts in ADR-0053, ADR-0027's admission-loader execution contract and P02d-6 +G40, and qualifies ADR-0054's renderer Retry-After forwarding boundary only. ### Native context and middleware admission @@ -158,9 +160,9 @@ One renderer sanitizer accepts optional parsed Retry-After integer delta-seconds **0 through 60 inclusive**, only on 429/503 from the closed supported rate-limit/ unavailable error cases. Preserve eligible values; omit malformed, HTTP-date, negative and out-of-range values without clamping or inventing a retry duration. -The SDK's current safe-integer parser alone does not implement this proposed bound. +The SDK's current safe-integer parser alone does not implement this accepted bound. -If accepted, this qualifies ADR-0054's +This qualifies ADR-0054's [Anonymous accounting](0054-bounded-public-renderer-admission.md#anonymous-accounting) instruction to preserve the selected refusal's Retry-After unchanged at the renderer forwarding boundary only: forward an eligible value unchanged, otherwise @@ -186,7 +188,7 @@ defect and cannot change the already completed HTTP response. Native startup installs the sole ALS holder as a versioned, non-enumerable `globalThis[Symbol.for('learnstack.public-admission.v1')]`. Server-only facades retrieve and validate that instance; bundles never create a fallback. Only native -ingress calls `run()` with a fresh store. This is a same-process Node candidate; +ingress calls `run()` with a fresh store. This is a same-process Node contract; workers, Edge or serverless need a new decision. Prove actual singleton/context reuse through middleware, RSC and error rendering on Next 15.5.27. Missing context fails closed; no header/cache/extra-read fallback or Next API guarantee is implied. @@ -206,7 +208,7 @@ production upgrade closure and HMR rules; HMR must not reuse old snapshots. At `69b382a`, middleware discards its site read; RSC reads again and collapses failures to `null`, which `public-resource.ts` turns into `notFound()`. G40 spends three calls on a normal document and five on a followed missing document. This -proposal replaces that ownership/count choice instead of intercepting streaming. +decision replaces that ownership/count choice instead of intercepting streaming. This is the request-local bootstrap reuse explicitly left undelivered by [ADR-0054's Context](0054-bounded-public-renderer-admission.md#context). @@ -244,7 +246,8 @@ also verify neutral diagnostics during this path. This establishes the same-process propagation premise only. Actual provenance, route binding, API/RLS, call accounting, abort/shutdown, keep-alive, HMR and browser navigation remain production proof obligations. The experiment does not accept -this decision or satisfy those obligations. Maintainer approval remains pending. +this decision or satisfy those obligations. At the experiment's completion, +maintainer approval remained pending; Status records the subsequent acceptance. ## Consequences @@ -257,7 +260,7 @@ this decision or satisfy those obligations. Maintainer approval remains pending. ## Implementation Notes -P02d-6 remediation owns the proposed replacement of G40's duplicate RSC bootstrap, +P02d-6 remediation owns the accepted replacement of G40's duplicate RSC bootstrap, response ownership and three/five-call contract. Approval explicitly accepts the request-local DTO handoff and two/three-call replacement. Record bounded dated supersession/navigation in ADR-0053, ADR-0027 and the G40/current guidance, plus @@ -272,12 +275,19 @@ bootstrap needs its own ownership/build design. ## Architecture Tests -These are mandatory obligations, not passing-test or Implemented-catalogue claims. -At acceptance, register canonical rule names and their owning test paths as -Registered in the -[architecture-test catalogue](../standards/21-architecture-tests-catalogue.md) -before dependent production code. Mark a rule Implemented only after its -executable proof and planted failing/passing controls pass: +The acceptance commit registers the following canonical rules and planned owning +test paths in the +[architecture-test catalogue](../standards/21-architecture-tests-catalogue.md#p02d-6-request-local-bootstrap-admission-controls): + +- `Public_Admission_Uses_Only_The_Active_Native_Context` +- `Public_Bootstrap_Refusals_Preserve_Exact_Http_Responses` +- `Public_Admission_Reuses_One_Immutable_Snapshot_Per_Request` +- `Public_Admission_Ends_With_The_Native_Request` +- `Public_Admission_Preserves_Production_Ingress_Boundaries` + +All five are Registered, not passing-test or Implemented claims. Mark a rule +Implemented only after its executable proof and planted failing/passing controls +pass. Together they own these mandatory obligations: - Actual Next 15.5.27 production context propagation and same-request snapshot reuse; missing, duplicated, disposed or mismatched context fails closed before consumers. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 13968ea3..95b2065c 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -70,13 +70,13 @@ an amendment is not a lifecycle status change. | 0052 | [Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md) | Accepted 2026-10-03: host-resolved bootstrap, immutable host ceiling, mandatory marked dispatch and read-only ambient frames; ADR-0036/0040 Amendment 8; P02d-4 Steps 1–3 deliver host provenance, physical read-only frames and all institution GET/HEAD reads; both Step 3 review rounds pass; Step 4 delivers OpenAPI/SDK/CI controls; both review rounds and focused fix verification pass; six real required CI jobs and live sixth-check activation verified; P02d-4 complete and merged through PR #25 on 2026-10-08 ([Amendment 13](0052-anonymous-public-read-boundary.md#amendment-13--merge-closeout-2026-10-08)) | | 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/three-call accounting. Isolated feasibility verified; P02d-6 remediation implementation and production proofs pending | ## Proposed ADRs | # | Title | Topic | Target phase / decision point | |---|---|---|---| | 0049 | [Institution Sites and an Optional Course Marketplace](0049-institution-sites-and-course-marketplace.md) | Direction endorsed 2026-10-02; architecture still Proposed, no gate accepted | P02d-2 preparation hold released; remaining contracts before proposed Phase 09a's first consumers | -| 0055 | [Handle Public Renderer Bootstrap Failures in Middleware](0055-public-renderer-bootstrap-failures.md) | One live middleware bootstrap with an immutable same-request snapshot; exact 429/503 and revised call accounting proposed | P02d-6 remediation; feasibility proof and approval before dependent implementation | The maintainer endorsed the marketplace direction and accepted ADR-0050/0051 and the P02d-2 package on 2026-10-02. Their rows are now in Active ADRs; ADR-0049 remains diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index c0f65188..481852a2 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5168,6 +5168,74 @@ matrix and active/late distinction, and approves without findings. Both reviews inspect evidence rather than run another build. The root's separate replay passes the same matrix and both negative controls. No review constitutes ADR acceptance. +##### ADR-0055 acceptance and implementation plan (2026-10-10) + +**Accepted — 2026-10-10; implementation not yet delivered.** After the isolated +Next 15.5.27 feasibility experiment and two independent feasibility reviews, the +maintainer explicitly approves revised +[ADR-0055](../decisions/0055-public-renderer-bootstrap-failures.md) and dependent +implementation. The earlier draft/review/feasibility notes record their own +pending-approval boundaries; this dated acceptance supersedes those current-status +readings without rewriting them. The decision-only acceptance commit precedes +production changes. All work stays on development. + +**Bounded G40 supersession.** The original G40 acceptance and its three/five-call +delivery remain historical. ADR-0055 replaces only duplicate bootstrap ownership, +request-local snapshot handoff and call accounting: middleware owns one live site +read and exact neutral 404/429/503; RSC re-verifies provenance and consumes the +immutable snapshot bound to that native request. A completed product document +costs two API calls, a fixed status/scaffold document one, and a followed missing +detail three. Actual HEAD/RSC/prefetch and Flight fallback counts require separate +production proof. Changes after bootstrap appear in the next request's site value; +content eligibility still runs live at the API. + +Renderer Retry-After forwarding accepts only eligible parsed integer zero-to-sixty +delta-seconds on supported 429/503 cases. Ineligible values are omitted without +clamping or invention. API refusal selection/metadata, numeric quotas, content +HTTP 200/noindex, cursor SEO and the local missing-detail 307-to-404 chain remain +unchanged. ADR-0053/0027/0054 carry dated bounded supersession navigation. + +Five canonical rules are Registered before code in the +[catalogue](../standards/21-architecture-tests-catalogue.md#p02d-6-request-local-bootstrap-admission-controls): +active native context, exact bootstrap HTTP responses, one immutable per-request +snapshot, native lifecycle completion and preserved production ingress boundaries. +Registered status is a commitment, not an executable or passing-test claim. + +###### Implementation and review sequence + +1. **Native context, facade and lifecycle units.** Add the sole versioned native + ALS holder and server-only facade, binding verified host/peer/method/target to a + fresh pending/ready/closed request store. Publish a bounded deeply immutable + snapshot only once; cancel/clear exactly once on finish/close/shutdown. Unit + controls cover missing/duplicate/mismatched context, abort, late publication, + deep immutability and overlapping identical requests. Extend source fences only + for this explicit owner/facade boundary. Commit, run fresh independent review + round 1, verify/fix/commit findings, then use new reviewers for round 2 and + verify/fix/commit its findings. +2. **Middleware publication and RSC consumption.** Middleware owns one bootstrap, + bounded Retry-After and refusal/redirect terminal states; RSC/resource/i18n + consumers use only the verified ready snapshot and native cancellation signal. + Remove the second site read without changing locale authority or content-state + policy. Add mapping and fault controls, update affected unit expectations, and + commit. Complete the same two fresh independent review/fix rounds before Step 3. +3. **Actual production proof and closeout.** Exercise the shipped native launcher, + pinned Next and real API/PostgreSQL as `learnstack_app`: first-wire refusals, + zero Education/theme after failure, exact counts, identical/differing concurrent + host/locale requests, HTML/RSC/prefetch/status/scaffolds, next-request freshness, + keep-alive, abort/shutdown/HMR and valid HEAD late work with neutral diagnostics. + Retain stock-launch, method/upgrade, exempt asset/health, no build bootstrap and + private-value containment controls. Plant failures beside clean controls. Run + affected full suites/build/lint/typecheck, catalogue and local link/fragment + checks; commit and complete both fresh independent review/fix rounds. Only then + mark proven rules Implemented, update delivery navigation and the existing PR, + and verify fresh exact-head CI. Merge remains a maintainer action. + +No step or review round is complete at acceptance. Missing propagation or lifecycle +proof cannot be replaced by another bootstrap, DTO headers, shared data or an +unapproved response owner; a required architectural change returns for a new +decision. P02d-7 and Phase 06/11 retain their named browser/accessibility/hardening +work. No new manual browser/VoiceOver result is implied by this approval. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/standards/07-frontend-architecture.md b/docs/standards/07-frontend-architecture.md index 0da04ef3..ef2f598f 100644 --- a/docs/standards/07-frontend-architecture.md +++ b/docs/standards/07-frontend-architecture.md @@ -12,6 +12,8 @@ configured caller and dynamic rendering derive from [ADR-0053](../decisions/0053-trusted-public-server-rendering.md); native admission and redirect/query rules derive from [ADR-0054](../decisions/0054-bounded-public-renderer-admission.md). Public UI localization derives from [ADR-0027](../decisions/0027-frontend-i18n.md); +single-bootstrap ownership derives from +[ADR-0055](../decisions/0055-public-renderer-bootstrap-failures.md); page composition follows the [Accepted P02d-6 package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09). @@ -133,6 +135,32 @@ evidence belongs to the [delivery record](../roadmap/phase-02d-walking-skeleton. path select the destination. Membership-first precedence and redirect statuses remain those of the [entry matrix](../roadmap/phase-02d-walking-skeleton.md#public-entry-matrix). +## Request-Local Bootstrap Admission + +**ADR-0055 Accepted — 2026-10-10; replacement implementation pending.** +Middleware owns one live site bootstrap and its exact neutral 404/429/503 before +rendering. A native-created context binds the captured host/peer/method/signed +target and request lifetime. Successful entry publishes one bounded, deeply +immutable validated Site DTO; RSC re-verifies provenance and consumes that exact +request's snapshot without another site call. No header DTO, context lookup, +alternate holder or cross-request representation is permitted. + +Native finish/close/shutdown disposes the store once and cancels transport. Late +work cannot publish or begin downstream reads; response finish does not prove all +React work ended. Active-request context defects fail closed as internal lifecycle +failures, not API 404. Actual pinned-Next security/lifecycle proofs are mandatory. +Content eligibility remains a live API decision; changes after the sole bootstrap +appear in the next request's site snapshot. + +The accepted replacement costs two API calls for a completed product document, +one for a completed fixed status/scaffold document and three for a followed +missing-detail chain. HEAD/RSC/prefetch and Flight fallback require separately +proven counts. Visitor/peer limits remain API-call budgets. Until the +[implementation plan](../roadmap/phase-02d-walking-skeleton.md#adr-0055-acceptance-and-implementation-plan-2026-10-10) +is delivered, runtime retains its earlier duplicate-read three/five-call paths. +[Standards 09](09-error-handling.md#public-page-status-and-recovery) owns refusal +and bounded renderer Retry-After rules. + ## Locale Resolution P02d-4 accepts one required query `locale` for Education reads. Canonicalize the @@ -262,8 +290,9 @@ and unavailable presentation use bounded fallbacks. Metadata shares the page's request-local content read: verified live host/local segments only, actual eligible alternate slugs, noindex pagination with a first-page canonical and no misleading resource alternates on errors. Full menus, media, preview and Studio remain -Phase 06. Normal documents use three API calls; following a missing document's -status redirect uses five total. No retry or shared DTO cache is added. +Phase 06. The accepted replacement and current delivery boundary live in +[Request-Local Bootstrap Admission](#request-local-bootstrap-admission); no retry +or shared DTO cache is added. ### Public source fence scope diff --git a/docs/standards/09-error-handling.md b/docs/standards/09-error-handling.md index 26de5d75..e9bf8951 100644 --- a/docs/standards/09-error-handling.md +++ b/docs/standards/09-error-handling.md @@ -6,6 +6,8 @@ Public-read additions derive from [ADR-0052](../decisions/0052-anonymous-public-read-boundary.md). Public UI message mapping derives from [ADR-0027](../decisions/0027-frontend-i18n.md) and the [Accepted P02d-6 package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09). +Bootstrap response ownership and renderer Retry-After qualification derive from +[ADR-0055](../decisions/0055-public-renderer-bootstrap-failures.md). How LearnStack represents, propagates, surfaces, and recovers from failures. @@ -450,13 +452,31 @@ original URL. A fresh bootstrap refusal remains neutral. Known content-call failures are translated **HTTP 200 noindex** page states: invalid cursor has a relative reset link, rate limiting has a retry-later state, and transport/invalid-response/unavailable or unknown outcomes have the bounded -unavailable state. They do not claim HTTP 400/429/503. Bootstrap retains its real -404/429/503 and bounded Retry-After. Unexpected framework failures retain -pre-stream 500/post-stream 200 behavior; `error.tsx` cannot choose arbitrary status. +unavailable state. They do not claim HTTP 400/429/503. Unexpected framework failures +retain pre-stream 500/post-stream 200 behavior; `error.tsx` cannot choose arbitrary +status. Plain same-host relative anchors trigger fresh document requests. Admission completes before loading boundaries flush; [Standards 07](07-frontend-architecture.md#routing) owns placement and the shared request-local loader. +**ADR-0055 Accepted — 2026-10-10; replacement implementation pending.** +Middleware owns the sole live bootstrap and emits neutral 404 for invalid entry +or API 404, 429 for valid API 429 and 503 for other site/transport/configuration +failures. Responses are no-store with fixed plain-text copy and bodyless HEAD; +failed bootstrap permits no Education operation or newly emitted tenant theme. +RSC consumes only the validated same-request snapshot. An active-request context +defect is a sanitized lifecycle failure, not API 404 or another read. Completion +refusal cannot rewrite an already completed response. + +Forward optional parsed Retry-After only for closed supported 429/503 error cases, +as integer delta-seconds from zero through sixty inclusive. Preserve eligible +values; omit malformed/date/negative/out-of-range values without clamping or +inventing a retry. This qualifies renderer forwarding only; the API's selected +refusal metadata and accounting remain unchanged. The +[implementation plan](../roadmap/phase-02d-walking-skeleton.md#adr-0055-acceptance-and-implementation-plan-2026-10-10) +owns production proofs and review status. Earlier duplicate-read behavior remains +the runtime baseline until that replacement is delivered. + ### User-Facing Copy - Never show stack traces. diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index e3bd8bbb..19619efb 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -6,7 +6,9 @@ [ADR-0054 Bounded Public Renderer Admission](../decisions/0054-bounded-public-renderer-admission.md) (accepted replacement proofs implemented), [ADR-0027 Frontend UI Localization](../decisions/0027-frontend-i18n.md) -(P02d-6 proofs implemented; manual smoke recorded). The catalogue grows as +(P02d-6 proofs implemented; manual smoke recorded), +[ADR-0055 Public Renderer Bootstrap Failures](../decisions/0055-public-renderer-bootstrap-failures.md) +(replacement admission proofs Registered). The catalogue grows as subsequent ADRs and phases land their tests; per-test ownership stays with the originating ADR / standard. @@ -4327,6 +4329,99 @@ owns execution, review and manual evidence. assertion controls; this does not prove browser zoom or WCAG conformance. - **Phase:** 02d (P02d-6). +## P02d-6 request-local bootstrap admission controls + +**Accepted — 2026-10-10; implementation pending.** ADR-0055 registers the five +rules below before dependent code. Planned files are ownership commitments, not +claims that new tests exist or pass. Existing P5/P6 controls remain mandatory. +The [implementation plan](../roadmap/phase-02d-walking-skeleton.md#adr-0055-acceptance-and-implementation-plan-2026-10-10) +owns delivery and two independent review rounds per step. + +### `Public_Admission_Uses_Only_The_Active_Native_Context` + +- **Asserts:** the native-created, versioned server-only ALS holder binds a fresh + request to its captured host/peer/method/signed target. Middleware and RSC share + that actual holder/store through the pinned production Next build; missing, + duplicated, disposed or mismatched context fails closed without header lookup, + another site read or an alternate holder. Planted failures and clean controls + exercise both facade admission and actual framework propagation. +- **Source:** ADR-0055; accepted P02d-6 G40 supersession. +- **Type:** frontend runtime + actual production Next/API. **Kind:** behavioural. +- **Status:** **Registered** — planned + `frontend/apps/web/src/server/public-admission-runtime.test.ts`, + `frontend/apps/web/src/server/public-admission.test.ts` and existing + `PublicServerRenderingTests` / `verify-public-rendering.mjs` integration. +- **Phase:** 02d (P02d-6 remediation Steps 1–3). + +### `Public_Bootstrap_Refusals_Preserve_Exact_Http_Responses` + +- **Asserts:** middleware owns first-wire bootstrap 404/429/503, no-store and + bodyless HEAD. Only supported 429/503 cases forward parsed Retry-After from + zero through sixty unchanged; malformed/date/negative/out-of-range values are + omitted. Timeout, invalid/oversized responses and configuration/context faults + are neutral 503. Failed bootstrap permits no Education operation, newly emitted + tenant theme, private marker or raw diagnostic. Broken refusal and containment + controls must fail beside positive controls. +- **Source:** ADR-0055; ADR-0054 Amendment 8. +- **Type:** frontend mapping + actual production HTTP. **Kind:** behavioural. +- **Status:** **Registered** — planned extensions to + `frontend/apps/web/src/middleware.test.ts`, + `frontend/apps/web/src/server/public-request.test.ts` and existing + `PublicServerRenderingTests` / `verify-public-rendering.mjs` integration. +- **Phase:** 02d (P02d-6 remediation Steps 2–3). + +### `Public_Admission_Reuses_One_Immutable_Snapshot_Per_Request` + +- **Asserts:** one bounded, deeply immutable validated Site DTO is published only + after successful middleware entry and shared by metadata/layout/page/UI/error + consumers without another bootstrap. Completed product/status documents cost + two/one calls; followed missing-detail GET costs three. Identical and differing + host/locale requests overlap without shared values; the next request reads live + state. HTML/RSC/prefetch and Flight-to-document fallback have separately proven + counts, while content 200/noindex and missing 307-to-404 remain unchanged. +- **Source:** ADR-0055; accepted P02d-6 G40 supersession. +- **Type:** frontend snapshot/memo + production rendering. **Kind:** behavioural. +- **Status:** **Registered** — planned + `frontend/apps/web/src/server/public-admission-runtime.test.ts`, + `frontend/apps/web/src/server/public-admission.test.ts`, extensions to existing + `frontend/apps/web/src/server/public-request.test.ts` and + `PublicServerRenderingTests` / `verify-public-rendering.mjs` integration. +- **Phase:** 02d (P02d-6 remediation Steps 1–3). + +### `Public_Admission_Ends_With_The_Native_Request` + +- **Asserts:** finish, early close and shutdown close each store once, clear its + snapshot/listeners and cancel bootstrap/content transport. Publication and + downstream work refuse late continuations, including valid HEAD work after + response finish, without exposing the context/DTO in diagnostics. Keep-alive + and HMR do not reuse snapshots. Active-context/snapshot observations return to + zero; this does not claim all React work or captured local references ended. +- **Source:** ADR-0055. +- **Type:** runtime lifecycle + native production fixture. **Kind:** behavioural. +- **Status:** **Registered** — planned + `frontend/apps/web/src/server/public-admission-runtime.test.ts`, + `frontend/apps/web/src/server/public-admission.test.ts` and extensions to + existing `PublicServerRenderingTests`, `verify-public-rendering.mjs` and + `frontend/apps/web/scripts/verify-ingress.mjs`. +- **Phase:** 02d (P02d-6 remediation Steps 1–3). + +### `Public_Admission_Preserves_Production_Ingress_Boundaries` + +- **Asserts:** actual product/status/scaffold HTML/RSC, navigation and error paths + preserve provenance, API admission, READ ONLY/RLS and server-only containment. + Invalid host/locale/stamp, stock-launch bypass and planted broken context are + refused. Native method/upgrade, exempt asset/health fallback, no build bootstrap + and development HMR controls remain effective. No header DTO carrier, shared + representation cache, fallback context or private client asset is introduced. +- **Source:** ADR-0055; ADR-0052/0053/0054. +- **Type:** source/build + actual production Next/API/PostgreSQL. **Kind:** + structural + behavioural. +- **Status:** **Registered** — planned extensions to existing + `frontend/apps/web/src/test/public-boundaries.test.ts`, + `PublicServerRenderingTests`, `verify-public-rendering.mjs` and + `frontend/apps/web/scripts/verify-ingress.mjs`, with facade/runtime unit controls. +- **Phase:** 02d (P02d-6 remediation Steps 1–3). + ## References - [ADR-0003 Tenant Isolation Defense in Depth](../decisions/0003-tenant-isolation-defense-in-depth.md) (Amendment 3) From 166ffb3a022ffbc766b66872c51d55602c5f0950 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 14:31:58 +0300 Subject: [PATCH 42/58] feat(public): own admission in the native request lifetime Install one native context so separately bundled consumers can share a closed immutable snapshot without another site read or cross-request storage. Bind handles to the active native lifetime and refuse late use. ADR: 0055 --- docs/roadmap/phase-02d-walking-skeleton.md | 28 + frontend/apps/web/scripts/public-server.mjs | 29 +- .../src/server/configured-public-client.ts | 8 +- .../server/public-admission-runtime.test.ts | 509 ++++++++++++++++++ .../src/server/public-admission-runtime.ts | 244 +++++++++ .../web/src/server/public-admission.test.ts | 121 +++++ .../apps/web/src/server/public-admission.ts | 48 ++ .../apps/web/src/server/public-api-limits.ts | 3 + .../web/src/test/public-boundaries.test.ts | 41 ++ .../web/src/test/public-boundary-analysis.ts | 61 ++- frontend/apps/web/tsconfig.server.json | 2 +- 11 files changed, 1079 insertions(+), 15 deletions(-) create mode 100644 frontend/apps/web/src/server/public-admission-runtime.test.ts create mode 100644 frontend/apps/web/src/server/public-admission-runtime.ts create mode 100644 frontend/apps/web/src/server/public-admission.test.ts create mode 100644 frontend/apps/web/src/server/public-admission.ts create mode 100644 frontend/apps/web/src/server/public-api-limits.ts diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 481852a2..dee37f89 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5236,6 +5236,34 @@ unapproved response owner; a required architectural change returns for a new decision. P02d-7 and Phase 06/11 retain their named browser/accessibility/hardening work. No new manual browser/VoiceOver result is implied by this approval. +###### ADR-0055 Step 1 — Native request context and lifecycle + +**Implemented — 2026-10-10; independent review rounds pending.** The native +launcher installs the sole versioned ALS holder before Next preparation and wraps +each admitted HTTP delegation in a fresh verified binding. Server-only consumers +retrieve the existing holder lazily; no bundled runtime constructs a replacement. +The holder copies only the closed public Site fields, bounds the copied snapshot +to the existing decoded-response ceiling and freezes its nested values. Refusal, +finish, close, abort and shutdown prevent later publication/use and release the +holder's snapshot reference and owned listeners. Request-input `close` and handler +promise resolution do not terminate the response lifetime. + +Forty-eight focused unit cases cover state/binding conflicts, source mutation, +the inclusive UTF-8 byte ceiling, eight overlapping identical requests, escaped +handles, singleton descriptor checks and completed HEAD/late work. Source fences +refuse runtime imports of the native owner, facade fallback construction and +Client Component access, with dirty/clean controls. The shared transport limits +retain the existing ten-second and 8 MiB values. + +The guarded frontend run passes **1,284** cases: **1,224 web and 60 SDK**, with zero +skips/todos. Typecheck, lint, production build and all **198 architecture cases** +pass. Native ingress verification and all **19 setup/cleanup controls** pass, +including production upgrades, development HMR, signal/IPC/EPIPE and owned +fixture cleanup. These unit/native-foundation results do not claim the complete actual +middleware-to-RSC handoff: Step 2 still owns publication/consumption and removal +of the second bootstrap; Step 3 owns actual product integration and lifecycle +proofs. The five new catalogue rules remain Registered until that proof closes. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/frontend/apps/web/scripts/public-server.mjs b/frontend/apps/web/scripts/public-server.mjs index 7ddc3a53..9e99bcef 100644 --- a/frontend/apps/web/scripts/public-server.mjs +++ b/frontend/apps/web/scripts/public-server.mjs @@ -8,9 +8,12 @@ import { admitIncomingRequest, loadLocalEnvironment, PUBLIC_ENV_KEYS, + INGRESS_HEADER, publicServerConfiguration, refuseIngress, + verifyProvenance, } from '../.server/ingress.js'; +import { installPublicAdmissionRuntime } from '../.server/public-admission-runtime.js'; const root = fileURLToPath(new URL('../../../../', import.meta.url)); try { @@ -20,6 +23,7 @@ try { const { default: next } = await import('next'); const values = loadLocalEnvironment(root, process.env); const configuration = publicServerConfiguration(values, root); + const admission = installPublicAdmissionRuntime(); // Next reads the same values; root/projection conflicts were refused before prepare. for (const key of PUBLIC_ENV_KEYS) process.env[key] = values[key]; const dev = process.argv.includes('--dev'); @@ -37,13 +41,17 @@ try { }, (request, response) => { if (!admitIncomingRequest(request, configuration.secret)) return refuseIngress(response); + const binding = verifyProvenance(request.headers[INGRESS_HEADER], configuration.secret); + if (!binding) return refuseIngress(response); // No request, credential or provider-error values enter launcher diagnostics. - Promise.resolve(handle(request, response)).catch(() => { - if (!response.headersSent) { - response.writeHead(503, { 'cache-control': 'no-store' }); - response.end('Service unavailable'); - } else response.destroy(); - }); + Promise.resolve() + .then(() => admission.run(binding, request, response, () => handle(request, response))) + .catch(() => { + if (!response.headersSent) { + response.writeHead(503, { 'cache-control': 'no-store' }); + response.end('Service unavailable'); + } else response.destroy(); + }); }, ); server.on('upgrade', (request, socket, head) => { @@ -52,9 +60,18 @@ try { }); server.on('connect', (_request, socket) => socket.destroy()); server.on('error', () => { + admission.shutdown(); console.error('Public HTTPS listener failed'); process.exit(1); }); + const shutdown = () => { + admission.shutdown(); + server.close(); + server.closeAllConnections(); + void app.close().catch(() => console.error('Public server shutdown failed')); + }; + process.once('SIGTERM', shutdown); + process.once('SIGINT', shutdown); server.listen(3000, '127.0.0.1', () => { console.warn('Public HTTPS listener ready on 127.0.0.1:3000'); }); diff --git a/frontend/apps/web/src/server/configured-public-client.ts b/frontend/apps/web/src/server/configured-public-client.ts index c569fc9c..0c5438a9 100644 --- a/frontend/apps/web/src/server/configured-public-client.ts +++ b/frontend/apps/web/src/server/configured-public-client.ts @@ -6,11 +6,9 @@ import { createServerSdk } from '@learnstack/sdk/server'; import type { ServerSdk } from '@learnstack/sdk/server'; import { publicServerConfiguration, verifyProvenance } from './ingress'; +import { MAX_PUBLIC_RESPONSE_BYTES, PUBLIC_API_DEADLINE_MS } from './public-api-limits'; import { canonicalRouteLocale } from './public-entry'; -const TOTAL_DEADLINE_MS = 10_000; -const MAX_DECODED_BYTES = 8 * 1024 * 1024; - type ClientOptions = { readonly traceparent?: string | null; readonly signal?: AbortSignal; @@ -62,7 +60,7 @@ async function boundedResponse( }); controller.signal.addEventListener('abort', rejectInterrupted, { once: true }); callerSignal?.addEventListener('abort', abort, { once: true }); - const deadline = setTimeout(abort, TOTAL_DEADLINE_MS); + const deadline = setTimeout(abort, PUBLIC_API_DEADLINE_MS); let reader: ReadableStreamDefaultReader | undefined; try { if (callerSignal?.aborted) abort(); @@ -91,7 +89,7 @@ async function boundedResponse( const chunk = await Promise.race([reader.read(), interrupted]); if (chunk.done) break; size += chunk.value.byteLength; - if (size > MAX_DECODED_BYTES) { + if (size > MAX_PUBLIC_RESPONSE_BYTES) { controller.abort(); throw new Error('Public API response exceeds the consumer limit'); } diff --git a/frontend/apps/web/src/server/public-admission-runtime.test.ts b/frontend/apps/web/src/server/public-admission-runtime.test.ts new file mode 100644 index 00000000..b8184776 --- /dev/null +++ b/frontend/apps/web/src/server/public-admission-runtime.test.ts @@ -0,0 +1,509 @@ +// @vitest-environment node +import { EventEmitter } from 'node:events'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { describe, expect, it, vi } from 'vitest'; + +import type { IngressContext } from './ingress'; +import { createPublicAdmissionRuntime } from './public-admission-runtime'; +import type { PublicAdmissionRead, PublicAdmissionWrite } from './public-admission-runtime'; +import { MAX_PUBLIC_RESPONSE_BYTES } from './public-api-limits'; +import { publicThemeCss } from '../components/public/theme'; + +const binding: IngressContext = { + host: 'institution.example:3000', + peer: '203.0.113.9', + method: 'GET', + target: '/tr/courses?cursor=inert', +}; +const unavailable = 'Public admission context unavailable'; +const completed = 'Public admission request completed'; + +function site(displayName = 'Institution') { + return { + displayName, + enabledLocales: ['tr', 'en'], + defaultLocale: 'tr', + showPlatformAttribution: true, + theme: { primary: '#123456', background: '#ffffff', foreground: '#000000', muted: '#555555' }, + }; +} + +function lifetime() { + return { + request: Object.assign(new EventEmitter(), { aborted: false }) as IncomingMessage, + response: Object.assign(new EventEmitter(), { + destroyed: false, + writableEnded: false, + }) as ServerResponse, + }; +} + +function expectReleased(owner: ReturnType) { + expect(owner.request.listenerCount('aborted')).toBe(0); + expect(owner.response.listenerCount('finish')).toBe(0); + expect(owner.response.listenerCount('close')).toBe(0); +} + +function deferred() { + let release!: () => void; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +describe('native public admission state and closed snapshot', () => { + it('Public_Admission_Uses_Only_The_Active_Native_Context', () => { + const runtime = createPublicAdmissionRuntime(); + expect(() => runtime.holder.begin(binding)).toThrow(unavailable); + expect(() => runtime.holder.read(binding)).toThrow(unavailable); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + }); + + it('Public_Admission_Reuses_One_Immutable_Snapshot_Per_Request', () => { + const runtime = createPublicAdmissionRuntime(); + const owner = lifetime(); + const input = { + ...site(), + tenantId: 'private identifier', + providerDiagnostic: 'private detail', + }; + Object.assign(input.theme, { privateColor: 'private extra color' }); + runtime.run(binding, owner.request, owner.response, () => { + expect(runtime.counts()).toEqual({ active: 1, snapshots: 0 }); + const writing = runtime.holder.begin({ ...binding }); + expect(Object.isFrozen(writing)).toBe(true); + expect(writing.signal.aborted).toBe(false); + writing.assertActive(); + writing.publish(input); + expect(runtime.counts()).toEqual({ active: 1, snapshots: 1 }); + const first = runtime.holder.read(binding); + const second = runtime.holder.read({ ...binding }); + expect(Object.isFrozen(first)).toBe(true); + expect(first.signal).toBe(writing.signal); + expect(second.site).toBe(first.site); + expect(first.site).not.toBe(input); + const snapshot = first.site as ReturnType; + expect(snapshot).toEqual(site()); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.enabledLocales)).toBe(true); + expect(Object.isFrozen(snapshot.theme)).toBe(true); + expect(Reflect.set(snapshot, 'displayName', 'Mutated')).toBe(false); + expect(Reflect.set(snapshot.enabledLocales, '0', 'ar')).toBe(false); + expect(Reflect.set(snapshot.theme, 'primary', '#ffffff')).toBe(false); + input.displayName = 'Changed source'; + input.enabledLocales.push('ar'); + input.theme.primary = '#abcdef'; + expect(snapshot).toEqual(site()); + expect(JSON.stringify(snapshot)).not.toContain('private'); + first.assertActive(); + }); + expect(runtime.counts()).toEqual({ active: 1, snapshots: 1 }); + owner.response.emit('finish'); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + expectReleased(owner); + }); + + it.each(['begin', 'publish', 'refuse'] as const)( + 'closes a conflicting second %s operation', + (operation) => { + const runtime = createPublicAdmissionRuntime(); + const owner = lifetime(); + runtime.run(binding, owner.request, owner.response, () => { + const writing = runtime.holder.begin(binding); + if (operation === 'begin') expect(() => runtime.holder.begin(binding)).toThrow(unavailable); + else { + writing.publish(site()); + if (operation === 'publish') expect(() => writing.publish(site())).toThrow(unavailable); + else expect(() => writing.refuse()).toThrow(unavailable); + } + expect(writing.signal.aborted).toBe(true); + expect(() => writing.assertActive()).toThrow(completed); + }); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + expectReleased(owner); + }, + ); + + it.each(['pending', 'loading', 'refused'] as const)( + 'refuses reading in the %s phase', + (phase) => { + const runtime = createPublicAdmissionRuntime(); + const owner = lifetime(); + runtime.run(binding, owner.request, owner.response, () => { + if (phase !== 'pending') { + const writing = runtime.holder.begin(binding); + if (phase === 'refused') writing.refuse(); + } + expect(() => runtime.holder.read(binding)).toThrow(unavailable); + }); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + expectReleased(owner); + }, + ); + + it('makes refusal terminal without publishing and aborts transport once', () => { + const runtime = createPublicAdmissionRuntime(); + const owner = lifetime(); + runtime.run(binding, owner.request, owner.response, () => { + const writing = runtime.holder.begin(binding); + const aborted = vi.fn(); + writing.signal.addEventListener('abort', aborted); + writing.refuse(); + expect(writing.signal.aborted).toBe(true); + expect(aborted).toHaveBeenCalledOnce(); + expect(runtime.counts()).toEqual({ active: 1, snapshots: 0 }); + owner.response.emit('finish'); + expect(aborted).toHaveBeenCalledOnce(); + expect(() => writing.publish(site())).toThrow(completed); + }); + expectReleased(owner); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + }); + + it.each([ + { displayName: null }, + { enabledLocales: null }, + { enabledLocales: ['tr', 42] }, + { defaultLocale: null }, + { showPlatformAttribution: 'true' }, + { theme: undefined }, + { theme: [] }, + { theme: { primary: '#123456' } }, + { theme: { ...site().theme, primary: 42 } }, + ])('refuses malformed snapshot shape %j without retaining it', (change) => { + const runtime = createPublicAdmissionRuntime(); + const owner = lifetime(); + runtime.run(binding, owner.request, owner.response, () => { + const writing = runtime.holder.begin(binding); + expect(() => writing.publish({ ...site(), ...change })).toThrow(unavailable); + expect(writing.signal.aborted).toBe(true); + expect(() => runtime.holder.read(binding)).toThrow(completed); + }); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + expectReleased(owner); + }); + + it('masks throwing source accessors and releases the failed snapshot', () => { + const runtime = createPublicAdmissionRuntime(); + const owner = lifetime(); + runtime.run(binding, owner.request, owner.response, () => { + const writing = runtime.holder.begin(binding); + const input = { + ...site(), + get displayName(): string { + throw new Error('private value'); + }, + }; + expect(() => writing.publish(input)).toThrow(unavailable); + expect(writing.signal.aborted).toBe(true); + }); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + expectReleased(owner); + }); + + it('bounds the retained closed DTO in UTF-8 bytes and discards large unknown fields', () => { + const runtime = createPublicAdmissionRuntime(); + const overhead = Buffer.byteLength(JSON.stringify(site(''))); + const exactName = 'x'.repeat(MAX_PUBLIC_RESPONSE_BYTES - overhead); + const permitted = lifetime(); + runtime.run(binding, permitted.request, permitted.response, () => { + runtime.holder.begin(binding).publish(site(exactName)); + const snapshot = runtime.holder.read(binding).site; + expect(Buffer.byteLength(JSON.stringify(snapshot))).toBe(MAX_PUBLIC_RESPONSE_BYTES); + expect(runtime.counts()).toEqual({ active: 1, snapshots: 1 }); + }); + permitted.response.emit('finish'); + expectReleased(permitted); + + for (const name of [exactName + 'x', exactName.slice(0, -1) + 'é']) { + const refused = lifetime(); + runtime.run(binding, refused.request, refused.response, () => { + const writing = runtime.holder.begin(binding); + expect(() => writing.publish(site(name))).toThrow(unavailable); + expect(writing.signal.aborted).toBe(true); + }); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + expectReleased(refused); + } + + const additive = lifetime(); + runtime.run(binding, additive.request, additive.response, () => { + runtime.holder.begin(binding).publish({ ...site(), unknownPrivateValue: exactName }); + expect(runtime.holder.read(binding).site).toEqual(site()); + }); + additive.response.emit('finish'); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + expectReleased(additive); + }); + + it.each([null, { ...site().theme, primary: 'malformed color' }])( + 'retains renderer theme fallback semantics: %j', + (theme) => { + const runtime = createPublicAdmissionRuntime(); + const owner = lifetime(); + runtime.run(binding, owner.request, owner.response, () => { + runtime.holder.begin(binding).publish({ ...site(), theme }); + const snapshot = runtime.holder.read(binding).site as ReturnType; + expect(snapshot.theme).toEqual(theme); + expect(publicThemeCss(snapshot.theme)).toBeNull(); + }); + owner.response.emit('finish'); + expectReleased(owner); + }, + ); + + it.each([ + { host: 'other.example:3000' }, + { peer: '203.0.113.10' }, + { method: 'HEAD' }, + { target: '/en/courses?cursor=inert' }, + ])('closes a ready context when binding differs: %j', (change) => { + const runtime = createPublicAdmissionRuntime(); + const owner = lifetime(); + runtime.run(binding, owner.request, owner.response, () => { + const writing = runtime.holder.begin(binding); + writing.publish(site()); + expect(() => runtime.holder.read({ ...binding, ...change })).toThrow(unavailable); + expect(writing.signal.aborted).toBe(true); + }); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + expectReleased(owner); + }); + + it.each([ + { host: 'INSTITUTION.example:3000' }, + { peer: '::ffff:203.0.113.9' }, + { method: 'POST' }, + { target: '//other.example/' }, + ])('refuses invalid native input before listeners or delegation: %j', (change) => { + const runtime = createPublicAdmissionRuntime(); + const owner = lifetime(); + const work = vi.fn(); + expect(() => + runtime.run({ ...binding, ...change }, owner.request, owner.response, work), + ).toThrow(unavailable); + expect(work).not.toHaveBeenCalled(); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + expectReleased(owner); + }); +}); + +describe('native public admission lifetime and concurrent ownership', () => { + it.each(['finish', 'close', 'aborted'] as const)( + '%s closes once and removes only owned listeners', + (event) => { + const runtime = createPublicAdmissionRuntime(); + const owner = lifetime(); + const otherAborted = vi.fn(); + const otherFinished = vi.fn(); + const otherClosed = vi.fn(); + owner.request.on('aborted', otherAborted); + owner.response.on('finish', otherFinished); + owner.response.on('close', otherClosed); + let reading!: PublicAdmissionRead; + runtime.run(binding, owner.request, owner.response, () => { + runtime.holder.begin(binding).publish(site()); + reading = runtime.holder.read(binding); + }); + const aborted = vi.fn(); + reading.signal.addEventListener('abort', aborted); + const emitter = event === 'aborted' ? owner.request : owner.response; + emitter.emit(event); + emitter.emit(event); + expect(aborted).toHaveBeenCalledOnce(); + expect(reading.signal.aborted).toBe(true); + expect(() => reading.assertActive()).toThrow(completed); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + expect(owner.request.listeners('aborted')).toEqual([otherAborted]); + expect(owner.response.listeners('finish')).toEqual([otherFinished]); + expect(owner.response.listeners('close')).toEqual([otherClosed]); + expect( + { finish: otherFinished, close: otherClosed, aborted: otherAborted }[event], + ).toHaveBeenCalledTimes(2); + }, + ); + + it.each(['aborted', 'destroyed', 'writableEnded'] as const)( + 'refuses a native lifetime already %s', + (state) => { + const runtime = createPublicAdmissionRuntime(); + const owner = lifetime(); + if (state === 'aborted') owner.request.aborted = true; + else Object.assign(owner.response, { [state]: true }); + const work = vi.fn(); + expect(() => runtime.run(binding, owner.request, owner.response, work)).toThrow(completed); + expect(work).not.toHaveBeenCalled(); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + expectReleased(owner); + }, + ); + + it('keeps async delegation active after its promise resolves until native completion', async () => { + const runtime = createPublicAdmissionRuntime(); + const owner = lifetime(); + const gate = deferred(); + const pending = runtime.run(binding, owner.request, owner.response, async () => { + runtime.holder.begin(binding).publish(site()); + await gate.promise; + const reading = runtime.holder.read(binding); + reading.assertActive(); + return reading.site; + }); + expect(runtime.counts()).toEqual({ active: 1, snapshots: 1 }); + gate.release(); + expect(await pending).toEqual(site()); + expect(runtime.counts()).toEqual({ active: 1, snapshots: 1 }); + owner.response.emit('finish'); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + expectReleased(owner); + }); + + it.each(['finish', 'close', 'aborted', 'shutdown'] as const)( + 'refuses late publication after %s', + async (event) => { + const runtime = createPublicAdmissionRuntime(); + const owner = lifetime(); + const gate = deferred(); + let writing!: PublicAdmissionWrite; + const pending = runtime.run(binding, owner.request, owner.response, async () => { + writing = runtime.holder.begin(binding); + await gate.promise; + expect(() => writing.publish(site())).toThrow(completed); + expect(() => writing.assertActive()).toThrow(completed); + expect(() => runtime.holder.read(binding)).toThrow(completed); + }); + if (event === 'shutdown') runtime.shutdown(); + else (event === 'aborted' ? owner.request : owner.response).emit(event); + expect(writing.signal.aborted).toBe(true); + gate.release(); + await pending; + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + expectReleased(owner); + }, + ); + + it('Public_Admission_Ends_With_The_Native_Request', async () => { + const runtime = createPublicAdmissionRuntime(); + const owner = lifetime(); + const head = { ...binding, method: 'HEAD' }; + const gate = deferred(); + const pending = runtime.run(head, owner.request, owner.response, async () => { + runtime.holder.begin(head).publish(site()); + const reading = runtime.holder.read(head); + await gate.promise; + expect(() => reading.assertActive()).toThrow(completed); + expect(() => runtime.holder.read(head)).toThrow(completed); + expect(reading.signal.aborted).toBe(true); + }); + owner.response.emit('finish'); + gate.release(); + await pending; + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + expectReleased(owner); + }); + + it('isolates eight overlapped identical bindings with distinct native stores', async () => { + const runtime = createPublicAdmissionRuntime(); + const gate = deferred(); + const owners = Array.from({ length: 8 }, lifetime); + const snapshots = new Set(); + const signals = new Set(); + const pending = owners.map((owner, index) => + runtime.run(binding, owner.request, owner.response, async () => { + const writing = runtime.holder.begin(binding); + signals.add(writing.signal); + writing.publish(site(`Institution ${index}`)); + const reading = runtime.holder.read(binding); + snapshots.add(reading.site); + await gate.promise; + reading.assertActive(); + expect(runtime.holder.read(binding).site).toBe(reading.site); + expect(reading.site).toEqual(site(`Institution ${index}`)); + owner.response.emit('finish'); + }), + ); + expect(runtime.counts()).toEqual({ active: 8, snapshots: 8 }); + expect(snapshots.size).toBe(8); + expect(signals.size).toBe(8); + gate.release(); + await Promise.all(pending); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + owners.forEach(expectReleased); + }); + + it.each(['write', 'read'] as const)( + 'rejects escaped %s handles inside an identical neighbor', + (kind) => { + const runtime = createPublicAdmissionRuntime(); + const first = lifetime(); + const second = lifetime(); + let escaped!: PublicAdmissionWrite | PublicAdmissionRead; + runtime.run(binding, first.request, first.response, () => { + const writing = runtime.holder.begin(binding); + if (kind === 'read') { + writing.publish(site('First')); + escaped = runtime.holder.read(binding); + } else escaped = writing; + }); + runtime.run(binding, second.request, second.response, () => { + const writing = runtime.holder.begin(binding); + expect(() => escaped.assertActive()).toThrow(unavailable); + expect(escaped.signal.aborted).toBe(true); + expect(writing.signal.aborted).toBe(false); + writing.publish(site('Second')); + expect(runtime.holder.read(binding).site).toEqual(site('Second')); + expect(runtime.counts()).toEqual({ active: 1, snapshots: 1 }); + }); + expectReleased(first); + second.response.emit('finish'); + expectReleased(second); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + }, + ); + + it('closes synchronous delegation faults without replacing the original error', () => { + const runtime = createPublicAdmissionRuntime(); + const owner = lifetime(); + const fault = new Error('owned test failure'); + expect(() => + runtime.run(binding, owner.request, owner.response, () => { + runtime.holder.begin(binding).publish(site()); + throw fault; + }), + ).toThrow(fault); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + expectReleased(owner); + }); + + it('shuts down all stores once and refuses new native requests', () => { + const runtime = createPublicAdmissionRuntime(); + const ready = lifetime(); + const loading = lifetime(); + let first!: PublicAdmissionRead; + let second!: PublicAdmissionWrite; + runtime.run(binding, ready.request, ready.response, () => { + runtime.holder.begin(binding).publish(site()); + first = runtime.holder.read(binding); + }); + runtime.run(binding, loading.request, loading.response, () => { + second = runtime.holder.begin(binding); + }); + expect(runtime.counts()).toEqual({ active: 2, snapshots: 1 }); + runtime.shutdown(); + runtime.shutdown(); + expect(first.signal.aborted).toBe(true); + expect(second.signal.aborted).toBe(true); + expect(() => first.assertActive()).toThrow(completed); + expect(() => second.publish(site())).toThrow(completed); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + expectReleased(ready); + expectReleased(loading); + const later = lifetime(); + const work = vi.fn(); + expect(() => runtime.run(binding, later.request, later.response, work)).toThrow(unavailable); + expect(work).not.toHaveBeenCalled(); + expectReleased(later); + }); +}); diff --git a/frontend/apps/web/src/server/public-admission-runtime.ts b/frontend/apps/web/src/server/public-admission-runtime.ts new file mode 100644 index 00000000..1ffc6ead --- /dev/null +++ b/frontend/apps/web/src/server/public-admission-runtime.ts @@ -0,0 +1,244 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { canonicalAddress, normalizeHost, validTarget } from './ingress.js'; +import type { IngressContext } from './ingress.js'; +import { MAX_PUBLIC_RESPONSE_BYTES } from './public-api-limits.js'; + +/** The native owner is compiled separately; bundled consumers only import its types. */ +export interface PublicAdmissionRead { + readonly site: unknown; + readonly signal: AbortSignal; + readonly assertActive: () => void; +} + +export interface PublicAdmissionWrite { + readonly signal: AbortSignal; + readonly assertActive: () => void; + readonly publish: (site: unknown) => void; + readonly refuse: () => void; +} + +export interface PublicAdmissionHolder { + readonly version: 1; + readonly begin: (binding: IngressContext) => PublicAdmissionWrite; + readonly read: (binding: IngressContext) => PublicAdmissionRead; +} + +type Phase = 'pending' | 'loading' | 'ready' | 'refused' | 'closed'; +type Store = { + readonly binding: IngressContext; + readonly controller: AbortController; + phase: Phase; + site: unknown; + close: () => void; +}; +type RequestLifetime = Pick; +type ResponseLifetime = Pick< + ServerResponse, + 'destroyed' | 'writableEnded' | 'once' | 'removeListener' +>; + +function invariant(): never { + // Only fixed diagnostics: never include a binding, DTO, envelope or provider value. + throw new Error('Public admission context unavailable'); +} + +function completed(): never { + throw new Error('Public admission request completed'); +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Copy the closed public DTO, discarding additive wire members and all source ownership. */ +function snapshot(value: unknown): unknown { + if ( + !record(value) || + typeof value.displayName !== 'string' || + !Array.isArray(value.enabledLocales) || + !value.enabledLocales.every((locale): locale is string => typeof locale === 'string') || + typeof value.defaultLocale !== 'string' || + typeof value.showPlatformAttribution !== 'boolean' + ) + return invariant(); + let theme: Readonly> | null = null; + const sourceTheme = value.theme; + if (sourceTheme !== null) { + if ( + !record(sourceTheme) || + !['primary', 'background', 'foreground', 'muted'].every( + (key) => typeof sourceTheme[key] === 'string', + ) + ) + return invariant(); + // CSS grammar stays with the atomic renderer; a malformed color retains its fallback. + theme = Object.freeze({ + primary: sourceTheme.primary as string, + background: sourceTheme.background as string, + foreground: sourceTheme.foreground as string, + muted: sourceTheme.muted as string, + }); + } + const projected = { + displayName: value.displayName, + enabledLocales: Object.freeze([...value.enabledLocales]), + defaultLocale: value.defaultLocale, + showPlatformAttribution: value.showPlatformAttribution, + theme, + }; + if (Buffer.byteLength(JSON.stringify(projected)) > MAX_PUBLIC_RESPONSE_BYTES) return invariant(); + return Object.freeze(projected); +} + +/** Native-only construction. The active set owns shutdown, never request lookup. */ +export function createPublicAdmissionRuntime() { + const storage = new AsyncLocalStorage(); + const active = new Set(); + let stopped = false; + + function assertStore(store: Store): void { + if (store.phase === 'closed') completed(); + if (storage.getStore() !== store) { + store.close(); + invariant(); + } + } + + function current(binding: IngressContext): Store { + const store = storage.getStore(); + if (!store) return invariant(); + assertStore(store); + if ( + store.binding.host !== binding.host || + store.binding.peer !== binding.peer || + store.binding.method !== binding.method || + store.binding.target !== binding.target + ) { + store.close(); + return invariant(); + } + return store; + } + + function requirePhase(store: Store, phase: Phase): void { + assertStore(store); + if (store.phase !== phase) { + store.close(); + invariant(); + } + } + + const holder: PublicAdmissionHolder = Object.freeze({ + version: 1, + begin(binding: IngressContext): PublicAdmissionWrite { + const store = current(binding); + requirePhase(store, 'pending'); + store.phase = 'loading'; + return Object.freeze({ + signal: store.controller.signal, + assertActive: () => requirePhase(store, 'loading'), + publish(value: unknown) { + requirePhase(store, 'loading'); + try { + store.site = snapshot(value); + store.phase = 'ready'; + } catch { + store.close(); + invariant(); + } + }, + refuse() { + requirePhase(store, 'loading'); + store.phase = 'refused'; + store.controller.abort(); + }, + }); + }, + read(binding: IngressContext): PublicAdmissionRead { + const store = current(binding); + requirePhase(store, 'ready'); + return Object.freeze({ + site: store.site, + signal: store.controller.signal, + assertActive: () => requirePhase(store, 'ready'), + }); + }, + }); + + return Object.freeze({ + holder, + run( + binding: IngressContext, + request: RequestLifetime, + response: ResponseLifetime, + work: () => T, + ): T { + if ( + stopped || + normalizeHost(binding.host) !== binding.host || + canonicalAddress(binding.peer) !== binding.peer || + (binding.method !== 'GET' && binding.method !== 'HEAD') || + !validTarget(binding.target) + ) + return invariant(); + const store: Store = { + binding: Object.freeze({ ...binding }), + controller: new AbortController(), + phase: 'pending', + site: undefined, + close: () => {}, + }; + const close = () => { + if (store.phase === 'closed') return; + store.phase = 'closed'; + store.site = undefined; + active.delete(store); + request.removeListener('aborted', close); + response.removeListener('finish', close); + response.removeListener('close', close); + store.controller.abort(); + }; + store.close = close; + active.add(store); + request.once('aborted', close); + response.once('finish', close); + response.once('close', close); + if (request.aborted || response.destroyed || response.writableEnded) close(); + return storage.run(store, () => { + assertStore(store); + try { + return work(); + } catch (error) { + close(); + throw error; + } + }); + }, + shutdown() { + stopped = true; + for (const store of active) store.close(); + }, + counts() { + return Object.freeze({ + active: active.size, + snapshots: [...active].filter((store) => store.site !== undefined).length, + }); + }, + }); +} + +/** Install once before Next loads its separately bundled middleware and RSC facades. */ +export function installPublicAdmissionRuntime() { + const key = Symbol.for('learnstack.public-admission.v1'); + if (Object.getOwnPropertyDescriptor(globalThis, key)) return invariant(); + const runtime = createPublicAdmissionRuntime(); + Object.defineProperty(globalThis, key, { + value: runtime.holder, + enumerable: false, + configurable: false, + writable: false, + }); + return runtime; +} diff --git a/frontend/apps/web/src/server/public-admission.test.ts b/frontend/apps/web/src/server/public-admission.test.ts new file mode 100644 index 00000000..a1bce386 --- /dev/null +++ b/frontend/apps/web/src/server/public-admission.test.ts @@ -0,0 +1,121 @@ +// @vitest-environment node +import { EventEmitter } from 'node:events'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { describe, expect, it, vi } from 'vitest'; + +import { beginPublicAdmission, readPublicAdmission } from './public-admission'; +import { installPublicAdmissionRuntime } from './public-admission-runtime'; + +const key = Symbol.for('learnstack.public-admission.v1'); +const binding = { + host: 'institution.example:3000', + peer: '203.0.113.9', + method: 'GET', + target: '/tr/courses', +}; +const site = { + displayName: 'Institution', + enabledLocales: ['tr', 'en'], + defaultLocale: 'tr', + showPlatformAttribution: true, + theme: null, +}; +const unavailable = 'Public admission context unavailable'; + +function lifetime() { + return { + request: Object.assign(new EventEmitter(), { aborted: false }) as IncomingMessage, + response: Object.assign(new EventEmitter(), { + destroyed: false, + writableEnded: false, + }) as ServerResponse, + }; +} + +describe('server-only admission facade retrieves the sole native holder', () => { + // One real installation preserves the production nonconfigurable descriptor. + it('lazily refuses absence, shares a native store across module instances and validates the holder', async () => { + expect(Object.getOwnPropertyDescriptor(globalThis, key)).toBeUndefined(); + expect(() => beginPublicAdmission(binding)).toThrow(unavailable); + expect(() => readPublicAdmission(binding)).toThrow(unavailable); + expect(Object.getOwnPropertyDescriptor(globalThis, key)).toBeUndefined(); + const runtime = installPublicAdmissionRuntime(); + try { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, key)!; + expect(descriptor).toEqual({ + value: runtime.holder, + configurable: false, + enumerable: false, + writable: false, + }); + expect(Object.isFrozen(runtime.holder)).toBe(true); + expect(runtime.holder.version).toBe(1); + expect(() => installPublicAdmissionRuntime()).toThrow(unavailable); + expect(() => beginPublicAdmission(binding)).toThrow(unavailable); + expect(() => readPublicAdmission(binding)).toThrow(unavailable); + vi.resetModules(); + const secondFacade = await import('./public-admission'); + expect(secondFacade.readPublicAdmission).not.toBe(readPublicAdmission); + const owner = lifetime(); + runtime.run(binding, owner.request, owner.response, () => { + const writing = beginPublicAdmission(binding); + writing.publish(site); + const first = readPublicAdmission(binding); + const second = secondFacade.readPublicAdmission({ ...binding }); + expect(second.site).toBe(first.site); + expect(second.site).toEqual(site); + expect(second.site).not.toBe(site); + expect(second.signal).toBe(writing.signal); + second.assertActive(); + }); + expect(runtime.counts()).toEqual({ active: 1, snapshots: 1 }); + owner.response.emit('finish'); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + expect(owner.request.listenerCount('aborted')).toBe(0); + expect(owner.response.listenerCount('close')).toBe(0); + expect(owner.response.listenerCount('finish')).toBe(0); + const getDescriptor = Object.getOwnPropertyDescriptor; + const dirtyDescriptors: PropertyDescriptor[] = [ + { ...descriptor, enumerable: true }, + { ...descriptor, configurable: true }, + { ...descriptor, writable: true }, + { ...descriptor, value: null }, + { ...descriptor, value: 1 }, + { ...descriptor, value: { ...runtime.holder } }, + { ...descriptor, value: Object.freeze({ ...runtime.holder, version: 2 }) }, + { ...descriptor, value: Object.freeze({ version: 1, read: runtime.holder.read }) }, + { ...descriptor, value: Object.freeze({ version: 1, begin: runtime.holder.begin }) }, + { ...descriptor, value: Object.freeze({ ...runtime.holder, begin: false }) }, + { ...descriptor, value: Object.freeze({ ...runtime.holder, read: false }) }, + { get: () => runtime.holder, configurable: false, enumerable: false }, + ]; + for (const dirty of dirtyDescriptors) { + const owner = lifetime(); + runtime.run(binding, owner.request, owner.response, () => { + const lookup = vi + .spyOn(Object, 'getOwnPropertyDescriptor') + .mockImplementation((object, name) => + object === globalThis && name === key ? dirty : getDescriptor(object, name), + ); + try { + expect(() => beginPublicAdmission(binding)).toThrow(unavailable); + expect(() => readPublicAdmission(binding)).toThrow(unavailable); + expect(runtime.counts()).toEqual({ active: 1, snapshots: 0 }); + } finally { + lookup.mockRestore(); + } + // The same scope succeeds after the dirty descriptor is removed. + beginPublicAdmission(binding).publish(site); + expect(readPublicAdmission(binding).site).toEqual(site); + }); + owner.response.emit('finish'); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + } + expect(Object.getOwnPropertyDescriptor(globalThis, key)).toEqual(descriptor); + } finally { + runtime.shutdown(); + } + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + }); +}); diff --git a/frontend/apps/web/src/server/public-admission.ts b/frontend/apps/web/src/server/public-admission.ts new file mode 100644 index 00000000..2277ec7e --- /dev/null +++ b/frontend/apps/web/src/server/public-admission.ts @@ -0,0 +1,48 @@ +import 'server-only'; + +import type { IngressContext } from './ingress'; +import type { + PublicAdmissionHolder, + PublicAdmissionRead, + PublicAdmissionWrite, +} from './public-admission-runtime'; +import type { PublicSite } from './public-entry'; + +/** Lazy retrieval permits builds; a request never creates an alternate ALS holder. */ +function holder(): PublicAdmissionHolder { + const descriptor = Object.getOwnPropertyDescriptor( + globalThis, + Symbol.for('learnstack.public-admission.v1'), + ); + const value: unknown = descriptor?.value; + if ( + !descriptor || + descriptor.enumerable || + descriptor.configurable || + descriptor.writable || + typeof value !== 'object' || + value === null || + !Object.isFrozen(value) || + !('version' in value) || + value.version !== 1 || + !('begin' in value) || + typeof value.begin !== 'function' || + !('read' in value) || + typeof value.read !== 'function' + ) + throw new Error('Public admission context unavailable'); + return value as PublicAdmissionHolder; +} + +export function beginPublicAdmission(binding: IngressContext): PublicAdmissionWrite { + return holder().begin(binding); +} + +export function readPublicAdmission( + binding: IngressContext, +): Omit & { readonly site: PublicSite } { + // The sole native holder clones and freezes the SDK-validated closed Site shape. + return holder().read(binding) as Omit & { + readonly site: PublicSite; + }; +} diff --git a/frontend/apps/web/src/server/public-api-limits.ts b/frontend/apps/web/src/server/public-api-limits.ts new file mode 100644 index 00000000..936ed971 --- /dev/null +++ b/frontend/apps/web/src/server/public-api-limits.ts @@ -0,0 +1,3 @@ +/** Shared bounds for the configured transport and its closed admission snapshot. */ +export const PUBLIC_API_DEADLINE_MS = 10_000; +export const MAX_PUBLIC_RESPONSE_BYTES = 8 * 1024 * 1024; diff --git a/frontend/apps/web/src/test/public-boundaries.test.ts b/frontend/apps/web/src/test/public-boundaries.test.ts index 6428cd8a..00ac3d73 100644 --- a/frontend/apps/web/src/test/public-boundaries.test.ts +++ b/frontend/apps/web/src/test/public-boundaries.test.ts @@ -11,6 +11,9 @@ import { describe, expect, it } from 'vitest'; import { ADAPTER, + ADMISSION_RUNTIME, + ADMISSION_FACADE, + admissionBoundaryFindings, buildSourceGraph, cacheFindings, clientBoundaryFindings, @@ -146,6 +149,8 @@ describe('ADR-0053 production public boundaries', () => { expect([...production.files.keys()]).toEqual( expect.arrayContaining([ ADAPTER, + ADMISSION_RUNTIME, + ADMISSION_FACADE, INGRESS, I18N_REQUEST, MIDDLEWARE, @@ -223,6 +228,42 @@ describe('ADR-0053 production public boundaries', () => { ); } }); + it('keeps the native admission owner outside every bundled runtime edge', () => { + expect(production.files.has(ADMISSION_RUNTIME)).toBe(true); + expect(production.files.has(ADMISSION_FACADE)).toBe(true); + expect(production.resolveModule('./public-admission-runtime.js', ADMISSION_FACADE)).toBe( + ADMISSION_RUNTIME, + ); + clean(admissionBoundaryFindings(production), 'Fix: preserve native-only admission ownership.'); + expect(reachable(production, renderRoots)).not.toContain(ADMISSION_RUNTIME); + const imported = graphWith( + ADMISSION_FACADE, + sources[ADMISSION_FACADE]! + + '\nimport { createPublicAdmissionRuntime } from "./public-admission-runtime.js"; createPublicAdmissionRuntime();', + ); + expect(admissionBoundaryFindings(imported)).not.toEqual([]); + for (const mutation of [ + sources[ADMISSION_FACADE]!.replace("import 'server-only';", ''), + sources[ADMISSION_FACADE]! + + '\nimport { AsyncLocalStorage } from "node:async_hooks"; new AsyncLocalStorage();', + sources[ADMISSION_FACADE]! + + '\nObject.defineProperty(globalThis, Symbol.for("fallback"), {value: {}});', + sources[ADMISSION_FACADE]! + + '\nconst install = Object.defineProperty; install(globalThis, Symbol.for("fallback"), {value: {}});', + sources[ADMISSION_FACADE]! + + '\nconst {defineProperties: install} = Object; install(globalThis, {fallback: {value: {}}});', + sources[ADMISSION_FACADE]! + + '\nconst target = globalThis; Object.assign(target, {fallback: {}});', + ]) + expect(admissionBoundaryFindings(graphWith(ADMISSION_FACADE, mutation))).not.toEqual([]); + for (const file of [ADMISSION_RUNTIME, ADMISSION_FACADE]) { + const graph = graphWith( + probe, + `'use client'; import * as admission from ${JSON.stringify('@/server/' + file.split('/').at(-1)!.replace(/\.ts$/, ''))}; export const value = admission;`, + ); + expect(clientBoundaryFindings(graph).some((item) => item.file === probe)).toBe(true); + } + }); it('Public_Renderer_Uses_Trusted_Ingress_And_Server_Only_Transport', () => { const missingMarker: Finding[] = hasServerOnlyMarker(production.files.get(ADAPTER)!) ? [] diff --git a/frontend/apps/web/src/test/public-boundary-analysis.ts b/frontend/apps/web/src/test/public-boundary-analysis.ts index 80bb8e48..0998379e 100644 --- a/frontend/apps/web/src/test/public-boundary-analysis.ts +++ b/frontend/apps/web/src/test/public-boundary-analysis.ts @@ -25,7 +25,57 @@ export const MIDDLEWARE = 'apps/web/src/middleware.ts'; export const PUBLIC_LAYOUT = 'apps/web/src/app/(public)/layout.tsx'; export const I18N_REQUEST = 'apps/web/src/i18n/request.ts'; export const REQUEST_MEMO = 'apps/web/src/server/request-memo.ts'; +export const ADMISSION_RUNTIME = 'apps/web/src/server/public-admission-runtime.ts'; +export const ADMISSION_FACADE = 'apps/web/src/server/public-admission.ts'; export const SDK_SERVER = 'packages/sdk/src/server.ts'; + +/** Native ownership is not a cache exception in the bundled rendering graph. */ +export function admissionBoundaryFindings(graph: SourceGraph): Finding[] { + const findings: Finding[] = []; + for (const [name, targets] of graph.edges) { + if (targets.includes(ADMISSION_RUNTIME)) + findings.push({ + file: name, + line: 1, + reason: 'Fix: only the native launcher imports the admission owner at runtime.', + }); + } + const facade = graph.files.get(ADMISSION_FACADE); + if (!facade) + return [ + ...findings, + { + file: ADMISSION_FACADE, + line: 1, + reason: 'Fix: retain the passive server-only admission facade.', + }, + ]; + if (!hasServerOnlyMarker(facade)) + findings.push(finding(facade, 'Fix: mark the admission facade server-only.')); + walk(facade, (node) => { + if ( + (ts.isImportDeclaration(node) && + runtimeImport(node) && + constantString(node.moduleSpecifier, graph.checker) !== 'server-only') || + (ts.isNewExpression(node) && !globalBuiltin(node.expression, 'Error', graph.checker)) || + (ts.isCallExpression(node) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && node.expression.text === 'require') || + globalBuiltin(node.expression, 'Object.defineProperty', graph.checker) || + globalBuiltin(node.expression, 'Object.defineProperties', graph.checker) || + (globalBuiltin(node.expression, 'Object.assign', graph.checker) && + node.arguments[0] !== undefined && + globalObject(node.arguments[0], graph.checker)))) + ) + findings.push( + finding( + node, + 'Fix: retrieve the native holder lazily; the facade cannot construct or install state.', + ), + ); + }); + return [...findings, ...cacheFindings(graph, [ADMISSION_FACADE])]; +} const HOP_HEADERS = new Set([ 'x-learnstack-host', 'x-learnstack-hop-secret', @@ -344,9 +394,14 @@ export function buildSourceGraph(sources: SourceCensus): SourceGraph { const [pkg, ...path] = specifier.slice('@learnstack/'.length).split('/'); base = `packages/${pkg}/src/${path.length === 0 ? 'index' : path.join('/')}`; } else return undefined; - return [base, base + '.ts', base + '.tsx', base + '/index.ts', base + '/index.tsx'].find( - (name) => files.has(name), - ); + return [ + base, + ...(base.endsWith('.js') ? [base.slice(0, -3) + '.ts'] : []), + base + '.ts', + base + '.tsx', + base + '/index.ts', + base + '/index.tsx', + ].find((name) => files.has(name)); }; const options: ts.CompilerOptions = { noLib: true, diff --git a/frontend/apps/web/tsconfig.server.json b/frontend/apps/web/tsconfig.server.json index c5b830c2..2b1f613b 100644 --- a/frontend/apps/web/tsconfig.server.json +++ b/frontend/apps/web/tsconfig.server.json @@ -9,6 +9,6 @@ "incremental": false, "plugins": [] }, - "include": ["src/server/ingress.ts"], + "include": ["src/server/ingress.ts", "src/server/public-admission-runtime.ts"], "exclude": ["node_modules", ".next"] } From af34e29f2cd3a0c5d85196b5bfbd6ce5e54baecf Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 14:40:37 +0300 Subject: [PATCH 43/58] fix(public): release completed admission lifetimes Prevent escaped admission handles from retaining native HTTP objects through cleanup closures or lazy abort stacks, and make facade installer fences reject ordinary call, apply and bind wrappers. ADR: 0055 --- docs/roadmap/phase-02d-walking-skeleton.md | 14 +++ .../server/public-admission-runtime.test.ts | 93 +++++++++++++++++++ .../src/server/public-admission-runtime.ts | 12 ++- .../web/src/test/public-boundaries.test.ts | 11 +++ .../web/src/test/public-boundary-analysis.ts | 14 ++- 5 files changed, 136 insertions(+), 8 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index dee37f89..7e003520 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5264,6 +5264,20 @@ middleware-to-RSC handoff: Step 2 still owns publication/consumption and removal of the second bootstrap; Step 3 owns actual product integration and lifecycle proofs. The five new catalogue rules remain Registered until that proof closes. +**Review round 1 — `166ffb3`.** Fresh security/lifecycle and proof/structure +reviewers independently run all 326 focused cases. They verify two gaps: completed +handles retain native request/response objects through the disposal closure and +the default abort exception's lazy stack; ordinary `call`/`apply`/`bind` wrappers +also evade the new facade installer fence. The fixes clear the retained closure, +use a fixed primitive cancellation reason on both terminal paths and recognize +the three installer wrappers. Dirty/clean controls cover every wrapper; the +original proof reviewer verifies its finding is closed. An isolated GC regression +retains completed handles while checking that both native objects are released, +with live-object positive controls. Both original and closure-only mutants fail +at the collection assertion for ready/refused paths; the repaired 327-case +focused run, typecheck and lint pass. Actual middleware/RSC integration remains +pending in the next steps. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/frontend/apps/web/src/server/public-admission-runtime.test.ts b/frontend/apps/web/src/server/public-admission-runtime.test.ts index b8184776..f62223a0 100644 --- a/frontend/apps/web/src/server/public-admission-runtime.test.ts +++ b/frontend/apps/web/src/server/public-admission-runtime.test.ts @@ -1,7 +1,12 @@ // @vitest-environment node +import { execFileSync } from 'node:child_process'; import { EventEmitter } from 'node:events'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import type { IncomingMessage, ServerResponse } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import ts from 'typescript'; import { describe, expect, it, vi } from 'vitest'; import type { IngressContext } from './ingress'; @@ -291,6 +296,94 @@ describe('native public admission state and closed snapshot', () => { }); describe('native public admission lifetime and concurrent ownership', () => { + it('collects native request objects while completed and refused writer handles remain alive', () => { + const root = mkdtempSync(join(tmpdir(), 'learnstack-admission-gc-')); + try { + writeFileSync(join(root, 'package.json'), '{"type":"module"}'); + // Compile only the three native sources, independently of build:ingress or + // an existing .server directory. The child owns its explicit GC capability. + for (const name of ['public-admission-runtime', 'public-api-limits', 'ingress']) { + const source = readFileSync(new URL(`./${name}.ts`, import.meta.url), 'utf8'); + const output = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, + }).outputText; + writeFileSync(join(root, `${name}.js`), output); + } + writeFileSync( + join(root, 'proof.mjs'), + `import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { setImmediate } from 'node:timers/promises'; +import { createPublicAdmissionRuntime } from './public-admission-runtime.js'; + +const binding = ${JSON.stringify(binding)}; +const site = ${JSON.stringify(site())}; +const reason = ${JSON.stringify(completed)}; +assert.equal(typeof global.gc, 'function'); + +function retainedWriter(kind) { + const runtime = createPublicAdmissionRuntime(); + const request = Object.assign(new EventEmitter(), { aborted: false }); + const response = Object.assign(new EventEmitter(), { destroyed: false, writableEnded: false }); + let writing; + runtime.run(binding, request, response, () => { + writing = runtime.holder.begin(binding); + if (kind === 'ready') writing.publish(site); + else writing.refuse(); + }); + return { runtime, writing, request: new WeakRef(request), response: new WeakRef(response) }; +} + +async function collectNativeObjects(owner) { + // Yield between GC and WeakRef observations: deref() keeps its target alive + // until the end of the current job. No timer or timing threshold decides this. + for (let turn = 0; turn < 50; turn++) { + await setImmediate(); + global.gc(); + await setImmediate(); + if (owner.request.deref() === undefined && owner.response.deref() === undefined) return true; + } + return false; +} + +for (const kind of ['ready', 'refused']) { + const owner = retainedWriter(kind); + await setImmediate(); + global.gc(); + await setImmediate(); + assert.ok(owner.request.deref(), 'positive control: active request must remain owned'); + assert.ok(owner.response.deref(), 'positive control: active response must remain owned'); + assert.deepEqual(owner.runtime.counts(), { active: 1, snapshots: kind === 'ready' ? 1 : 0 }); + owner.response.deref().emit('finish'); + assert.equal(owner.request.deref().listenerCount('aborted'), 0); + assert.equal(owner.response.deref().listenerCount('finish'), 0); + assert.equal(owner.response.deref().listenerCount('close'), 0); + assert.deepEqual(owner.runtime.counts(), { active: 0, snapshots: 0 }); + assert.equal(await collectNativeObjects(owner), true, + 'completed ' + kind + ' retains native request/response through an escaped writer'); + // Retain and exercise the writer after collection, so a dropped handle cannot + // manufacture the pass. Default DOMException abort reasons can retain frames. + assert.equal(owner.writing.signal.aborted, true); + assert.equal(owner.writing.signal.reason, reason); + assert.throws(() => owner.writing.assertActive(), { message: reason }); + assert.throws(() => owner.writing.publish(site), { message: reason }); + owner.runtime.shutdown(); +} +process.stdout.write('native-lifetimes-collected:ready,refused\\n'); +`, + ); + const output = execFileSync(process.execPath, ['--expose-gc', join(root, 'proof.mjs')], { + encoding: 'utf8', + timeout: 10_000, + maxBuffer: 16 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }); + expect(output).toBe('native-lifetimes-collected:ready,refused\n'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it.each(['finish', 'close', 'aborted'] as const)( '%s closes once and removes only owned listeners', (event) => { diff --git a/frontend/apps/web/src/server/public-admission-runtime.ts b/frontend/apps/web/src/server/public-admission-runtime.ts index 1ffc6ead..2cd46684 100644 --- a/frontend/apps/web/src/server/public-admission-runtime.ts +++ b/frontend/apps/web/src/server/public-admission-runtime.ts @@ -39,6 +39,9 @@ type ResponseLifetime = Pick< 'destroyed' | 'writableEnded' | 'once' | 'removeListener' >; +const release = () => {}; +const completionReason = 'Public admission request completed'; + function invariant(): never { // Only fixed diagnostics: never include a binding, DTO, envelope or provider value. throw new Error('Public admission context unavailable'); @@ -152,7 +155,7 @@ export function createPublicAdmissionRuntime() { refuse() { requirePhase(store, 'loading'); store.phase = 'refused'; - store.controller.abort(); + store.controller.abort(completionReason); }, }); }, @@ -188,7 +191,7 @@ export function createPublicAdmissionRuntime() { controller: new AbortController(), phase: 'pending', site: undefined, - close: () => {}, + close: release, }; const close = () => { if (store.phase === 'closed') return; @@ -198,7 +201,10 @@ export function createPublicAdmissionRuntime() { request.removeListener('aborted', close); response.removeListener('finish', close); response.removeListener('close', close); - store.controller.abort(); + // Escaped handles and late ALS work must not retain the native objects. + store.close = release; + // A fresh default DOMException can retain this stack and its request objects. + store.controller.abort(completionReason); }; store.close = close; active.add(store); diff --git a/frontend/apps/web/src/test/public-boundaries.test.ts b/frontend/apps/web/src/test/public-boundaries.test.ts index 00ac3d73..b3d70cc4 100644 --- a/frontend/apps/web/src/test/public-boundaries.test.ts +++ b/frontend/apps/web/src/test/public-boundaries.test.ts @@ -254,6 +254,17 @@ describe('ADR-0053 production public boundaries', () => { '\nconst {defineProperties: install} = Object; install(globalThis, {fallback: {value: {}}});', sources[ADMISSION_FACADE]! + '\nconst target = globalThis; Object.assign(target, {fallback: {}});', + ...[ + ['defineProperty', 'globalThis, Symbol.for("fallback"), {value: {}}'], + ['defineProperties', 'globalThis, {[Symbol.for("fallback")]: {value: {}}}'], + ['assign', 'globalThis, {[Symbol.for("fallback")]: {}}'], + ].flatMap(([method, args]) => + [ + `Object.${method}.call(Object, ${args});`, + `Object.${method}.apply(Object, [${args}]);`, + `const install = Object.${method}.bind(Object); install(${args});`, + ].map((mutation) => sources[ADMISSION_FACADE]! + '\n' + mutation), + ), ]) expect(admissionBoundaryFindings(graphWith(ADMISSION_FACADE, mutation))).not.toEqual([]); for (const file of [ADMISSION_RUNTIME, ADMISSION_FACADE]) { diff --git a/frontend/apps/web/src/test/public-boundary-analysis.ts b/frontend/apps/web/src/test/public-boundary-analysis.ts index 0998379e..013eff0e 100644 --- a/frontend/apps/web/src/test/public-boundary-analysis.ts +++ b/frontend/apps/web/src/test/public-boundary-analysis.ts @@ -53,6 +53,11 @@ export function admissionBoundaryFindings(graph: SourceGraph): Finding[] { if (!hasServerOnlyMarker(facade)) findings.push(finding(facade, 'Fix: mark the admission facade server-only.')); walk(facade, (node) => { + const installer = (expression: ts.Node) => + ['Object.defineProperty', 'Object.defineProperties', 'Object.assign'].some((name) => + globalBuiltin(expression, name, graph.checker), + ); + const receiver = ts.isCallExpression(node) ? memberReceiver(node.expression) : undefined; if ( (ts.isImportDeclaration(node) && runtimeImport(node) && @@ -61,11 +66,10 @@ export function admissionBoundaryFindings(graph: SourceGraph): Finding[] { (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require') || - globalBuiltin(node.expression, 'Object.defineProperty', graph.checker) || - globalBuiltin(node.expression, 'Object.defineProperties', graph.checker) || - (globalBuiltin(node.expression, 'Object.assign', graph.checker) && - node.arguments[0] !== undefined && - globalObject(node.arguments[0], graph.checker)))) + installer(node.expression) || + (receiver !== undefined && + ['call', 'apply', 'bind'].includes(memberName(node.expression, graph.checker) ?? '') && + installer(receiver)))) ) findings.push( finding( From c9a3c9367f1910078cde31222581f9b31683c67f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 14:48:48 +0300 Subject: [PATCH 44/58] fix(public): close native shutdown and installer alias gaps Keep completed admission ownership bounded even with live development HMR, and prevent static facade aliases from silently bypassing the source fence. ADR: 0055 --- docs/roadmap/phase-02d-walking-skeleton.md | 16 +- frontend/apps/web/scripts/public-server.mjs | 28 +++- frontend/apps/web/scripts/verify-ingress.mjs | 153 +++++++++++------- .../web/src/test/public-boundaries.test.ts | 15 ++ .../web/src/test/public-boundary-analysis.ts | 2 +- 5 files changed, 155 insertions(+), 59 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 7e003520..d62ab381 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5238,7 +5238,7 @@ work. No new manual browser/VoiceOver result is implied by this approval. ###### ADR-0055 Step 1 — Native request context and lifecycle -**Implemented — 2026-10-10; independent review rounds pending.** The native +**Implemented — 2026-10-10; both independent review rounds complete.** The native launcher installs the sole versioned ALS holder before Next preparation and wraps each admitted HTTP delegation in a fresh verified binding. Server-only consumers retrieve the existing holder lazily; no bundled runtime constructs a replacement. @@ -5278,6 +5278,20 @@ at the collection assertion for ready/refused paths; the repaired 327-case focused run, typecheck and lint pass. Actual middleware/RSC integration remains pending in the next steps. +**Review round 2 — `af34e29`.** Fresh runtime and proof reviewers independently +run 367 and 446 focused cases. They verify that ordinary literal object/array +aliases evade the facade installer check and that the new signal handler leaves +a real development HMR connection/process alive. The source check now resolves +those static aliases, with dirty and clean controls. The launcher owns upgraded +sockets, cancels admission, awaits native/Next cleanup and bounds termination. +The native proof keeps HMR open through SIGTERM and SIGINT and requires exit code +zero and socket closure without fixture escalation. The original runtime reviewer +independently repeats both signals on real Next 15.5.27: both close and exit in +7 ms without SIGKILL, and closes the finding. The proof reviewer verifies its +minimal alias fix and all 278 boundary cases. Root revalidation passes 327 focused +cases, typecheck, lint, native ingress and all 19 setup/cleanup controls. Step 1 +is complete; no second-bootstrap removal or full product proof is claimed here. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/frontend/apps/web/scripts/public-server.mjs b/frontend/apps/web/scripts/public-server.mjs index 9e99bcef..0dde8ec6 100644 --- a/frontend/apps/web/scripts/public-server.mjs +++ b/frontend/apps/web/scripts/public-server.mjs @@ -33,6 +33,7 @@ try { const app = next({ dev, hostname: '127.0.0.1', port: 3000, httpServer: upgradeSink }); await app.prepare(); const handle = app.getRequestHandler(); + const upgradeSockets = new Set(); const server = createServer( { cert: readFileSync(configuration.certificate), @@ -56,6 +57,8 @@ try { ); server.on('upgrade', (request, socket, head) => { if (!dev || !admitIncomingRequest(request, configuration.secret)) return socket.destroy(); + upgradeSockets.add(socket); + socket.once('close', () => upgradeSockets.delete(socket)); delegateDevelopmentHmr(request, socket, head, upgradeSink); }); server.on('connect', (_request, socket) => socket.destroy()); @@ -64,11 +67,30 @@ try { console.error('Public HTTPS listener failed'); process.exit(1); }); - const shutdown = () => { + let stopping = false; + const shutdown = async () => { + if (stopping) return; + stopping = true; admission.shutdown(); - server.close(); + // Node excludes upgraded sockets from closeAllConnections; Next's custom + // server also leaves development handles alive after app.close(). + for (const socket of upgradeSockets) socket.destroy(); + upgradeSockets.clear(); + const deadline = setTimeout(() => { + console.error('Public server shutdown failed'); + process.exit(1); + }, 5000); + deadline.unref(); + const closed = new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); server.closeAllConnections(); - void app.close().catch(() => console.error('Public server shutdown failed')); + upgradeSink.close(); + const results = await Promise.allSettled([closed, app.close()]); + clearTimeout(deadline); + const failed = results.some((result) => result.status === 'rejected'); + if (failed) console.error('Public server shutdown failed'); + process.exit(failed ? 1 : 0); }; process.once('SIGTERM', shutdown); process.once('SIGINT', shutdown); diff --git a/frontend/apps/web/scripts/verify-ingress.mjs b/frontend/apps/web/scripts/verify-ingress.mjs index bd849e6f..d7dcd2f5 100644 --- a/frontend/apps/web/scripts/verify-ingress.mjs +++ b/frontend/apps/web/scripts/verify-ingress.mjs @@ -778,67 +778,112 @@ async function run() { 0, 'Unsupported development upgrade reached Next', ); - await new Promise((resolve, reject) => { + for (const signal of ['SIGTERM', 'SIGINT']) { + const child = + signal === 'SIGTERM' + ? development + : start([join(app, 'scripts/public-server.mjs'), '--dev'], true); + if (child !== development) await ready(child, true, 3000); + await verifyHmrShutdown(child, signal); + } + assert.deepEqual(upgrades, [ + { surface: 'sink', sanitized: true }, + { surface: 'sink', sanitized: true }, + ]); + assert.equal(diagnosticsLeaked(), false, 'Private material entered development diagnostics'); + + async function verifyHmrShutdown(child, signal) { const client = new WebSocket('wss://127.0.0.1:3000/_next/webpack-hmr', { ca: certificate, servername: 'localhost', rejectUnauthorized: true, headers: { 'X-Matched-Path': 'attacker', 'X-Middleware-Subrequest': 'attacker' }, }); - let accepted = false; - let observedFrame = false; - let retained = false; - let retention; - const deadline = setTimeout(() => { + try { + await new Promise((resolve, reject) => { + let accepted = false; + let retention; + const deadline = setTimeout( + () => reject(new Error('Development HMR handshake/frame deadline')), + 12000, + ); + const cleanup = () => { + clearTimeout(deadline); + clearTimeout(retention); + client.off('error', fail); + client.off('close', fail); + }; + const fail = () => { + cleanup(); + reject(new Error('Development HMR closed before retained acceptance')); + }; + client.on('upgrade', (response) => { + accepted = response.statusCode === 101; + }); + client.once('error', fail); + client.once('close', fail); + client.once('message', (bytes) => { + try { + const frame = JSON.parse(bytes.toString()); + assert(typeof frame.action === 'string', 'HMR must send a real protocol frame'); + } catch { + fail(); + return; + } + // The accepted connection must outlive the launcher's handshake deadline. + retention = setTimeout(() => { + cleanup(); + if (accepted && client.readyState === WebSocket.OPEN) resolve(); + else reject(new Error('Development HMR did not retain its accepted connection')); + }, 5100); + }); + }); + assert.equal( + (await observe(child)).sinkCalls, + 1, + 'Exactly the accepted HMR upgrade must reach Next', + ); + assert.equal(client.readyState, WebSocket.OPEN, 'HMR must remain open when shutdown begins'); + // No stopTestChild here: its SIGKILL escalation would hide a hung launcher. + await new Promise((resolve, reject) => { + let exited = false; + let closed = false; + const deadline = setTimeout(() => { + cleanup(); + reject(new Error('Native development shutdown deadline')); + }, 8000); + const complete = () => { + if (exited && closed) { + cleanup(); + resolve(); + } + }; + const onExit = (code, childSignal) => { + if (code !== 0 || childSignal !== null) { + cleanup(); + reject(new Error('Native development shutdown did not exit cleanly')); + return; + } + exited = true; + complete(); + }; + const onClose = () => { + closed = true; + complete(); + }; + const cleanup = () => { + clearTimeout(deadline); + child.off('exit', onExit); + client.off('close', onClose); + }; + child.once('exit', onExit); + client.once('close', onClose); + child.kill(signal); + }); + } finally { client.terminate(); - reject(new Error('Development HMR handshake/frame deadline')); - }, 12000); - client.on('upgrade', (response) => { - accepted = response.statusCode === 101; - }); - client.once('message', (bytes) => { - try { - const frame = JSON.parse(bytes.toString()); - assert(typeof frame.action === 'string', 'HMR must send a real protocol frame'); - observedFrame = true; - } catch { - client.terminate(); - clearTimeout(deadline); - reject(new Error('Invalid development HMR frame')); - return; - } - // The accepted connection must outlive the launcher's handshake deadline. - retention = setTimeout(() => { - clearTimeout(deadline); - if (!accepted || client.readyState !== WebSocket.OPEN) { - client.terminate(); - reject(new Error('Development HMR did not retain its accepted connection')); - } else { - retained = true; - client.close(); - } - }, 5100); - }); - client.once('error', (error) => { - clearTimeout(deadline); - clearTimeout(retention); - reject(error); - }); - client.once('close', () => { - clearTimeout(deadline); - clearTimeout(retention); - if (accepted && observedFrame && retained) resolve(); - else reject(new Error('Development HMR closed before retained acceptance')); - }); - }); - assert.equal( - (await observe(development)).sinkCalls, - 1, - 'Exactly the accepted HMR upgrade must reach Next', - ); - assert.deepEqual(upgrades, [{ surface: 'sink', sanitized: true }]); - await observe(development); - assert.equal(diagnosticsLeaked(), false, 'Private material entered development diagnostics'); + } + } console.warn( 'Native ingress: TLS, GET/HEAD admission, disabled image optimizer with configuration mutant, query redirects, production closure, DEBUG containment and development HMR passed.', ); diff --git a/frontend/apps/web/src/test/public-boundaries.test.ts b/frontend/apps/web/src/test/public-boundaries.test.ts index b3d70cc4..4067a978 100644 --- a/frontend/apps/web/src/test/public-boundaries.test.ts +++ b/frontend/apps/web/src/test/public-boundaries.test.ts @@ -263,10 +263,25 @@ describe('ADR-0053 production public boundaries', () => { `Object.${method}.call(Object, ${args});`, `Object.${method}.apply(Object, [${args}]);`, `const install = Object.${method}.bind(Object); install(${args});`, + `const intrinsics = {install: Object.${method}}; intrinsics.install(${args});`, + `const [install] = [Object.${method}]; install(${args});`, + `const intrinsics = {install: Object.${method}}; intrinsics.install.call(Object, ${args});`, + `const intrinsics = {install: Object.${method}}; intrinsics.install.apply(Object, [${args}]);`, + `const intrinsics = {install: Object.${method}}; const install = intrinsics.install.bind(Object); install(${args});`, ].map((mutation) => sources[ADMISSION_FACADE]! + '\n' + mutation), ), ]) expect(admissionBoundaryFindings(graphWith(ADMISSION_FACADE, mutation))).not.toEqual([]); + for (const mutation of [ + 'const intrinsics = {install: (..._args: unknown[]) => null}; intrinsics.install(globalThis, Symbol.for("fallback"), {});', + 'const [install] = [(..._args: unknown[]) => null]; install(globalThis, Symbol.for("fallback"), {});', + ]) + clean( + admissionBoundaryFindings( + graphWith(ADMISSION_FACADE, sources[ADMISSION_FACADE]! + '\n' + mutation), + ), + 'Local methods selected from literals do not install native state.', + ); for (const file of [ADMISSION_RUNTIME, ADMISSION_FACADE]) { const graph = graphWith( probe, diff --git a/frontend/apps/web/src/test/public-boundary-analysis.ts b/frontend/apps/web/src/test/public-boundary-analysis.ts index 013eff0e..3e3ea109 100644 --- a/frontend/apps/web/src/test/public-boundary-analysis.ts +++ b/frontend/apps/web/src/test/public-boundary-analysis.ts @@ -55,7 +55,7 @@ export function admissionBoundaryFindings(graph: SourceGraph): Finding[] { walk(facade, (node) => { const installer = (expression: ts.Node) => ['Object.defineProperty', 'Object.defineProperties', 'Object.assign'].some((name) => - globalBuiltin(expression, name, graph.checker), + globalBuiltin(localInitializer(expression, graph), name, graph.checker), ); const receiver = ts.isCallExpression(node) ? memberReceiver(node.expression) : undefined; if ( From 578dcd8e44576c4b563576071156509bf5fda867 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 14:55:52 +0300 Subject: [PATCH 45/58] feat(public): reuse the native middleware admission snapshot Give bootstrap failures their exact pre-render response owner while removing the second site read and refusing late or foreign request-local consumers. ADR: 0055 --- docs/roadmap/phase-02d-walking-skeleton.md | 28 ++ frontend/apps/web/scripts/verify-ingress.mjs | 8 +- .../[slug]/lessons/[lessonSlug]/page.tsx | 6 +- .../(public)/[locale]/courses/[slug]/page.tsx | 6 +- .../app/(public)/[locale]/courses/page.tsx | 6 +- .../[locale]/status/not-found/layout.tsx | 2 + .../[locale]/status/not-found/page.tsx | 2 + frontend/apps/web/src/app/(public)/layout.tsx | 2 + frontend/apps/web/src/app/layout.tsx | 2 + .../web/src/components/public/lesson.test.tsx | 16 + frontend/apps/web/src/i18n/request.test.ts | 18 +- frontend/apps/web/src/i18n/request.ts | 3 +- frontend/apps/web/src/middleware.test.ts | 269 +++++++++- frontend/apps/web/src/middleware.ts | 78 ++- .../web/src/server/public-request.test.ts | 396 ++++++++++----- .../apps/web/src/server/public-request.ts | 35 +- .../server/public-resource-lifecycle.test.ts | 471 ++++++++++++++++++ .../web/src/server/public-resource.test.ts | 5 +- .../apps/web/src/server/public-resource.ts | 16 +- .../apps/web/src/server/public-ui.test.ts | 47 ++ frontend/apps/web/src/server/public-ui.ts | 23 +- 21 files changed, 1281 insertions(+), 158 deletions(-) create mode 100644 frontend/apps/web/src/server/public-resource-lifecycle.test.ts diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index d62ab381..9cb2ac6a 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5292,6 +5292,34 @@ minimal alias fix and all 278 boundary cases. Root revalidation passes 327 focus cases, typecheck, lint, native ingress and all 19 setup/cleanup controls. Step 1 is complete; no second-bootstrap removal or full product proof is claimed here. + +###### ADR-0055 Step 2 — Single bootstrap publication and checked consumers + +**Implemented — 2026-10-10; independent review rounds pending.** Middleware +requires the matching active native context before transport, performs the sole +site call and publishes only after successful route admission and response +construction. Redirects and refusals terminalize without a snapshot. Controlled +404/429/503 responses are bodyless for HEAD; the renderer forwards only eligible +integer Retry-After values from zero through sixty, without clamping. + +The RSC loader independently verifies provenance, consumes that native snapshot +and constructs its content caller with native cancellation. It never calls +`getSite()`. Invalid provenance and exempt transport fallbacks retain their prior +neutral behavior; active context/configuration defects fail with fixed internal +diagnostics. Identity checks distinguish simultaneous identical bindings. Private +memoized loaders have checked outer wrappers, and resource/UI/i18n/document +consumers recheck after their awaits; completed cache hits and retained translators +cannot revive an ended native request. + +The guarded frontend run passes **1,342** cases: **1,282 web and 60 SDK**, zero +skips/todos. Typecheck, lint, production build and all **198 architecture cases** +pass. The native ingress proof passes with one site call for status/scaffold +requests, real middleware/RSC consumption and retained TLS, method, optimizer, +DEBUG, HMR and signal controls. Focused tests include 88 middleware/admission and +18 actual-memo resource lifecycle cases. These are Step 2 results; Step 3 still +owns real-API/PostgreSQL product accounting, exact wire failures, broader lifecycle +and navigation proofs. The five catalogue rules remain Registered. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/frontend/apps/web/scripts/verify-ingress.mjs b/frontend/apps/web/scripts/verify-ingress.mjs index d7dcd2f5..a6310f73 100644 --- a/frontend/apps/web/scripts/verify-ingress.mjs +++ b/frontend/apps/web/scripts/verify-ingress.mjs @@ -581,9 +581,9 @@ async function run() { ['/faviconXico', 404, 1], ['/favicon.ico/extra', 404, 1], ['/nested/favicon.ico', 404, 1], - ['/en/status/not-found', 404, 2], - ['/studio', 200, 2], - ['/portal', 200, 2], + ['/en/status/not-found', 404, 1], + ['/studio', 200, 1], + ['/portal', 200, 1], ]) { for (const method of ['GET', 'HEAD']) { const before = bootstrapCalls; @@ -633,7 +633,7 @@ async function run() { }); assert.equal(listenerFailed, false, 'Bootstrap fixture refused the hop'); assert.equal(positive.status, 404); // Actual branded status route is admitted without Education. - assert.equal(bootstrapCalls, beforePositive + 2); // Middleware and request-local RSC bootstrap. + assert.equal(bootstrapCalls, beforePositive + 1); // One middleware bootstrap; RSC consumes its snapshot. assert.match(positive.headers['cache-control'], /(?:^|,\s*)no-store(?:,|$)/); for (const query of [ '?', diff --git a/frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/lessons/[lessonSlug]/page.tsx b/frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/lessons/[lessonSlug]/page.tsx index fd022f25..0ae270dd 100644 --- a/frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/lessons/[lessonSlug]/page.tsx +++ b/frontend/apps/web/src/app/(public)/[locale]/courses/[slug]/lessons/[lessonSlug]/page.tsx @@ -4,17 +4,21 @@ import { PublicLesson } from '@/components/public/lesson'; import { PublicState } from '@/components/public/state'; import { publicMetadata } from '@/server/public-metadata'; import { catalogPath } from '@/server/public-paths'; +import { assertPublicRequestActive } from '@/server/public-request'; import { requirePublicResource } from '@/server/public-resource'; import { getPublicUi } from '@/server/public-ui'; export async function generateMetadata() { const resource = await requirePublicResource(); - return publicMetadata(resource, await getPublicUi()); + const ui = await getPublicUi(); + assertPublicRequestActive(resource.request); + return publicMetadata(resource, ui); } export default async function LessonPage() { const resource = await requirePublicResource(); const ui = await getPublicUi(); + assertPublicRequestActive(resource.request); if (resource.kind === 'failure') return ( { const resource = await requirePublicResource(); if (resource.kind !== 'status') notFound(); const { t } = await getPublicUi(); + assertPublicRequestActive(resource.request); return { title: t('page.missing.title'), description: t('page.missing.description'), diff --git a/frontend/apps/web/src/app/(public)/[locale]/status/not-found/page.tsx b/frontend/apps/web/src/app/(public)/[locale]/status/not-found/page.tsx index f3be2cfd..64c41327 100644 --- a/frontend/apps/web/src/app/(public)/[locale]/status/not-found/page.tsx +++ b/frontend/apps/web/src/app/(public)/[locale]/status/not-found/page.tsx @@ -1,6 +1,7 @@ import { notFound } from 'next/navigation'; import { PublicState } from '@/components/public/state'; +import { assertPublicRequestActive } from '@/server/public-request'; import { requirePublicResource } from '@/server/public-resource'; import { getPublicUi } from '@/server/public-ui'; @@ -8,6 +9,7 @@ export default async function MissingPage() { const resource = await requirePublicResource(); if (resource.kind !== 'status') notFound(); const ui = await getPublicUi(); + assertPublicRequestActive(resource.request); return ( diff --git a/frontend/apps/web/src/components/public/lesson.test.tsx b/frontend/apps/web/src/components/public/lesson.test.tsx index e461c219..90dc3cbe 100644 --- a/frontend/apps/web/src/components/public/lesson.test.tsx +++ b/frontend/apps/web/src/components/public/lesson.test.tsx @@ -19,10 +19,12 @@ const dependencies = vi.hoisted(() => ({ resource: vi.fn(), ui: vi.fn(), metadata: vi.fn(), + active: vi.fn(), notFound: vi.fn(() => { throw new Error('Not found'); }), })); +vi.mock('@/server/public-request', () => ({ assertPublicRequestActive: dependencies.active })); vi.mock('@/server/public-resource', () => ({ requirePublicResource: dependencies.resource })); vi.mock('@/server/public-ui', () => ({ getPublicUi: dependencies.ui })); vi.mock('@/server/public-metadata', () => ({ publicMetadata: dependencies.metadata })); @@ -412,6 +414,20 @@ describe('lesson route dispatch', () => { }, ); + it.each([CoursePage, LessonPage, generateMetadata])( + 'checks the original resource again after UI work', + async (consumer) => { + dependencies.resource.mockResolvedValue(lesson()); + dependencies.ui.mockResolvedValue(createPublicTranslator('en')); + dependencies.active.mockImplementationOnce(() => { + throw new Error('Public admission request completed'); + }); + await expect(consumer()).rejects.toThrow('Public admission request completed'); + expect(dependencies.metadata).not.toHaveBeenCalled(); + expect(dependencies.notFound).not.toHaveBeenCalled(); + }, + ); + it('refuses a mismatched resource kind', async () => { dependencies.resource.mockResolvedValue({ kind: 'status', request: lesson().request }); dependencies.ui.mockResolvedValue(createPublicTranslator('en')); diff --git a/frontend/apps/web/src/i18n/request.test.ts b/frontend/apps/web/src/i18n/request.test.ts index 56adb7e6..a29ccfed 100644 --- a/frontend/apps/web/src/i18n/request.test.ts +++ b/frontend/apps/web/src/i18n/request.test.ts @@ -9,11 +9,15 @@ import requestConfig from './request'; const dependencies = vi.hoisted(() => ({ request: vi.fn(), + active: vi.fn(), notFound: vi.fn(() => { throw new Error('Admission refused'); }), })); -vi.mock('@/server/public-request', () => ({ getPublicRequest: dependencies.request })); +vi.mock('@/server/public-request', () => ({ + getPublicRequest: dependencies.request, + assertPublicRequestActive: dependencies.active, +})); vi.mock('next/navigation', () => ({ notFound: dependencies.notFound })); // Execute the production callback directly; next-intl's request cache is outside // this unit boundary. The real formatter consumes the returned config below. @@ -23,6 +27,7 @@ const configure = () => requestConfig({ requestLocale: Promise.resolve('forged-l beforeEach(() => { vi.clearAllMocks(); + dependencies.active.mockReset(); }); describe('verified public next-intl request configuration', () => { @@ -60,6 +65,17 @@ describe('verified public next-intl request configuration', () => { expect(select).not.toHaveBeenCalled(); }); + it('refuses a completed request after the admission await before catalogue selection', async () => { + dependencies.request.mockResolvedValue({ locale: 'tr' }); + dependencies.active.mockImplementationOnce(() => { + throw new Error('Public admission request completed'); + }); + const select = vi.spyOn(catalogues, 'getPublicCatalogue'); + await expect(configure()).rejects.toThrow('Public admission request completed'); + expect(select).not.toHaveBeenCalled(); + expect(dependencies.notFound).not.toHaveBeenCalled(); + }); + it.each(['tr-TR', 'ar'])('bounds formatter failures in the actual %s config', async (locale) => { dependencies.request.mockResolvedValue({ locale }); const error = vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/frontend/apps/web/src/i18n/request.ts b/frontend/apps/web/src/i18n/request.ts index 1d111c21..0adad72d 100644 --- a/frontend/apps/web/src/i18n/request.ts +++ b/frontend/apps/web/src/i18n/request.ts @@ -3,13 +3,14 @@ import 'server-only'; import { notFound } from 'next/navigation'; import { getRequestConfig } from 'next-intl/server'; -import { getPublicRequest } from '@/server/public-request'; +import { assertPublicRequestActive, getPublicRequest } from '@/server/public-request'; import { getPublicCatalogue, publicFormattingOptions } from './catalogues'; export default getRequestConfig(async () => { const request = await getPublicRequest(); if (!request) notFound(); + assertPublicRequestActive(request); // English applies only to an admitted non-localized scaffold, never a refusal. const contentLocale = request.locale ?? 'en'; const catalogue = getPublicCatalogue(contentLocale); diff --git a/frontend/apps/web/src/middleware.test.ts b/frontend/apps/web/src/middleware.test.ts index 30b8f551..739a4fc2 100644 --- a/frontend/apps/web/src/middleware.test.ts +++ b/frontend/apps/web/src/middleware.test.ts @@ -1,16 +1,25 @@ // @vitest-environment node import { randomBytes } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import { NextRequest } from 'next/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { middleware } from './middleware'; +import { middleware as handleMiddleware } from './middleware'; import { validatedTraceparent } from './server/configured-public-client'; import type * as ConfiguredClientModule from './server/configured-public-client'; import { INGRESS_HEADER, mintProvenance } from './server/ingress'; +import type { IngressContext } from './server/ingress'; +import { createPublicAdmissionRuntime } from './server/public-admission-runtime'; import type { PublicSite } from './server/public-entry'; -const { factory, bootstrap } = vi.hoisted(() => ({ factory: vi.fn(), bootstrap: vi.fn() })); +const { factory, bootstrap, beginAdmission } = vi.hoisted(() => ({ + factory: vi.fn(), + bootstrap: vi.fn(), + beginAdmission: vi.fn(), +})); +vi.mock('./server/public-admission', () => ({ beginPublicAdmission: beginAdmission })); vi.mock('./server/configured-public-client', async (original) => { const actual = await original(); return { ...actual, createConfiguredPublicClient: factory }; @@ -26,7 +35,36 @@ const site: PublicSite = { }; const trace = '00-1234567890abcdef1234567890abcdef-1234567890abcdef-01'; -function incoming(target: string, stamp: 'valid' | 'missing' | 'forged' = 'valid') { +let runtime: ReturnType; +const nativeBindings = new WeakMap(); + +function lifetime() { + return { + request: Object.assign(new EventEmitter(), { aborted: false }) as IncomingMessage, + response: Object.assign(new EventEmitter(), { + destroyed: false, + writableEnded: false, + }) as ServerResponse, + }; +} + +async function middleware(request: NextRequest) { + const owner = lifetime(); + return runtime.run(nativeBindings.get(request)!, owner.request, owner.response, async () => { + try { + return await handleMiddleware(request); + } finally { + owner.response.emit('finish'); + } + }); +} + +function incoming( + target: string, + stamp: 'valid' | 'missing' | 'forged' = 'valid', + method = 'GET', + signal?: AbortSignal, +) { const headers = new Headers({ host: 'substituted.invalid', cookie: 'session=private', @@ -52,24 +90,239 @@ function incoming(target: string, stamp: 'valid' | 'missing' | 'forged' = 'valid stamp === 'forged' ? 'forged' : mintProvenance( - { host: 'tenant.example:9999', peer: '192.0.2.11', method: 'GET', target }, + { host: 'tenant.example:9999', peer: '192.0.2.11', method, target }, secret, ), ); // Pinned Next normalizes the full URL before parsing, then removes _rsc. const observed = new URL(('https://substituted.invalid' + target).replace(/\.rsc($|\?)/, '$1')); observed.searchParams.delete('_rsc'); - return new NextRequest(observed, { headers }); + const request = new NextRequest(observed, { headers, method, signal }); + nativeBindings.set(request, { host: 'tenant.example:9999', peer: '192.0.2.11', method, target }); + return request; } beforeEach(() => { vi.stubEnv('LEARNSTACK_PUBLIC_HOP_SECRET', secret); + runtime = createPublicAdmissionRuntime(); + beginAdmission + .mockReset() + .mockImplementation((binding: IngressContext) => runtime.holder.begin(binding)); factory.mockReset().mockReturnValue({ getSite: bootstrap }); bootstrap.mockReset().mockResolvedValue({ kind: 'success', status: 200, data: site }); }); -afterEach(() => vi.unstubAllEnvs()); +afterEach(() => { + runtime.shutdown(); + vi.unstubAllEnvs(); +}); describe('Node public middleware', () => { + it('publishes one detached immutable snapshot only after successful continuation', async () => { + const request = incoming('/tr/courses'); + const owner = lifetime(); + await runtime.run(nativeBindings.get(request)!, owner.request, owner.response, async () => { + const response = await handleMiddleware(request); + expect(response.status).toBe(200); + expect(response.headers.get('x-middleware-next')).toBe('1'); + const snapshot = runtime.holder.read(nativeBindings.get(request)!).site; + expect(snapshot).toEqual(site); + expect(snapshot).not.toBe(site); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen((snapshot as PublicSite).enabledLocales)).toBe(true); + expect(bootstrap).toHaveBeenCalledOnce(); + expect(runtime.counts()).toEqual({ active: 1, snapshots: 1 }); + }); + owner.response.emit('finish'); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + }); + + it('refuses a missing or mismatched native context before any API call', async () => { + const request = incoming('/tr/courses'); + expect((await handleMiddleware(request)).status).toBe(503); + const owner = lifetime(); + await runtime.run( + { ...nativeBindings.get(request)!, host: 'other.example' }, + owner.request, + owner.response, + async () => { + const response = await handleMiddleware(request); + expect(response.status).toBe(503); + expect(await response.text()).toBe('Service unavailable'); + expect(response.headers.get('retry-after')).toBeNull(); + }, + ); + expect(factory).not.toHaveBeenCalled(); + expect(bootstrap).not.toHaveBeenCalled(); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + }); + + it.each(['aborted', 'finish', 'close'] as const)( + 'prevents late bootstrap publication after native %s', + async (event) => { + const request = incoming('/tr/courses'); + const owner = lifetime(); + let resolve!: (value: unknown) => void; + bootstrap.mockReturnValueOnce( + new Promise((release) => { + resolve = release; + }), + ); + const pending = runtime.run(nativeBindings.get(request)!, owner.request, owner.response, () => + handleMiddleware(request), + ); + expect(bootstrap).toHaveBeenCalledOnce(); + const signal = factory.mock.calls[0]![1].signal as AbortSignal; + expect(signal).not.toBe(request.signal); + expect(signal.aborted).toBe(false); + (event === 'aborted' ? owner.request : owner.response).emit(event); + expect(signal.aborted).toBe(true); + resolve({ kind: 'success', status: 200, data: site }); + const response = await pending; + expect(response.status).toBe(503); + expect(await response.text()).toBe('Service unavailable'); + expect(response.headers.get('x-middleware-next')).toBeNull(); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + }, + ); + + it('propagates framework cancellation and refuses late success before a native abort event', async () => { + const cancel = new AbortController(); + const request = incoming('/tr/courses', 'valid', 'GET', cancel.signal); + const owner = lifetime(); + let resolve!: (value: unknown) => void; + bootstrap.mockReturnValueOnce( + new Promise((release) => { + resolve = release; + }), + ); + const pending = runtime.run(nativeBindings.get(request)!, owner.request, owner.response, () => + handleMiddleware(request), + ); + const signal = factory.mock.calls[0]![1].signal as AbortSignal; + cancel.abort(); + expect(signal.aborted).toBe(true); + resolve({ kind: 'success', status: 200, data: site }); + expect((await pending).status).toBe(503); + expect(runtime.counts()).toEqual({ active: 1, snapshots: 0 }); + owner.response.emit('finish'); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + }); + + it('refuses conflicting middleware re-entry without a second bootstrap', async () => { + const request = incoming('/tr/courses'); + const owner = lifetime(); + await runtime.run(nativeBindings.get(request)!, owner.request, owner.response, async () => { + expect((await handleMiddleware(request)).status).toBe(200); + expect((await handleMiddleware(request)).status).toBe(503); + expect(bootstrap).toHaveBeenCalledOnce(); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + }); + }); + + it.each([ + [429, 'rate_limited'], + [503, 'dependency_unavailable'], + [503, 'audit_unavailable'], + ] as const)('forwards only bounded integer Retry-After for %s/%s', async (status, code) => { + for (const retryAfter of [ + 0, + 1, + 60, + -1, + 61, + 0.5, + Number.MAX_SAFE_INTEGER, + NaN, + Infinity, + '30', + 'Wed, 21 Oct 2015 07:28:00 GMT', + undefined, + ]) { + bootstrap.mockResolvedValueOnce({ + kind: 'api-error', + status, + error: { code, retryAfter }, + problem: { detail: secret }, + }); + const response = await middleware(incoming('/tr/courses')); + expect(response.status).toBe(status); + expect(response.headers.get('retry-after')).toBe( + typeof retryAfter === 'number' && + Number.isInteger(retryAfter) && + retryAfter >= 0 && + retryAfter <= 60 + ? String(retryAfter) + : null, + ); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(await response.text()).toBe( + status === 429 ? 'Too many requests' : 'Service unavailable', + ); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + } + }); + + it.each([ + [404, 'not_found', 404], + [429, 'dependency_unavailable', 429], + [503, 'rate_limited', 503], + [503, 'not_found', 503], + [500, 'dependency_unavailable', 503], + [400, 'validation_failed', 503], + ] as const)('omits Retry-After from unsupported %s/%s errors', async (status, code, expected) => { + bootstrap.mockResolvedValueOnce({ kind: 'api-error', status, error: { code, retryAfter: 30 } }); + const response = await middleware(incoming('/tr/courses')); + expect(response.status).toBe(expected); + expect(response.headers.get('retry-after')).toBeNull(); + expect(response.headers.get('x-middleware-next')).toBeNull(); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + }); + + it.each([404, 429, 503] as const)( + 'owns bodyless HEAD bootstrap %s before any rendering', + async (status) => { + bootstrap.mockResolvedValueOnce({ + kind: 'api-error', + status, + error: { + code: + status === 404 + ? 'not_found' + : status === 429 + ? 'rate_limited' + : 'dependency_unavailable', + retryAfter: 30, + }, + }); + const response = await middleware(incoming('/tr/courses', 'valid', 'HEAD')); + expect(response.status).toBe(status); + expect(await response.text()).toBe(''); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('content-type')).toBe('text/plain; charset=utf-8'); + expect(response.headers.get('retry-after')).toBe(status === 404 ? null : '30'); + expect(response.headers.get('x-middleware-next')).toBeNull(); + }, + ); + + it.each(['/courses', '/fr/courses', '/TR/courses'])( + 'makes redirect/refusal terminal with no retained snapshot: %s', + async (target) => { + const request = incoming(target); + const owner = lifetime(); + await runtime.run(nativeBindings.get(request)!, owner.request, owner.response, async () => { + const response = await handleMiddleware(request); + expect([307, 308, 404]).toContain(response.status); + expect(response.headers.get('x-middleware-next')).toBeNull(); + expect(runtime.counts()).toEqual({ active: 1, snapshots: 0 }); + expect(() => runtime.holder.read(nativeBindings.get(request)!)).toThrow( + 'Public admission context unavailable', + ); + }); + owner.response.emit('finish'); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + }, + ); + it('continues only the exact admitted status namespace with a no-store 404', async () => { const response = await middleware(incoming('/tr/status/not-found?slug=private')); expect(response.status).toBe(404); @@ -171,7 +424,7 @@ describe('Node public middleware', () => { expect(response.headers.get('cache-control')).toBe('no-store'); expect(factory).toHaveBeenCalledWith(request.headers.get(INGRESS_HEADER), { traceparent: trace, - signal: request.signal, + signal: expect.any(AbortSignal), }); expect(bootstrap).toHaveBeenCalledTimes(1); }); @@ -196,7 +449,7 @@ describe('Node public middleware', () => { expect(downstream).not.toBeNull(); expect(factory).toHaveBeenCalledWith(request.headers.get(INGRESS_HEADER), { traceparent: downstream, - signal: request.signal, + signal: expect.any(AbortSignal), }); expect(response.headers.get('traceparent')).toBeNull(); }, diff --git a/frontend/apps/web/src/middleware.ts b/frontend/apps/web/src/middleware.ts index e0a68656..5037741c 100644 --- a/frontend/apps/web/src/middleware.ts +++ b/frontend/apps/web/src/middleware.ts @@ -2,11 +2,18 @@ import { NextResponse, type NextRequest } from 'next/server'; import { createConfiguredPublicClient, publicTraceparent } from '@/server/configured-public-client'; import { INGRESS_HEADER, verifyNextProvenance } from '@/server/ingress'; +import { beginPublicAdmission } from '@/server/public-admission'; import { isPublicStatusTarget, publicEntry, publicRedirect } from '@/server/public-entry'; -function refusal(status: 404 | 429 | 503, retryAfter?: number): NextResponse { +function refusal(method: string, status: 404 | 429 | 503, retryAfter?: number): NextResponse { return new NextResponse( - status === 404 ? 'Not found' : status === 429 ? 'Too many requests' : 'Service unavailable', + method === 'HEAD' + ? null + : status === 404 + ? 'Not found' + : status === 429 + ? 'Too many requests' + : 'Service unavailable', { status, headers: { @@ -18,6 +25,17 @@ function refusal(status: 404 | 429 | 503, retryAfter?: number): NextResponse { ); } +function boundedRetryAfter(status: number, code: string, value: unknown): number | undefined { + return ((status === 429 && code === 'rate_limited') || + (status === 503 && (code === 'dependency_unavailable' || code === 'audit_unavailable'))) && + typeof value === 'number' && + Number.isInteger(value) && + value >= 0 && + value <= 60 + ? value + : undefined; +} + /** Bootstrap is request-local; only the API resolves the captured visitor host. */ export async function middleware(request: NextRequest) { const url = new URL(request.url); @@ -26,35 +44,63 @@ export async function middleware(request: NextRequest) { method: request.method, target: `${url.pathname}${url.search}`, }); - if (!context) return refusal(404); + if (!context) return refusal(request.method, 404); + let admission: ReturnType | undefined; try { + admission = beginPublicAdmission(context); + const activeAdmission = admission; + const finish = (response: NextResponse) => { + activeAdmission.refuse(); + return response; + }; const traceparent = publicTraceparent(request.headers.get('traceparent')); + const signal = AbortSignal.any([admission.signal, request.signal]); const client = createConfiguredPublicClient(envelope, { traceparent, - signal: request.signal, + signal, }); - if (!client) return refusal(404); + if (!client) return finish(refusal(request.method, 404)); const bootstrap = await client.getSite(); + admission.assertActive(); + signal.throwIfAborted(); if (bootstrap.kind !== 'success') { if (bootstrap.kind === 'api-error') { - if (bootstrap.status === 404) return refusal(404); + if (bootstrap.status === 404) return finish(refusal(request.method, 404)); if (bootstrap.status === 429) - return refusal( - 429, - bootstrap.error.code === 'rate_limited' ? bootstrap.error.retryAfter : undefined, + return finish( + refusal( + request.method, + 429, + boundedRetryAfter( + bootstrap.status, + bootstrap.error.code, + 'retryAfter' in bootstrap.error ? bootstrap.error.retryAfter : undefined, + ), + ), ); + return finish( + refusal( + request.method, + 503, + boundedRetryAfter( + bootstrap.status, + bootstrap.error.code, + 'retryAfter' in bootstrap.error ? bootstrap.error.retryAfter : undefined, + ), + ), + ); } - return refusal(503); + return finish(refusal(request.method, 503)); } const entry = publicEntry(context.target, bootstrap.data); - if (entry.kind === 'refuse') return refusal(entry.status); + if (entry.kind === 'refuse') return finish(refusal(request.method, entry.status)); if (entry.kind === 'redirect') { const response = NextResponse.redirect( publicRedirect(context.host, entry.path), entry.status, ); response.headers.set('cache-control', 'no-store'); - return response; + return finish(response); } // Next's pinned adapter hides Flight headers here and restores them after // middleware. Rebuild ordinary headers; protocol restoration is proved by @@ -69,10 +115,16 @@ export async function middleware(request: NextRequest) { request: { headers: downstream }, }); response.headers.set('cache-control', 'no-store'); + admission.publish(bootstrap.data); return response; } catch { + try { + admission?.refuse(); + } catch { + /* Completed or poisoned native context. */ + } // No provider/request/configuration details become HTML, headers or logs. - return refusal(503); + return refusal(request.method, 503); } } diff --git a/frontend/apps/web/src/server/public-request.test.ts b/frontend/apps/web/src/server/public-request.test.ts index 4830e04e..e749974f 100644 --- a/frontend/apps/web/src/server/public-request.test.ts +++ b/frontend/apps/web/src/server/public-request.test.ts @@ -1,36 +1,44 @@ // @vitest-environment node +import { AsyncLocalStorage } from 'node:async_hooks'; import { randomBytes } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createConfiguredPublicClient } from './configured-public-client'; import type { ConfiguredPublicClient, publicTraceparent } from './configured-public-client'; import { INGRESS_HEADER, mintProvenance, verifyProvenance } from './ingress'; +import type { IngressContext } from './ingress'; +import { createPublicAdmissionRuntime } from './public-admission-runtime'; import type { PublicSite } from './public-entry'; -import { getPublicRequest } from './public-request'; +import { assertPublicRequestActive, getPublicRequest } from './public-request'; -const mocks = vi.hoisted(() => ({ headers: vi.fn<() => Promise>() })); +const mocks = vi.hoisted(() => ({ + headers: vi.fn<() => Promise>(), + readAdmission: vi.fn(), +})); vi.mock('next/headers', () => ({ headers: mocks.headers })); +vi.mock('./public-admission', () => ({ readPublicAdmission: mocks.readAdmission })); vi.mock('./configured-public-client', async (importOriginal) => ({ ...(await importOriginal<{ publicTraceparent: typeof publicTraceparent }>()), createConfiguredPublicClient: vi.fn(), })); -// Real memoization has dedicated controls and a production RSC proof. Admission -// cases deliberately execute a fresh loader for every invocation. -vi.mock('./request-memo', () => ({ - requestMemo: (load: (incoming: Headers) => Promise) => async () => - load(await mocks.headers()), -})); - +// Keep the real requestMemo. This test ALS supplies distinct framework header +// identities while the independent production runtime owns native admission. +const headerScope = new AsyncLocalStorage(); const secret = randomBytes(32).toString('base64url'); const traceparent = '00-11223344556677889900aabbccddeeff-1234567890abcdef-01'; const createClient = vi.mocked(createConfiguredPublicClient); -const context = { +const context: IngressContext = { host: 'first.example:3000', peer: '203.0.113.7', method: 'GET', target: '/tr-TR/courses?locale=en', }; +const unavailable = 'Public admission context unavailable'; +const completed = 'Public admission request completed'; +let runtime: ReturnType; function site(enabledLocales = ['tr-TR', 'en', 'ar'], defaultLocale = 'tr-TR'): PublicSite { return { @@ -42,11 +50,11 @@ function site(enabledLocales = ['tr-TR', 'en', 'ar'], defaultLocale = 'tr-TR'): }; } -function client(configuration = site()) { +function client() { return { getSite: vi .fn() - .mockResolvedValue({ kind: 'success', status: 200, data: configuration }), + .mockRejectedValue(new Error('RSC must never bootstrap')), getCourses: vi.fn(), getCourse: vi.fn(), getLesson: vi.fn(), @@ -66,49 +74,102 @@ function incoming(target = context.target, host = context.host): Headers { }); } +function lifetime() { + return { + request: Object.assign(new EventEmitter(), { aborted: false }) as IncomingMessage, + response: Object.assign(new EventEmitter(), { + destroyed: false, + writableEnded: false, + }) as ServerResponse, + }; +} + +function admitted( + headers: Headers, + configuration: PublicSite, + work: (owner: ReturnType) => Promise, + nativeBinding?: IngressContext, +): Promise { + const owner = lifetime(); + const binding = nativeBinding ?? verifyProvenance(headers.get(INGRESS_HEADER), secret)!; + return headerScope.run(headers, () => + runtime.run(binding, owner.request, owner.response, async () => { + runtime.holder.begin(binding).publish(configuration); + try { + return await work(owner); + } finally { + owner.response.emit('finish'); + } + }), + ); +} + +function deferred() { + let release!: () => void; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + beforeEach(() => { vi.stubEnv('LEARNSTACK_PUBLIC_HOP_SECRET', secret); - mocks.headers.mockReset(); - createClient.mockReset(); + runtime = createPublicAdmissionRuntime(); + mocks.headers.mockReset().mockImplementation(async () => { + const headers = headerScope.getStore(); + if (!headers) throw new Error('Test framework header scope missing'); + return headers; + }); + mocks.readAdmission + .mockReset() + .mockImplementation((binding: IngressContext) => runtime.holder.read(binding)); + createClient.mockReset().mockImplementation(() => client()); +}); +afterEach(() => { + runtime.shutdown(); + vi.unstubAllEnvs(); }); -afterEach(() => vi.unstubAllEnvs()); describe('shared verified public request admission', () => { - it('uses authentic envelope authority, live enabled membership and signed locale only', async () => { + it('consumes the immutable middleware snapshot and authentic signed locale with zero site calls', async () => { const headers = incoming(); const configured = client(); - mocks.headers.mockResolvedValue(headers); createClient.mockReturnValue(configured); - - const request = await getPublicRequest(); - - expect(request).toEqual({ - context, - client: configured, - site: site(), - locale: 'tr-TR', - route: { kind: 'catalog', path: '/tr-TR/courses' }, + await admitted(headers, site(), async () => { + const [first, second] = await Promise.all([getPublicRequest(), getPublicRequest()]); + expect(second).toBe(first); + expect(first).toEqual({ + context, + client: configured, + site: site(), + locale: 'tr-TR', + route: { kind: 'catalog', path: '/tr-TR/courses' }, + }); + expect(Object.isFrozen(first!.site)).toBe(true); + expect(Object.isFrozen(first!.site.enabledLocales)).toBe(true); + const native = runtime.holder.read(context); + expect(first!.site).toBe(native.site); + expect(createClient).toHaveBeenCalledWith(headers.get(INGRESS_HEADER), { + traceparent, + signal: native.signal, + }); + expect(createClient).toHaveBeenCalledOnce(); + expect(configured.getSite).not.toHaveBeenCalled(); + expect(configured.getCourses).not.toHaveBeenCalled(); + expect(configured.getCourse).not.toHaveBeenCalled(); + expect(configured.getLesson).not.toHaveBeenCalled(); }); - expect(createClient).toHaveBeenCalledWith(headers.get(INGRESS_HEADER), { traceparent }); - expect(createClient).toHaveBeenCalledOnce(); - expect(configured.getSite).toHaveBeenCalledOnce(); - expect(configured.getCourses).not.toHaveBeenCalled(); - expect(configured.getCourse).not.toHaveBeenCalled(); - expect(configured.getLesson).not.toHaveBeenCalled(); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); }); it.each([null, '', 'unsigned-host', 'v1.invalid.invalid'])( - 'refuses %s before configured caller or private configuration', + 'refuses %s before context lookup or configuration', async (envelope) => { const headers = incoming(); if (envelope === null) headers.delete(INGRESS_HEADER); else headers.set(INGRESS_HEADER, envelope); - mocks.headers.mockResolvedValue(headers); - createClient.mockImplementation(() => { - throw new Error('Configuration must not be read'); - }); - - expect(await getPublicRequest()).toBeNull(); + expect(await headerScope.run(headers, getPublicRequest)).toBeNull(); + expect(mocks.readAdmission).not.toHaveBeenCalled(); expect(createClient).not.toHaveBeenCalled(); }, ); @@ -119,24 +180,23 @@ describe('shared verified public request admission', () => { '/api/healthz', '/_next/static/missing.js', '/_next/webpack-hmr', - ])('never bootstraps UI from a transport fallback: %s', async (target) => { - mocks.headers.mockResolvedValue(incoming(target)); - createClient.mockImplementation(() => { - throw new Error('Transport path cannot construct a caller'); - }); - expect(await getPublicRequest()).toBeNull(); + ])('keeps exempt transport fallback tenant-free: %s', async (target) => { + expect(await headerScope.run(incoming(target), getPublicRequest)).toBeNull(); + expect(mocks.readAdmission).not.toHaveBeenCalled(); expect(createClient).not.toHaveBeenCalled(); }); - it('rejects forged and repeated authentic stamps before caller construction', async () => { + it('rejects forged and repeated authentic stamps before native context access', async () => { const stamp = mintProvenance(context, secret); for (const envelope of [ mintProvenance(context, randomBytes(32).toString('base64url')), `${stamp}, ${stamp}`, ]) { - mocks.headers.mockResolvedValue(new Headers({ [INGRESS_HEADER]: envelope })); - expect(await getPublicRequest()).toBeNull(); + expect( + await headerScope.run(new Headers({ [INGRESS_HEADER]: envelope }), getPublicRequest), + ).toBeNull(); } + expect(mocks.readAdmission).not.toHaveBeenCalled(); expect(createClient).not.toHaveBeenCalled(); }); @@ -150,84 +210,194 @@ describe('shared verified public request admission', () => { ['/tr-TR/unknown', ['tr-TR'], 'tr-TR'], ['/', ['tr-TR'], 'tr-TR'], ])( - 'does not expose a request when live entry refuses or redirects %s', + 'fails closed on an impossible ready snapshot entry: %s', async (target, locales, defaultLocale) => { - mocks.headers.mockResolvedValue(incoming(target)); - const configured = client(site(locales, defaultLocale)); - createClient.mockReturnValue(configured); - expect(await getPublicRequest()).toBeNull(); - expect(configured.getSite).toHaveBeenCalledOnce(); + await admitted(incoming(target), site(locales, defaultLocale), async () => { + await expect(getPublicRequest()).rejects.toThrow(unavailable); + }); + expect(createClient).toHaveBeenCalledOnce(); + const configured = createClient.mock.results[0]!.value!; + expect(configured.getSite).not.toHaveBeenCalled(); + expect(configured.getCourses).not.toHaveBeenCalled(); + expect(configured.getCourse).not.toHaveBeenCalled(); + expect(configured.getLesson).not.toHaveBeenCalled(); }, ); it.each(['/ar/courses', '/tr-TR/status/not-found', '/studio', '/portal'])( - 'admits enabled unsupported UI locales, fixed status and scaffolds: %s', + 'consumes admission for supported entry %s', async (target) => { - mocks.headers.mockResolvedValue(incoming(target)); - createClient.mockReturnValue(client()); - const request = await getPublicRequest(); - expect(request?.locale).toBe( - target === '/studio' || target === '/portal' ? null : target.split('/')[1], - ); - expect(request?.context.target).toBe(target); + await admitted(incoming(target), site(), async () => { + const request = await getPublicRequest(); + expect(request?.locale).toBe( + target === '/studio' || target === '/portal' ? null : target.split('/')[1], + ); + expect(request?.context.target).toBe(target); + }); + expect(createClient).toHaveBeenCalledOnce(); + expect(createClient.mock.results[0]!.value!.getSite).not.toHaveBeenCalled(); }, ); - it('bounds configuration exceptions, missing clients and unsuccessful bootstrap results', async () => { - mocks.headers.mockResolvedValue(incoming()); - createClient.mockImplementationOnce(() => { - throw new Error('private configuration'); - }); - expect(await getPublicRequest()).toBeNull(); - createClient.mockReturnValueOnce(null); - expect(await getPublicRequest()).toBeNull(); - for (const result of [ - { kind: 'transport-error' }, - { kind: 'invalid-response', reason: 'invalid-success' }, - ] as const) { - const configured = client(); - configured.getSite.mockResolvedValue(result); - createClient.mockReturnValueOnce(configured); - expect(await getPublicRequest()).toBeNull(); - } + it.each(['missing', 'pending', 'loading', 'refused'] as const)( + 'throws a sanitized active %s context defect instead of an API 404', + async (phase) => { + const headers = incoming(); + if (phase === 'missing') + await expect(headerScope.run(headers, getPublicRequest)).rejects.toThrow(unavailable); + else { + const owner = lifetime(); + await headerScope.run(headers, () => + runtime.run(context, owner.request, owner.response, async () => { + if (phase !== 'pending') { + const writing = runtime.holder.begin(context); + if (phase === 'refused') writing.refuse(); + } + await expect(getPublicRequest()).rejects.toThrow(unavailable); + }), + ); + owner.response.emit('finish'); + } + expect(createClient).not.toHaveBeenCalled(); + }, + ); + + it('refuses a ready context bound to another signed request before caller construction', async () => { + await admitted( + incoming(), + site(), + async () => { + await expect(getPublicRequest()).rejects.toThrow(unavailable); + }, + { ...context, host: 'other.example:3000' }, + ); + expect(createClient).not.toHaveBeenCalled(); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + }); + + it.each(['throw', 'null'] as const)( + 'sanitizes %s configured-client failures without a fallback site read', + async (mode) => { + if (mode === 'throw') + createClient.mockImplementationOnce(() => { + throw new Error('private configuration secret'); + }); + else createClient.mockReturnValueOnce(null); + await admitted(incoming(), site(), async () => { + await expect(getPublicRequest()).rejects.toThrow(unavailable); + }); + expect(createClient).toHaveBeenCalledOnce(); + }, + ); + + it('guards a cached success again after native finish instead of returning retained data', async () => { const configured = client(); - configured.getSite.mockRejectedValue(new Error('private diagnostic')); - createClient.mockReturnValueOnce(configured); - expect(await getPublicRequest()).toBeNull(); + createClient.mockReturnValue(configured); + await admitted(incoming(), site(), async (owner) => { + const first = await getPublicRequest(); + expect(first).not.toBeNull(); + owner.response.emit('finish'); + await expect(getPublicRequest()).rejects.toThrow(completed); + expect(() => assertPublicRequestActive(first!)).toThrow(completed); + expect(configured.getSite).not.toHaveBeenCalled(); + expect(configured.getCourses).not.toHaveBeenCalled(); + }); + expect(createClient).toHaveBeenCalledOnce(); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); }); - it('keeps overlapping host and locale admissions independent and re-reads later requests', async () => { - let releaseFirst!: (value: Awaited>) => void; - const first = client(); - first.getSite.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseFirst = resolve; - }), - ); - const secondSite = { ...site(['en'], 'en'), displayName: 'Second institution' }; - const second = client(secondSite); - mocks.headers - .mockResolvedValueOnce(incoming()) - .mockResolvedValueOnce(incoming('/en/courses', 'second.example:3000')); - createClient.mockImplementation((envelope) => - verifyProvenance(envelope, secret)?.host === context.host ? first : second, + it('rechecks native lifetime after the asynchronous memo result resolves', async () => { + await admitted(incoming(), site(), async (owner) => { + const first = await getPublicRequest(); + const cached = getPublicRequest(); + owner.response.emit('finish'); + await expect(cached).rejects.toThrow(completed); + expect(first).not.toBeNull(); + }); + expect(createClient).toHaveBeenCalledOnce(); + }); + + it('rejects an escaped request value inside an identical neighbor by snapshot identity', async () => { + const firstHeaders = incoming(); + const secondHeaders = incoming(); + const held = deferred(); + const started = deferred(); + let escaped: NonNullable>>; + const first = admitted(firstHeaders, site(), async () => { + escaped = (await getPublicRequest())!; + started.release(); + await held.promise; + assertPublicRequestActive(escaped); + }); + await started.promise; + await admitted(secondHeaders, site(), async () => { + const neighbor = (await getPublicRequest())!; + expect(neighbor.context).toEqual(escaped.context); + expect(neighbor.site).not.toBe(escaped.site); + expect(() => assertPublicRequestActive(escaped)).toThrow(unavailable); + assertPublicRequestActive(neighbor); + }); + held.release(); + await first; + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + }); + + it('isolates eight overlapping identical envelopes and reads a fresh snapshot on the next request', async () => { + const gate = deferred(); + const allStarted = deferred(); + const records: NonNullable>>[] = []; + const pending = Array.from({ length: 8 }, (_, index) => + admitted(incoming(), { ...site(), displayName: `Institution ${index}` }, async () => { + const request = (await getPublicRequest())!; + records.push(request); + if (records.length === 8) allStarted.release(); + await gate.promise; + expect(await getPublicRequest()).toBe(request); + expect(request.site.displayName).toBe(`Institution ${index}`); + assertPublicRequestActive(request); + }), ); + await allStarted.promise; + expect(runtime.counts()).toEqual({ active: 8, snapshots: 8 }); + gate.release(); + await Promise.all(pending); + expect(records).toHaveLength(8); + expect(new Set(records.map((request) => request.site)).size).toBe(8); + expect(createClient).toHaveBeenCalledTimes(8); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + await admitted(incoming(), { ...site(), displayName: 'Updated institution' }, async () => { + expect((await getPublicRequest())?.site.displayName).toBe('Updated institution'); + }); + expect(createClient).toHaveBeenCalledTimes(9); + for (const result of createClient.mock.results) + expect(result.value!.getSite).not.toHaveBeenCalled(); + }); - const pendingFirst = getPublicRequest(); - const pendingSecond = getPublicRequest(); - expect((await pendingSecond)?.site).toBe(secondSite); - releaseFirst({ kind: 'success', status: 200, data: site() }); - const firstRequest = await pendingFirst; - expect(firstRequest?.locale).toBe('tr-TR'); - expect(firstRequest?.context.host).toBe(context.host); - expect(first.getSite).toHaveBeenCalledOnce(); - expect(second.getSite).toHaveBeenCalledOnce(); - - // A later live disablement must refuse rather than reuse the earlier admission. - mocks.headers.mockResolvedValueOnce(incoming()); - first.getSite.mockResolvedValueOnce({ kind: 'success', status: 200, data: site(['en'], 'en') }); - expect(await getPublicRequest()).toBeNull(); - expect(first.getSite).toHaveBeenCalledTimes(2); + it('keeps concurrent hosts and locales independent without looking up native contexts by headers', async () => { + const gate = deferred(); + const first = admitted(incoming(), site(), async () => { + const request = (await getPublicRequest())!; + await gate.promise; + expect(request.context.host).toBe(context.host); + expect(request.locale).toBe('tr-TR'); + expect(request.site.displayName).toBe('First institution'); + assertPublicRequestActive(request); + }); + const second = admitted( + incoming('/en/courses', 'second.example:3000'), + { ...site(['en'], 'en'), displayName: 'Second institution' }, + async () => { + const request = (await getPublicRequest())!; + expect(request.context.host).toBe('second.example:3000'); + expect(request.locale).toBe('en'); + expect(request.site.displayName).toBe('Second institution'); + assertPublicRequestActive(request); + }, + ); + await second; + gate.release(); + await first; + expect(createClient).toHaveBeenCalledTimes(2); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); }); }); diff --git a/frontend/apps/web/src/server/public-request.ts b/frontend/apps/web/src/server/public-request.ts index 00589f9a..1b1fae90 100644 --- a/frontend/apps/web/src/server/public-request.ts +++ b/frontend/apps/web/src/server/public-request.ts @@ -3,6 +3,7 @@ import 'server-only'; import { createConfiguredPublicClient, publicTraceparent } from './configured-public-client'; import type { ConfiguredPublicClient } from './configured-public-client'; import { INGRESS_HEADER, verifyProvenance } from './ingress'; +import { readPublicAdmission } from './public-admission'; import { publicEntry } from './public-entry'; import type { PublicSite } from './public-entry'; import { publicRoute } from './public-route'; @@ -18,7 +19,7 @@ export type PublicRequest = { }; /** One incoming RSC request only; this module deliberately knows no UI catalogue. */ -export const getPublicRequest = requestMemo(async (incoming): Promise => { +const loadPublicRequest = requestMemo(async (incoming): Promise => { const envelope = incoming.get(INGRESS_HEADER); const context = verifyProvenance(envelope, process.env.LEARNSTACK_PUBLIC_HOP_SECRET); if (!context) return null; @@ -27,24 +28,40 @@ export const getPublicRequest = requestMemo(async (incoming): Promise { + const request = await loadPublicRequest(); + if (request) assertPublicRequestActive(request); + return request; +} diff --git a/frontend/apps/web/src/server/public-resource-lifecycle.test.ts b/frontend/apps/web/src/server/public-resource-lifecycle.test.ts new file mode 100644 index 00000000..64e0bf0c --- /dev/null +++ b/frontend/apps/web/src/server/public-resource-lifecycle.test.ts @@ -0,0 +1,471 @@ +// @vitest-environment node +import { AsyncLocalStorage } from 'node:async_hooks'; +import { EventEmitter } from 'node:events'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; + +import type { ConfiguredPublicClient } from './configured-public-client'; +import type { IngressContext } from './ingress'; +import { createPublicAdmissionRuntime } from './public-admission-runtime'; +import type { PublicAdmissionRead } from './public-admission-runtime'; +import type { PublicSite } from './public-entry'; +import type * as PublicRequestModule from './public-request'; +import { getPublicResource, requirePublicResource } from './public-resource'; +import { publicRoute } from './public-route'; + +type PublicRequest = PublicRequestModule.PublicRequest; + +const dependencies = vi.hoisted(() => ({ + headers: vi.fn<() => Promise>(), + request: vi.fn<() => Promise>(), + read: vi.fn<(binding: IngressContext) => PublicAdmissionRead>(), + afterAssertion: vi.fn<() => void>(), + notFound: vi.fn<() => never>(), + redirect: vi.fn<(path: string) => never>(), +})); +vi.mock('next/headers', () => ({ headers: dependencies.headers })); +vi.mock('next/navigation', () => ({ + notFound: dependencies.notFound, + redirect: dependencies.redirect, +})); +vi.mock('./public-admission', () => ({ readPublicAdmission: dependencies.read })); +vi.mock('./public-request', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getPublicRequest: dependencies.request, + assertPublicRequestActive(request: PublicRequest) { + // Keep the production snapshot-identity check and the real native lifetime. + actual.assertPublicRequestActive(request); + dependencies.afterAssertion(); + }, + }; +}); + +const completed = 'Public admission request completed'; +const unavailable = 'Public admission context unavailable'; +const site: PublicSite = { + displayName: 'Institution', + enabledLocales: ['tr'], + defaultLocale: 'tr', + showPlatformAttribution: true, + theme: null, +}; +const catalog: Awaited> = { + kind: 'success', + status: 200, + data: { + locale: 'tr', + items: [], + pageInfo: { hasNext: false, hasPrevious: false, nextCursor: null, previousCursor: null }, + }, +}; +const course: Awaited> = { + kind: 'success', + status: 200, + data: { + locale: 'tr', + alternates: [], + lessons: null, + course: { + slug: 'foundation', + title: 'Foundation', + summary: null, + level: null, + contentAccess: 'enrollment_required', + }, + }, +}; +const lesson: Awaited> = { + kind: 'success', + status: 200, + data: { + locale: 'tr', + alternates: [], + course: { slug: 'foundation', title: 'Foundation' }, + lesson: { slug: 'intro', title: 'Introduction' }, + content: { + state: 'ready', + rendererKey: 'unsupported-private-renderer', + label: { locale: 'tr', value: 'Private renderer label' }, + fields: [], + }, + }, +}; + +function client() { + return { + getSite: vi.fn(), + getCourses: vi.fn().mockResolvedValue(catalog), + getCourse: vi.fn().mockResolvedValue(course), + getLesson: vi.fn().mockResolvedValue(lesson), + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((release) => { + resolve = release; + }); + return { promise, resolve }; +} + +function pause( + operation: Mock<(...args: Arguments) => Promise>, + result: Result, +) { + const entered = deferred(); + const pending = deferred(); + const readResult = vi.fn(() => result.kind); + const observed = Object.defineProperty({ ...result }, 'kind', { get: readResult }); + operation.mockImplementation(() => { + entered.resolve(); + return pending.promise; + }); + return { + entered: entered.promise, + release: () => pending.resolve(observed), + readResult, + }; +} + +type Frame = { + request: PublicRequest; + incoming: Headers; + nativeRequest: IncomingMessage; + nativeResponse: ServerResponse; + signal: AbortSignal; +}; +const frames = new AsyncLocalStorage(); +let runtime: ReturnType; + +function current(): Frame { + const frame = frames.getStore(); + if (!frame) throw new Error('Test request context missing'); + return frame; +} + +function withinRequest( + target: string, + work: (frame: Frame) => Promise, + configured = client(), + incoming = new Headers(), +): Promise { + const binding = { host: 'institution.example:3000', peer: '203.0.113.9', method: 'GET', target }; + const nativeRequest = Object.assign(new EventEmitter(), { aborted: false }) as IncomingMessage; + const nativeResponse = Object.assign(new EventEmitter(), { + destroyed: false, + writableEnded: false, + }) as ServerResponse; + return runtime.run(binding, nativeRequest, nativeResponse, async () => { + runtime.holder.begin(binding).publish(site); + const admission = runtime.holder.read(binding); + const frame: Frame = { + incoming, + nativeRequest, + nativeResponse, + signal: admission.signal, + request: { + context: binding, + site: admission.site as PublicSite, + client: configured, + locale: target === '/studio' ? null : 'tr', + route: publicRoute(target), + }, + }; + try { + return await frames.run(frame, () => work(frame)); + } finally { + nativeResponse.emit('finish'); + } + }); +} + +function finish(frame: Frame, event: 'finish' | 'abort') { + if (event === 'finish') frame.nativeResponse.emit('finish'); + else frame.nativeRequest.emit('aborted'); + expect(frame.signal.aborted).toBe(true); +} + +function expectNoEducation(configured: ReturnType) { + expect(configured.getCourses).not.toHaveBeenCalled(); + expect(configured.getCourse).not.toHaveBeenCalled(); + expect(configured.getLesson).not.toHaveBeenCalled(); +} + +beforeEach(() => { + runtime = createPublicAdmissionRuntime(); + dependencies.headers.mockReset().mockImplementation(async () => current().incoming); + dependencies.request.mockReset().mockImplementation(async () => current().request); + dependencies.read.mockReset().mockImplementation((binding) => runtime.holder.read(binding)); + dependencies.afterAssertion.mockReset(); + dependencies.notFound.mockReset().mockImplementation(() => { + throw new Error('NEXT_NOT_FOUND'); + }); + dependencies.redirect.mockReset().mockImplementation(() => { + throw new Error('NEXT_REDIRECT'); + }); +}); + +afterEach(() => { + const counts = runtime.counts(); + runtime.shutdown(); + expect(counts).toEqual({ active: 0, snapshots: 0 }); +}); + +describe('public resource native lifetime across real memoization', () => { + it.each(['finish', 'abort'] as const)( + 'starts no Education operation when admission resumes after native %s', + async (event) => { + const configured = client(); + await withinRequest( + '/tr/courses', + async (frame) => { + const entered = deferred(); + const admission = deferred(); + dependencies.request.mockImplementationOnce(() => { + entered.resolve(); + return admission.promise; + }); + const result = getPublicResource(); + await entered.promise; + finish(frame, event); + admission.resolve(frame.request); + await expect(result).rejects.toThrow(completed); + expectNoEducation(configured); + expect(configured.getSite).not.toHaveBeenCalled(); + }, + configured, + ); + }, + ); + + const operations = [ + { + name: 'catalog', + target: '/tr/courses', + hold: (configured: ReturnType) => pause(configured.getCourses, catalog), + }, + { + name: 'course', + target: '/tr/courses/foundation', + hold: (configured: ReturnType) => pause(configured.getCourse, course), + }, + { + name: 'lesson', + target: '/tr/courses/foundation/lessons/intro', + hold: (configured: ReturnType) => pause(configured.getLesson, lesson), + }, + ]; + + describe.each(operations)('$name content completion', ({ name, target, hold }) => { + it.each(['finish', 'abort'] as const)( + 'does not inspect or log a response arriving after native %s', + async (event) => { + const configured = client(); + const pending = hold(configured); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await withinRequest( + target, + async (frame) => { + const result = getPublicResource(); + await pending.entered; + finish(frame, event); + // The stub deliberately ignores cancellation and returns a late body. + pending.release(); + await expect(result).rejects.toThrow(completed); + expect(pending.readResult).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + await expect(getPublicResource()).rejects.toThrow(completed); + expect(dependencies.request).toHaveBeenCalledOnce(); + expect(configured.getSite).not.toHaveBeenCalled(); + }, + configured, + ); + }, + ); + + it('shares pending and completed content while active, then refuses cached replay', async () => { + const configured = client(); + const pending = hold(configured); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + await withinRequest( + target, + async (frame) => { + const first = getPublicResource(); + const second = getPublicResource(); + await pending.entered; + pending.release(); + const result = await first; + expect(result.kind).toBe(name); + expect(await second).toBe(result); + expect(await getPublicResource()).toBe(result); + expect(pending.readResult).toHaveBeenCalled(); + expect(dependencies.request).toHaveBeenCalledOnce(); + expect( + configured.getCourses.mock.calls.length + + configured.getCourse.mock.calls.length + + configured.getLesson.mock.calls.length, + ).toBe(1); + finish(frame, 'finish'); + await expect(getPublicResource()).rejects.toThrow(completed); + expect(dependencies.request).toHaveBeenCalledOnce(); + expect( + configured.getCourses.mock.calls.length + + configured.getCourse.mock.calls.length + + configured.getLesson.mock.calls.length, + ).toBe(1); + }, + configured, + ); + }); + }); + + it.each(['/tr/status/not-found', '/studio', '/tr/courses?cursor='])( + 'rejects completed cached %s without creating content work', + async (target) => { + const configured = client(); + await withinRequest( + target, + async (frame) => { + await getPublicResource(); + finish(frame, 'finish'); + await expect(getPublicResource()).rejects.toThrow(completed); + expectNoEducation(configured); + expect(dependencies.request).toHaveBeenCalledOnce(); + }, + configured, + ); + }, + ); + + it('checks the require await before redirecting a completed missing resource', async () => { + const configured = client(); + configured.getCourse.mockResolvedValue({ + kind: 'api-error', + status: 404, + error: { code: 'not_found' }, + problem: { + status: 404, + code: 'not_found', + title: 'Not found', + correlationId: 'test-correlation', + instance: '/api/v1/public/courses/foundation', + messageKey: 'lockey_not_found', + type: 'https://example.invalid/not-found', + }, + }); + await withinRequest( + '/tr/courses/foundation', + async (frame) => { + expect((await getPublicResource()).kind).toBe('missing'); + // The cached getPublicResource guard succeeds. Native completion is queued + // before requirePublicResource resumes its own await continuation. + dependencies.afterAssertion.mockImplementationOnce(() => { + queueMicrotask(() => finish(frame, 'finish')); + }); + await expect(requirePublicResource()).rejects.toThrow(completed); + expect(dependencies.redirect).not.toHaveBeenCalled(); + expect(dependencies.notFound).not.toHaveBeenCalled(); + expect(configured.getCourse).toHaveBeenCalledOnce(); + }, + configured, + ); + await withinRequest( + '/tr/courses/foundation', + async () => { + await expect(requirePublicResource()).rejects.toThrow('NEXT_REDIRECT'); + expect(dependencies.redirect).toHaveBeenCalledWith('/tr/status/not-found'); + }, + configured, + ); + }); + + it('does not accept an escaped admission in another active store with identical bindings', async () => { + const firstClient = client(); + const secondClient = client(); + await withinRequest( + '/tr/courses', + async (first) => { + await withinRequest( + '/tr/courses', + async (second) => { + expect(second.request.context).toEqual(first.request.context); + expect(second.request.site).toEqual(first.request.site); + expect(second.request.site).not.toBe(first.request.site); + dependencies.request.mockResolvedValueOnce(first.request); + await expect(getPublicResource()).rejects.toThrow(unavailable); + expectNoEducation(firstClient); + expectNoEducation(secondClient); + }, + secondClient, + ); + expect((await getPublicResource()).kind).toBe('catalog'); + expect(firstClient.getCourses).toHaveBeenCalledOnce(); + }, + firstClient, + ); + }); + + it('rejects a cross-store memo hit even when framework headers and bindings are identical', async () => { + const firstClient = client(); + const secondClient = client(); + await withinRequest( + '/tr/courses', + async (first) => { + const original = await getPublicResource(); + await withinRequest( + '/tr/courses', + async () => { + await expect(getPublicResource()).rejects.toThrow(unavailable); + expectNoEducation(secondClient); + }, + secondClient, + first.incoming, + ); + expect(await getPublicResource()).toBe(original); + expect(firstClient.getCourses).toHaveBeenCalledOnce(); + expect(dependencies.request).toHaveBeenCalledOnce(); + }, + firstClient, + ); + }); + + it('keeps concurrent identical bindings independent with ordinary distinct header objects', async () => { + const firstClient = client(); + const secondClient = client(); + const pending = pause(firstClient.getCourses, catalog); + let firstRequest!: PublicRequest; + const first = withinRequest( + '/tr/courses', + async (frame) => { + firstRequest = frame.request; + const result = await getPublicResource(); + if (result.kind !== 'catalog') throw new Error('Expected catalog'); + expect(result.request).toBe(frame.request); + return result; + }, + firstClient, + ); + await pending.entered; + const second = await withinRequest( + '/tr/courses', + async (frame) => { + const result = await getPublicResource(); + if (result.kind !== 'catalog') throw new Error('Expected catalog'); + expect(result.request).toBe(frame.request); + expect(result.request.context).toEqual(firstRequest.context); + expect(result.request.site).not.toBe(firstRequest.site); + return result; + }, + secondClient, + ); + pending.release(); + expect(await first).not.toBe(second); + expect(firstClient.getCourses).toHaveBeenCalledOnce(); + expect(secondClient.getCourses).toHaveBeenCalledOnce(); + expect(dependencies.request).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/apps/web/src/server/public-resource.test.ts b/frontend/apps/web/src/server/public-resource.test.ts index e87e5a9b..b6c8a7fc 100644 --- a/frontend/apps/web/src/server/public-resource.test.ts +++ b/frontend/apps/web/src/server/public-resource.test.ts @@ -13,7 +13,10 @@ const navigation = vi.hoisted(() => ({ redirect: vi.fn<(path: string) => never>(), })); vi.mock('next/navigation', () => navigation); -vi.mock('./public-request', () => ({ getPublicRequest: vi.fn() })); +vi.mock('./public-request', () => ({ + getPublicRequest: vi.fn(), + assertPublicRequestActive: vi.fn(), +})); // Dedicated memo controls and the production RSC fixture prove request reuse; // these invocations independently exercise dispatch and closed outcome mapping. vi.mock('./request-memo', () => ({ requestMemo: (load: T): T => load })); diff --git a/frontend/apps/web/src/server/public-resource.ts b/frontend/apps/web/src/server/public-resource.ts index 6c207edc..60fdcf6d 100644 --- a/frontend/apps/web/src/server/public-resource.ts +++ b/frontend/apps/web/src/server/public-resource.ts @@ -3,7 +3,7 @@ import 'server-only'; import type { ApiResult, ServerSdk } from '@learnstack/sdk/server'; import { notFound, redirect } from 'next/navigation'; -import { getPublicRequest } from './public-request'; +import { assertPublicRequestActive, getPublicRequest } from './public-request'; import type { PublicRequest } from './public-request'; import { publicPagination } from './public-route'; import type { PublicPagination } from './public-route'; @@ -61,14 +61,16 @@ function failure( } /** Metadata, root and page share a single content read; the next document re-reads. */ -export const getPublicResource = requestMemo(async (): Promise => { +const loadPublicResource = requestMemo(async (): Promise => { const request = await getPublicRequest(); if (!request) return { kind: 'refused' }; + assertPublicRequestActive(request); const { route, client, context } = request; if (route.kind === 'status') return { kind: 'status', request }; if (route.kind === 'scaffold') return { kind: 'scaffold', request }; if (route.kind === 'lesson') { const result = await client.getLesson({ slug: route.slug, lessonSlug: route.lessonSlug }); + assertPublicRequestActive(request); if (result.kind !== 'success') return failure(result, request); if (result.data.content.state === 'ready' && result.data.content.rendererKey !== 'default-card') // This memoized resource read is shared by metadata, layout and page. @@ -84,6 +86,7 @@ export const getPublicResource = requestMemo(async (): Promise = if (!pagination) return { kind: 'failure', request, state: 'invalid_cursor' }; if (route.kind === 'catalog') { const result = await client.getCourses({ cursor: pagination.cursor, limit: pagination.limit }); + assertPublicRequestActive(request); return result.kind === 'success' ? { kind: 'catalog', request, data: result.data, pagination } : failure(result, request, pagination); @@ -95,17 +98,26 @@ export const getPublicResource = requestMemo(async (): Promise = lessonLimit: pagination.limit, }, ); + assertPublicRequestActive(request); return result.kind === 'success' ? { kind: 'course', request, data: result.data, pagination } : failure(result, request, pagination); }); +/** Memo reuse never revives a completed native request. */ +export async function getPublicResource(): Promise { + const resource = await loadPublicResource(); + if (resource.kind !== 'refused') assertPublicRequestActive(resource.request); + return resource; +} + /** Each consumer honors admission; layouts do not serialize child execution. */ export async function requirePublicResource(): Promise< Exclude > { const resource = await getPublicResource(); if (resource.kind === 'refused') notFound(); + assertPublicRequestActive(resource.request); if (resource.kind === 'missing') { if (resource.request.locale === null) notFound(); redirect(`/${resource.request.locale}/status/not-found`); diff --git a/frontend/apps/web/src/server/public-ui.test.ts b/frontend/apps/web/src/server/public-ui.test.ts index 1e3d69c0..ea66d3ae 100644 --- a/frontend/apps/web/src/server/public-ui.test.ts +++ b/frontend/apps/web/src/server/public-ui.test.ts @@ -7,6 +7,23 @@ const dependencies = vi.hoisted(() => ({ locale: vi.fn(), translations: vi.fn(), translate: vi.fn(), + request: vi.fn(), + active: true, + cached: undefined as Promise | undefined, +})); +vi.mock('react', () => ({ + cache: (load: () => Promise) => () => (dependencies.cached ??= load()), +})); +vi.mock('./public-request', () => ({ + getPublicRequest: dependencies.request, + assertPublicRequestActive: () => { + if (!dependencies.active) throw new Error('Public admission request completed'); + }, +})); +vi.mock('next/navigation', () => ({ + notFound: () => { + throw new Error('NEXT_NOT_FOUND'); + }, })); vi.mock('next-intl/server', () => ({ getLocale: dependencies.locale, @@ -15,6 +32,9 @@ vi.mock('next-intl/server', () => ({ beforeEach(() => { vi.clearAllMocks(); + dependencies.active = true; + dependencies.cached = undefined; + dependencies.request.mockReset().mockResolvedValue({ locale: 'tr' }); dependencies.translations.mockResolvedValue(dependencies.translate); dependencies.translate.mockImplementation((key: string) => `translated:${key}`); }); @@ -52,4 +72,31 @@ describe('public server UI wrapper', () => { expect(dependencies.translate.mock.calls[0]?.[1]).toBe(values); }, ); + it('checks settled UI memo reuse and previously returned translators after completion', async () => { + dependencies.locale.mockResolvedValue('tr'); + const first = await getPublicUi(); + expect(await getPublicUi()).toBe(first); + expect(dependencies.locale).toHaveBeenCalledOnce(); + dependencies.active = false; + await expect(getPublicUi()).rejects.toThrow('Public admission request completed'); + expect(() => first.t('catalog.title')).toThrow('Public admission request completed'); + expect(dependencies.translate).not.toHaveBeenCalled(); + expect(dependencies.locale).toHaveBeenCalledOnce(); + }); + + it('checks lifetime after formatter work resolves', async () => { + dependencies.locale.mockImplementationOnce(async () => { + dependencies.active = false; + return 'tr'; + }); + await expect(getPublicUi()).rejects.toThrow('Public admission request completed'); + expect(dependencies.translate).not.toHaveBeenCalled(); + }); + + it('does not invoke formatters without admission', async () => { + dependencies.request.mockResolvedValue(null); + await expect(getPublicUi()).rejects.toThrow('NEXT_NOT_FOUND'); + expect(dependencies.locale).not.toHaveBeenCalled(); + expect(dependencies.translations).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/apps/web/src/server/public-ui.ts b/frontend/apps/web/src/server/public-ui.ts index 63a18c67..39ca529a 100644 --- a/frontend/apps/web/src/server/public-ui.ts +++ b/frontend/apps/web/src/server/public-ui.ts @@ -1,14 +1,31 @@ import 'server-only'; +import { notFound } from 'next/navigation'; import { getLocale, getTranslations } from 'next-intl/server'; import { cache } from 'react'; import type { PublicTranslate } from '@/i18n/catalogues'; import { textDirection } from '@/i18n/locale'; +import { assertPublicRequestActive, getPublicRequest } from './public-request'; + /** Uses the plugin's verified request configuration, never a callsite locale override. */ -export const getPublicUi = cache(async () => { +const loadPublicUi = cache(async () => { + const request = await getPublicRequest(); + if (!request) notFound(); + assertPublicRequestActive(request); const [locale, translate] = await Promise.all([getLocale(), getTranslations('public')]); - const t: PublicTranslate = (key, ...args) => translate(key as string, args[0]); - return { locale, direction: textDirection(locale), t }; + assertPublicRequestActive(request); + const t: PublicTranslate = (key, ...args) => { + assertPublicRequestActive(request); + return translate(key as string, args[0]); + }; + return { request, ui: { locale, direction: textDirection(locale), t } }; }); + +/** React cache may replay a settled result after the response has completed. */ +export async function getPublicUi() { + const { request, ui } = await loadPublicUi(); + assertPublicRequestActive(request); + return ui; +} From 7a2b4b25791260476d3f4db009166578a46bff0b Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 15:06:04 +0300 Subject: [PATCH 46/58] docs(roadmap): record single-bootstrap step two reviews Keep the receipt limited to independently verified implementation and unit evidence before the production integration proof begins. ADR: 0055 --- docs/roadmap/phase-02d-walking-skeleton.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 9cb2ac6a..e8f03ae5 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5295,7 +5295,7 @@ is complete; no second-bootstrap removal or full product proof is claimed here. ###### ADR-0055 Step 2 — Single bootstrap publication and checked consumers -**Implemented — 2026-10-10; independent review rounds pending.** Middleware +**Implemented — 2026-10-10; both independent review rounds complete.** Middleware requires the matching active native context before transport, performs the sole site call and publishes only after successful route admission and response construction. Redirects and refusals terminalize without a snapshot. Controlled @@ -5320,6 +5320,20 @@ DEBUG, HMR and signal controls. Focused tests include 88 middleware/admission an owns real-API/PostgreSQL product accounting, exact wire failures, broader lifecycle and navigation proofs. The five catalogue rules remain Registered. +**Review round 1 — `578dcd8`.** Fresh runtime/security and proof/structure +reviewers approve without actionable findings. Independent focused replays pass +122 and 400 cases. They verify single bootstrap ownership, exact middleware +refusals, bodyless HEAD, eligible Retry-After bounds, immutable snapshot identity, +post-await and memo-hit lifetime checks, cancellation and retained source fences. +Neither review claims Step 3's actual API/PostgreSQL or navigation obligations. + +**Review round 2 — `578dcd8`.** New runtime/security and proof/structure reviewers +approve without actionable findings. Independent focused replays pass 122 and +439 cases. They confirm real memo/runtime controls reject completed and +cross-store values before Education or result inspection, while preserving +request cancellation and the server-only boundary. Step 2 is complete; the +remaining production proof obligations stay with Step 3. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved From 37ec51e99db9c15e596e5c0a112317ca990d400b Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 15:43:46 +0300 Subject: [PATCH 47/58] test(public): prove single-bootstrap production admission Exercise native request lifetimes, exact refusal responses and stock Next navigation against the real API so snapshot reuse cannot weaken admission. Record the delivered contract and retain accepted decision history. ADR: 0055 --- .claude/skills/add-frontend-route/SKILL.md | 11 +- .claude/skills/run-tests-locally/SKILL.md | 15 + .github/CONTRIBUTING.md | 10 + .github/workflows/ci.yml | 7 + CLAUDE.md | 7 +- README.md | 8 +- .../Database/PublicAdmissionRenderingTests.cs | 521 ++++++++++++ .../Database/PublicServerRenderingTests.cs | 67 +- docs/architecture/14-frontend-architecture.md | 17 +- docs/decisions/0027-frontend-i18n.md | 10 + .../0053-trusted-public-server-rendering.md | 12 + .../0054-bounded-public-renderer-admission.md | 10 + ...0055-public-renderer-bootstrap-failures.md | 23 + docs/decisions/README.md | 2 +- docs/roadmap/README.md | 8 +- docs/roadmap/phase-02d-walking-skeleton.md | 57 ++ docs/standards/06-testing.md | 22 +- docs/standards/07-frontend-architecture.md | 12 +- docs/standards/09-error-handling.md | 10 +- .../21-architecture-tests-catalogue.md | 64 +- docs/standards/README.md | 7 +- .../apps/web/scripts/admission-browser.d.mts | 26 + .../apps/web/scripts/admission-browser.mjs | 256 ++++++ .../web/scripts/admission-hmr-watch.d.mts | 20 + .../apps/web/scripts/admission-hmr-watch.mjs | 162 ++++ frontend/apps/web/scripts/admission-hmr.d.mts | 26 + frontend/apps/web/scripts/admission-hmr.mjs | 103 +++ .../web/scripts/verify-public-admission.mjs | 804 ++++++++++++++++++ .../web/scripts/verify-public-rendering.mjs | 26 +- .../server/configured-public-client.test.ts | 75 +- .../src/server/configured-public-client.ts | 6 + .../web/src/test/admission-browser.test.ts | 48 ++ .../web/src/test/admission-hmr-watch.test.ts | 204 +++++ .../apps/web/src/test/admission-hmr.test.ts | 81 ++ 34 files changed, 2639 insertions(+), 98 deletions(-) create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/PublicAdmissionRenderingTests.cs create mode 100644 frontend/apps/web/scripts/admission-browser.d.mts create mode 100644 frontend/apps/web/scripts/admission-browser.mjs create mode 100644 frontend/apps/web/scripts/admission-hmr-watch.d.mts create mode 100644 frontend/apps/web/scripts/admission-hmr-watch.mjs create mode 100644 frontend/apps/web/scripts/admission-hmr.d.mts create mode 100644 frontend/apps/web/scripts/admission-hmr.mjs create mode 100644 frontend/apps/web/scripts/verify-public-admission.mjs create mode 100644 frontend/apps/web/src/test/admission-browser.test.ts create mode 100644 frontend/apps/web/src/test/admission-hmr-watch.test.ts create mode 100644 frontend/apps/web/src/test/admission-hmr.test.ts diff --git a/.claude/skills/add-frontend-route/SKILL.md b/.claude/skills/add-frontend-route/SKILL.md index df8a02b7..5582d354 100644 --- a/.claude/skills/add-frontend-route/SKILL.md +++ b/.claude/skills/add-frontend-route/SKILL.md @@ -291,10 +291,13 @@ status composition is distinct from the framework's thrown `notFound()` behavior - Unexpected framework errors keep pre-stream 500 / post-stream 200 behavior; `error.tsx` cannot set arbitrary status. -The accepted normal document costs three API calls; following a missing document -through the status route costs five total. Shared request-local loaders add no -metadata/layout/page calls or cross-request cache. See -[the P02d-6 package](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09). +ADR-0055's implemented single bootstrap costs two API calls per completed product +document, one per fixed status/scaffold document and three per followed missing +document. Metadata/layout/page/UI share the admitted snapshot without another +bootstrap or cross-request cache. HEAD/RSC/prefetch and Flight fallback require +separately proven counts. The +[Step 3 record](../../../docs/roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) +records passing production replacement proofs and pending Step 3 reviews. ### Step 10: Tests diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index 54570c19..2306839d 100644 --- a/.claude/skills/run-tests-locally/SKILL.md +++ b/.claude/skills/run-tests-locally/SKILL.md @@ -173,6 +173,21 @@ 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. +ADR-0055's separate admission fixture adds a narrow stock-Next browser navigation +probe and development HMR admission controls. Its Docker integration run requires +an installed stock Chromium browser: Chrome or Brave at the supported macOS +application paths, or Chrome/Chromium at the supported Linux executable paths. +Missing browser installation fails the proof; it never skips. The required CI +integration job checks `/usr/bin/google-chrome` and its version before running. +The fixture launches headless with its own disposable profile and trust limited +to its certificate's public-key fingerprint; it does not use a personal profile. +Browser observations cover a refused Flight followed by stock Next's fresh document +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 and remaining reviews. 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 diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a2300cfb..46a9832c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -69,6 +69,16 @@ Configure these in **GitHub → Settings → Branches → Branch protection rule P5 keeps its synthetic transport probe; P6 adds unchanged product-page modes for HTML/RSC isolation, pagination, presentation, freshness and recovery. These socket/DOM proofs do not claim a browser or manual accessibility audit. + ADR-0055 adds a separate narrow stock-Next navigation probe and development + HMR admission controls. Docker integration requires an installed stock + Chromium browser; CI explicitly checks `/usr/bin/google-chrome` and its + version before the suite. Local supported binaries are Chrome/Brave on macOS + and Chrome/Chromium on Linux; a missing browser fails rather than skips. + The fixture uses an isolated disposable profile and certificate-specific + 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; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0579a46..ab6f6500 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,6 +203,13 @@ jobs: working-directory: frontend run: pnpm install --frozen-lockfile + # ADR-0055's narrow stock-Next Flight fallback proof cannot silently skip. + # The hosted Ubuntu image supplies Chrome; do not use a user's browser profile. + - name: Verify stock browser for admission navigation proof + run: | + test -x /usr/bin/google-chrome + /usr/bin/google-chrome --version + - name: Restore working-directory: backend # CI=true here too, not only on the build: NuGet's audit reports at RESTORE, and diff --git a/CLAUDE.md b/CLAUDE.md index cbb94bdd..95f7f5e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,7 +138,12 @@ records both completed review rounds. **P02d-6 implementation is complete — 2026-10-10, unmerged**: 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. +Maintainer PR review and merge remain pending. ADR-0055 remediation delivers +single-bootstrap admission and passing production replacement proofs. Steps 1–2 +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 pending Step 3 reviews 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. diff --git a/README.md b/README.md index 51691114..9c8b9ec5 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,12 @@ Trusted server rendering is delivered by P02d-5. P02d-6 implements localized catalog/course/lesson pages, ordered text fields, safe themes and metadata; its [product closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) records automated verification/reviews and scoped browser/VoiceOver observations. -P02d-6 implementation is complete; maintainer PR review and merge remain pending. -The browser demo remains P02d-7. The +The original P02d-6 implementation is complete. ADR-0055 remediation delivers +single-bootstrap admission and passing production replacement proofs; Steps 1–2 +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 pending Step 3 reviews and delivery evidence. Maintainer +PR review and merge remain pending. The browser demo remains P02d-7. 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 diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicAdmissionRenderingTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicAdmissionRenderingTests.cs new file mode 100644 index 00000000..ed97a83e --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicAdmissionRenderingTests.cs @@ -0,0 +1,521 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.IO.Compression; +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; +using FluentAssertions; +using LearnStack.Tools.Seeder; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Serilog.Core; +using Serilog.Events; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// One copied production build; fresh real API budgets for each bounded proof phase. +[Collection(PublicReadTestGroup.Name)] +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed class PublicAdmissionRenderingTests(PublicReadFixture fixture) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private const string PrivateMarker = "admission-fixture-private-provider-value"; + private static readonly string[] ValidLocales = ["en"]; + private static readonly string[] DuplicateLocales = ["en", "en"]; + private sealed record WireCase(string Name, string Kind, int ApiStatus, string? RetryAfter, + string Method, int Status, string? ExpectedRetryAfter = null, string Code = "dependency_unavailable"); + private static readonly WireCase[] WireCases = + [ + new("missing-get", "problem", 404, null, "GET", 404, Code: "not_found"), + new("missing-head", "problem", 404, null, "HEAD", 404, Code: "not_found"), + new("rate-zero", "problem", 429, "0", "GET", 429, "0", "rate_limited"), + new("rate-head", "problem", 429, "60", "HEAD", 429, "60", "rate_limited"), + new("unavailable-zero", "problem", 503, "0", "GET", 503, "0"), + new("unavailable-head", "problem", 503, "60", "HEAD", 503, "60"), + new("audit-retry", "problem", 503, "60", "GET", 503, "60", "audit_unavailable"), + new("retry-over", "problem", 429, "61", "GET", 429, Code: "rate_limited"), + new("retry-negative", "problem", 429, "-1", "GET", 429, Code: "rate_limited"), + new("retry-fraction", "problem", 429, "0.5", "GET", 429, Code: "rate_limited"), + new("retry-date", "problem", 503, "Wed, 21 Oct 2026 07:28:00 GMT", "GET", 503), + new("retry-malformed", "problem", 503, "invalid", "GET", 503), + new("retry-unknown", "problem", 503, "60", "GET", 503, Code: "unknown_fixture_code"), + new("retry-other-status", "problem", 500, "60", "GET", 503), + new("malformed-json", "malformed", 200, null, "GET", 503), + new("malformed-rate-problem", "malformed-problem", 429, "60", "GET", 503), + new("malformed-unavailable-problem", "malformed-problem", 503, "60", "GET", 503), + new("wrong-content-type", "wrong-content-type", 200, null, "GET", 503), + new("empty-body", "empty", 200, null, "GET", 503), + new("invalid-shape", "invalid-shape", 200, null, "GET", 503), + new("invalid-site", "invalid-site", 200, null, "GET", 503), + new("invalid-membership", "invalid-membership", 200, null, "GET", 503), + new("oversized", "oversized", 200, null, "GET", 503), + new("decoded-oversized", "compressed", 200, null, "GET", 503), + new("transport", "transport", 0, null, "GET", 503), + new("header-deadline", "hold", 0, null, "GET", 503), + new("body-deadline", "body-hold", 200, null, "GET", 503), + ]; + + [Fact] + public async Task Native_admission_preserves_wire_failures_request_lifetimes_and_browser_fallback() + { + var secret = Convert.ToHexString(RandomNumberGenerator.GetBytes(24)).ToLowerInvariant(); + var root = RepositoryRoot(); + using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(10)); + ApiHost? host = null; + using var process = RendererProcess(root); + var started = false; + var verified = false; + var position = 0; + var hmrChanged = false; + var hmrReads = 0; + var phases = new List(); + var firstCourse = SeedData.English.Curriculum!.Courses.First(row => row.Status == "Published" + && row.ContentAccess == "public" && row.OrganizationId is null); + var english = firstCourse.Translations.Single(); + try + { + host = StartApi(secret); + started = process.Start(); + started.Should().BeTrue(); + var errors = BoundedErrorsAsync(process.StandardError, deadline.Token); + await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new + { + apiOrigin = host.Origin, + secret, + privateMarker = PrivateMarker, + locale = english.Locale, + courseSlug = english.Slug, + tenants = new[] + { + new { host = SeedData.English.Host, name = SeedData.English.DisplayName, locale = english.Locale }, + new { host = SeedData.Yoga.Host, name = SeedData.Yoga.DisplayName, + locale = SeedData.Yoga.Curriculum!.Locales.Single(row => row.IsDefault).Locale }, + }, + wireCases = WireCases.Select(row => new { row.Name, row.Method, row.Status, retryAfter = row.ExpectedRetryAfter }), + }, JsonOptions)); + while (await process.StandardOutput.ReadLineAsync(deadline.Token) is { } checkpoint) + { + checkpoint.Length.Should().BeLessThan(64, "only closed control tokens leave the renderer"); + string? nextOrigin = null; + if (checkpoint == "build-complete") host.Observation.Requests.Should().BeEmpty(); + else if (checkpoint == "hmr-before") + { + host.Observation.Requests.Should().HaveCount(position); + hmrReads = fixture.Observation.Reads; + } + else if (checkpoint == "hmr-change") + { + await fixture.ExecuteAsync(SeedData.English, "UPDATE tenants SET display_name=@name WHERE id=@tenant", + new Dictionary { ["name"] = SeedData.English.DisplayName + " HMR refresh" }); + hmrChanged = true; + } + else if (checkpoint == "hmr-after") + { + AssertCalls(host, ref position, 2, 2); + fixture.Observation.Reads.Should().Be(hmrReads + 2); + } + else if (checkpoint == "hmr-restore") + { + await fixture.ExecuteAsync(SeedData.English, "UPDATE tenants SET display_name=@name WHERE id=@tenant", + new Dictionary { ["name"] = SeedData.English.DisplayName }); + hmrChanged = false; + } + else if (checkpoint.StartsWith("phase-", StringComparison.Ordinal)) + { + checkpoint.Should().BeOneOf("phase-wire", "phase-lifetime", "phase-browser"); + phases.Add(checkpoint); + if (checkpoint != "phase-wire") + { + VerifyPhase(host, position); + await host.DisposeAsync(); + host = StartApi(secret); + } + position = 0; + nextOrigin = host.Origin; + } + else if (checkpoint.StartsWith("arm-", StringComparison.Ordinal)) + { + host.Observation.Requests.Length.Should().Be(position, "each earlier API call belongs to an asserted checkpoint"); + var name = checkpoint[4..]; + if (name == "overlap") host.Observation.ArmGate("/api/v1/public/site", 4); + else if (name == "shutdown") host.Observation.ArmGate("/api/v1/public/courses", 2); + else if (name is "bootstrap" or "content") + host.Observation.Arm(new WireCase(name, "hold", 0, null, "GET", 503), name == "bootstrap" ? "/api/v1/public/site" : "/api/v1/public/courses"); + else if (name is "browser-429" or "browser-503") + { + var status = name == "browser-429" ? 429 : 503; + host.Observation.Arm(new WireCase(name, "problem", status, "0", "GET", status, + Code: status == 429 ? "rate_limited" : "dependency_unavailable")); + } + else host.Observation.Arm(WireCases.Single(row => row.Name == name)); + } + else if (checkpoint == "release-overlap") host.Observation.ReleaseGate(); + else if (checkpoint is "held-overlap" or "held-shutdown") + await host.Observation.WaitForGateAsync(deadline.Token); + else if (checkpoint.StartsWith("held-", StringComparison.Ordinal)) + await host.Observation.WaitForHeldAsync(deadline.Token); + else if (checkpoint.StartsWith("aborted-", StringComparison.Ordinal)) + { + if (checkpoint == "aborted-shutdown") + { + await host.Observation.WaitForGateAbortAsync(deadline.Token); + AssertCalls(host, ref position, 4, 2); + host.Observation.VerifyGate(); + } + else + { + await host.Observation.WaitForAbortAsync(deadline.Token); + var expected = checkpoint == "aborted-bootstrap" ? 1 : 2; + AssertCalls(host, ref position, expected, 1); + host.Observation.VerifyFault(); + } + } + else if (checkpoint.StartsWith("done-", StringComparison.Ordinal)) + { + var name = checkpoint[5..]; + var wire = WireCases.SingleOrDefault(row => row.Name == name); + if (wire is not null) + { + AssertCalls(host, ref position, 1, 1); + if (wire.Kind is "hold" or "body-hold") await host.Observation.WaitForAbortAsync(deadline.Token); + host.Observation.VerifyFault(); + } + else if (name is "browser-429" or "browser-503") + { + var calls = AssertCalls(host, ref position, 3, 2); + calls.Select(row => row.Status).Should().Equal(name == "browser-429" ? 429 : 503, 200, 200); + host.Observation.VerifyFault(); + } + else if (name.StartsWith("context-", StringComparison.Ordinal) || name == "configuration") + AssertCalls(host, ref position, 0, 0); + else if (name is "html-overlap" or "rsc-overlap" or "prefetch-overlap" or "mixed-overlap") + { + AssertCalls(host, ref position, 8, 4); + host.Observation.VerifyGate(); + } + else if (name == "keep-alive") AssertCalls(host, ref position, 4, 2); + else if (name == "late-head" || name == "browser-initial" || name == "entry-refusal") + AssertCalls(host, ref position, 1, 1); + else if (name is "healthy-wire" or "healthy-lifetime" or "head" or "hmr-before" or "hmr-after") + AssertCalls(host, ref position, 2, 1); + else throw new InvalidOperationException("Unexpected admission proof checkpoint."); + } + else if (checkpoint == "verified") + { + VerifyPhase(host, position); + verified = true; + } + else throw new InvalidOperationException("Unexpected admission proof checkpoint."); + await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new { kind = "continue", apiOrigin = nextOrigin }, JsonOptions)); + } + await process.WaitForExitAsync(deadline.Token); + process.ExitCode.Should().Be(0, await errors); + verified.Should().BeTrue(); + phases.Should().Equal("phase-wire", "phase-lifetime", "phase-browser"); + } + finally + { + if (started && !process.HasExited) + { + process.StandardInput.Close(); + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + try { await process.WaitForExitAsync(cleanup.Token); } + catch (OperationCanceledException) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None); + } + } + if (hmrChanged) + await fixture.ExecuteAsync(SeedData.English, "UPDATE tenants SET display_name=@name WHERE id=@tenant", + new Dictionary { ["name"] = SeedData.English.DisplayName }); + if (host is not null) await host.DisposeAsync(); + } + } + + private ApiHost StartApi(string secret) + { + var observation = new AdmissionObservation(secret); + var factory = fixture.WithWebHostBuilder(builder => + { + builder.UseSetting("Tenancy:TrustedHop:Networks:0", "127.0.0.1/32"); + builder.UseSetting("Tenancy:TrustedHop:Secrets:0", secret); + builder.ConfigureTestServices(services => + { + services.AddSingleton(observation); + services.AddSingleton(observation); + }); + }); + factory.UseKestrel(options => options.Listen(IPAddress.Loopback, 0)); + var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + var address = factory.Services.GetRequiredService().Features.Get()!.Addresses.Single(); + return new(factory, client, address, observation, fixture.Observation.Reads); + } + + private void VerifyPhase(ApiHost host, int position) + { + host.Observation.Requests.Should().HaveCount(position); + host.Observation.Requests.Should().HaveCountLessThanOrEqualTo(60, "each phase uses the unchanged single-visitor budget"); + host.Observation.Requests.Should().OnlyContain(row => row.ValidHop); + host.Observation.PrivateLog.Should().BeFalse(); + host.Observation.Logs.Should().BeGreaterThan(0, "API containment observes nonempty real structured logs"); + fixture.Observation.Reads.Should().BeGreaterThan(host.InitialReads, "positive controls execute the production reader as READ ONLY learnstack_app"); + } + + private static ObservedRequest[] AssertCalls(ApiHost host, ref int position, int count, int sites) + { + var rows = host.Observation.Requests.Skip(position).ToArray(); + rows.Should().HaveCount(count); + rows.Count(row => row.Path == "/api/v1/public/site").Should().Be(sites); + rows.Count(row => row.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)).Should().Be(count - sites); + if (count > 0) rows.Should().OnlyContain(row => row.ValidHop); + position += rows.Length; + return rows; + } + + private sealed record ApiHost(WebApplicationFactory Factory, HttpClient Client, string Origin, + AdmissionObservation Observation, int InitialReads) : IAsyncDisposable + { + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await Factory.DisposeAsync(); + } + } + + private sealed class ObservedRequest(string path, bool validHop) + { + public string Path { get; } = path; + public bool ValidHop { get; } = validHop; + public int Status { get; set; } + } + + /// Explicit one-shot wire faults; healthy operations keep the real API pipeline. + private sealed class AdmissionObservation(string secret) : IStartupFilter, ILogEventSink + { + private readonly ConcurrentQueue _requests = new(); + private Fault? _fault; + private Fault? _armed; + private Gate? _gate; + private int _privateLog; + private int _logs; + public ObservedRequest[] Requests => _requests.ToArray(); + public bool PrivateLog => Volatile.Read(ref _privateLog) != 0; + public int Logs => Volatile.Read(ref _logs); + public void ArmGate(string path, int count) + { + _gate.Should().BeNull(); + _gate = new(path, count); + } + public Task WaitForGateAsync(CancellationToken token) => _gate!.Entered.Task.WaitAsync(TimeSpan.FromSeconds(15), token); + public Task WaitForGateAbortAsync(CancellationToken token) => _gate!.Aborted.Task.WaitAsync(TimeSpan.FromSeconds(15), token); + public void ReleaseGate() => _gate!.Release.TrySetResult(); + public void VerifyGate() + { + _gate!.Arrivals.Should().Be(_gate.Count, "identical requests count separately in the overlap multiset"); + _gate.Entered.Task.IsCompletedSuccessfully.Should().BeTrue(); + _gate = null; + } + public void Arm(WireCase value, string path = "/api/v1/public/site") + { + _armed.Should().BeNull(); + _armed = new(value, path); + Volatile.Write(ref _fault, _armed); + } + public Task WaitForHeldAsync(CancellationToken token) => _armed!.Entered.Task.WaitAsync(TimeSpan.FromSeconds(15), token); + public Task WaitForAbortAsync(CancellationToken token) => _armed!.Aborted.Task.WaitAsync(TimeSpan.FromSeconds(15), token); + public void VerifyFault() + { + _fault.Should().BeNull("the exact one-shot API fault was consumed"); + _armed!.Entered.Task.IsCompletedSuccessfully.Should().BeTrue(); + _armed = null; + } + public void Emit(LogEvent logEvent) + { + Interlocked.Increment(ref _logs); + using var output = new StringWriter(CultureInfo.InvariantCulture); + new Serilog.Formatting.Compact.RenderedCompactJsonFormatter().Format(logEvent, output); + var value = output.ToString(); + if (value.Contains(secret, StringComparison.Ordinal) || value.Contains(PrivateMarker, StringComparison.Ordinal) + || value.Contains("x-learnstack-", StringComparison.OrdinalIgnoreCase) + || Regex.IsMatch(value, @"(? Configure(Action next) => app => + { + app.Use(async (context, continuation) => + { + if (!context.Request.Path.StartsWithSegments("/api/v1/public")) { await continuation(); return; } + var headers = context.Request.Headers; + var validHop = headers["X-LearnStack-Hop-Secret"].Count == 1 + && headers["X-LearnStack-Hop-Secret"] == secret + && headers.Accept == "application/json" + && headers["traceparent"].Count == 1 + && Regex.IsMatch(headers["traceparent"].ToString(), "^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1)) + && headers["X-LearnStack-Visitor-Address"] == "127.0.0.1" + && headers["X-LearnStack-Host"].Count == 1 + && (headers["X-LearnStack-Host"] == SeedData.English.Host + ":3000" || headers["X-LearnStack-Host"] == SeedData.Yoga.Host + ":3000") + && !headers.ContainsKey("X-LearnStack-Ingress-Provenance") + && !headers.ContainsKey("Authorization") && !headers.ContainsKey("Cookie") + && !headers.ContainsKey("X-Tenant-Id") && !headers.ContainsKey("X-Organization-Id") + && !headers.ContainsKey("X-Locale") && !headers.ContainsKey("X-Forwarded-For"); + var row = new ObservedRequest(context.Request.Path.Value!, validHop); + _requests.Enqueue(row); + if (Volatile.Read(ref _gate) is { } gate && context.Request.Path == gate.Path) + { + try { await gate.ArriveAsync(context.RequestAborted); } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) { return; } + } + var fault = Volatile.Read(ref _fault); + if (fault is not null && context.Request.Path == fault.Path + && Interlocked.CompareExchange(ref _fault, null, fault) == fault) + { + fault.Entered.TrySetResult(); + try { await RespondAsync(context, fault.Value); } + finally + { + if (context.RequestAborted.IsCancellationRequested) fault.Aborted.TrySetResult(); + } + } + else await continuation(); + row.Status = context.Response.StatusCode; + }); + next(app); + }; + private static async Task RespondAsync(HttpContext context, WireCase fault) + { + if (fault.Kind == "transport") { context.Abort(); return; } + if (fault.Kind is "hold" or "body-hold") + { + if (fault.Kind == "body-hold") + { + context.Response.StatusCode = 200; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync("{\"displayName\":", context.RequestAborted); + await context.Response.Body.FlushAsync(context.RequestAborted); + } + try { await Task.Delay(Timeout.InfiniteTimeSpan, context.RequestAborted); } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) { } + return; + } + context.Response.StatusCode = fault.ApiStatus; + context.Response.ContentType = fault.Kind is "problem" or "malformed-problem" ? "application/problem+json" + : fault.Kind == "wrong-content-type" ? "text/plain" : "application/json"; + context.Response.Headers.CacheControl = "no-store"; + if (fault.RetryAfter is not null) context.Response.Headers.RetryAfter = fault.RetryAfter; + var body = fault.Kind switch + { + "problem" => JsonSerializer.Serialize(new + { + type = "https://fixture.invalid/private", + title = PrivateMarker, + status = fault.ApiStatus, + detail = PrivateMarker, + code = fault.Code, + messageKey = "lockey_fixture_private", + correlationId = "fixture-private-correlation", + instance = "/fixture-private" + }, JsonOptions), + "malformed" => "{\"private\":", + "malformed-problem" => "{\"status\":\"invalid\",\"detail\":\"" + PrivateMarker + "\"}", + "wrong-content-type" => JsonSerializer.Serialize(new + { + displayName = PrivateMarker, + enabledLocales = ValidLocales, + defaultLocale = "en", + theme = (object?)null, + showPlatformAttribution = true + }, JsonOptions), + "empty" => "", + "invalid-shape" => "[]", + "invalid-site" => "{\"displayName\":42}", + "invalid-membership" => JsonSerializer.Serialize(new + { + displayName = PrivateMarker, + enabledLocales = DuplicateLocales, + defaultLocale = "en", + theme = (object?)null, + showPlatformAttribution = true + }, JsonOptions), + "oversized" or "compressed" => "{\"displayName\":\"" + new string('x', 8 * 1024 * 1024) + "\"}", + _ => throw new InvalidOperationException("Unknown admission fixture response."), + }; + if (fault.Kind == "compressed") + { + context.Response.Headers.ContentEncoding = "gzip"; + await using var gzip = new GZipStream(context.Response.Body, CompressionLevel.Fastest, leaveOpen: true); + await gzip.WriteAsync(System.Text.Encoding.UTF8.GetBytes(body), context.RequestAborted); + } + else await context.Response.WriteAsync(body, context.RequestAborted); + } + private sealed record Fault(WireCase Value, string Path) + { + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Aborted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + private sealed class Gate(string path, int count) + { + private int _arrivals; + private int _aborted; + public string Path { get; } = path; + public int Count { get; } = count; + public int Arrivals => Volatile.Read(ref _arrivals); + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Aborted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public async Task ArriveAsync(CancellationToken token) + { + if (Interlocked.Increment(ref _arrivals) == Count) Entered.TrySetResult(); + try { await Release.Task.WaitAsync(token); } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + if (Interlocked.Increment(ref _aborted) == Count) Aborted.TrySetResult(); + throw; + } + } + } + } + + private static Process RendererProcess(string root) + { + var start = new ProcessStartInfo("node") + { + WorkingDirectory = root, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + start.ArgumentList.Add(Path.Combine(root, "frontend/apps/web/scripts/verify-public-admission.mjs")); + start.Environment.Clear(); + start.Environment["PATH"] = Environment.GetEnvironmentVariable("PATH"); + start.Environment["NODE_TLS_REJECT_UNAUTHORIZED"] = "1"; + return new() { StartInfo = start }; + } + private static string RepositoryRoot() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + if (File.Exists(Path.Combine(directory.FullName, "frontend/pnpm-lock.yaml"))) return directory.FullName; + throw new InvalidOperationException("Cannot find admission rendering fixture source."); + } + private static async Task BoundedErrorsAsync(StreamReader stream, CancellationToken token) + { + var output = new System.Text.StringBuilder(); + var buffer = new char[1024]; + int read; + while ((read = await stream.ReadAsync(buffer, token)) > 0) + if (output.Length + read <= 4096) output.Append(buffer, 0, read); + var text = output.ToString(); + return text.Split('\n', StringSplitOptions.RemoveEmptyEntries).All(line => line.StartsWith("Public admission fixture ", StringComparison.Ordinal)) + ? text : "Public admission fixture exited unexpectedly."; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs index ad79350f..440309b5 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs @@ -169,9 +169,9 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new case "trace-missing": case "trace-malformed": var requests = observed.Requests.Skip(tracePosition).ToArray(); - requests.Should().HaveCount(3, "middleware bootstrap, page bootstrap and content all use the real API"); - requests.Take(2).Should().OnlyContain(request => request.Path == "/api/v1/public/site"); - requests[2].Path.Should().StartWith("/api/v1/public/courses"); + requests.Should().HaveCount(2, "one middleware bootstrap and one content operation use the real API"); + requests[0].Path.Should().Be("/api/v1/public/site"); + requests[1].Path.Should().StartWith("/api/v1/public/courses"); requests.Select(request => request.TraceId).Distinct().Should().ContainSingle("one incoming render has one distributed trace"); requests.Should().OnlyContain(request => request.TraceId.Length == 32 && request.TraceId != new string('0', 32)); if (checkpoint == "trace-supplied") requests[0].TraceId.Should().Be(SuppliedTrace.Substring(3, 32)); @@ -208,10 +208,10 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new case "foundation-repeat": case "foundation-missing": var foundationRequests = observed.Requests.Skip(foundationPosition).ToArray(); - foundationRequests.Should().HaveCount(checkpoint == "foundation-missing" ? 5 : 3, + foundationRequests.Should().HaveCount(checkpoint == "foundation-missing" ? 3 : 2, "metadata, document/layout and page share one request-local admission and content operation"); foundationRequests.Count(request => request.Path == "/api/v1/public/site").Should() - .Be(checkpoint == "foundation-missing" ? 4 : 2); + .Be(checkpoint == "foundation-missing" ? 2 : 1); foundationRequests.Count(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)).Should().Be(1); foundationRequests.Single(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)) .Locale.Should().Be(locale, "UI configuration cannot change the exact content API locale"); @@ -233,8 +233,8 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new case "pagination-outline-next": case "pagination-outline-restart": var productRequests = observed.Requests.Skip(foundationPosition).ToArray(); - productRequests.Should().HaveCount(3, "an unchanged page uses one shared content operation"); - productRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(2); + productRequests.Should().HaveCount(2, "an unchanged page uses one middleware admission and one shared content operation"); + productRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(1); productRequests.Count(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)).Should().Be(1); productRequests.Single(request => request.Path != "/api/v1/public/site").Status.Should().Be(200); productRequests.Single(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)) @@ -249,8 +249,8 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new break; case "foundation-cross-tenant": var crossTenantRequests = observed.Requests.Skip(foundationPosition).ToArray(); - crossTenantRequests.Should().HaveCount(5, "missing cross-tenant detail and followed status use the approved redirect chain"); - crossTenantRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(4); + crossTenantRequests.Should().HaveCount(3, "missing cross-tenant detail and followed status use the approved redirect chain"); + crossTenantRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(2); crossTenantRequests.Count(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)).Should().Be(1); var hiddenCourseRequest = crossTenantRequests.Single(request => request.Path != "/api/v1/public/site"); hiddenCourseRequest.Path.Should().Be($"/api/v1/public/courses/{foreignCourse.Slug}"); @@ -265,9 +265,9 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new var statusRequests = observed.Requests.Skip(foundationPosition).ToArray(); statusRequests.Should().HaveCount(checkpoint switch { - "foundation-status" => 8, - "foundation-head" => 2, - "foundation-canonical" => 3, + "foundation-status" => 4, + "foundation-head" => 1, + "foundation-canonical" => 2, _ => 2 }); statusRequests.Should().OnlyContain(request => request.Path == "/api/v1/public/site", @@ -287,9 +287,9 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new case "presentation-unavailable": case "presentation-restored": var presentationRequests = observed.Requests.Skip(foundationPosition).ToArray(); - presentationRequests.Should().HaveCount(3, - "the unchanged lesson route shares one exact content read across metadata, document and page"); - presentationRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(2); + presentationRequests.Should().HaveCount(2, + "the unchanged lesson route shares one middleware admission and one exact content read across metadata, document and page"); + presentationRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(1); var lessonRequest = presentationRequests.Single(request => request.Path != "/api/v1/public/site"); lessonRequest.Path.Should().Contain("/lessons/"); lessonRequest.Locale.Should().Be(checkpoint is "presentation-yoga-tr" or "presentation-rsc-yoga-tr" or "presentation-yoga-unchanged" @@ -299,9 +299,9 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new case "presentation-cross-host": case "presentation-protected": var hiddenLessonRequests = observed.Requests.Skip(foundationPosition).ToArray(); - hiddenLessonRequests.Should().HaveCount(5, + hiddenLessonRequests.Should().HaveCount(3, "one refused lesson document and its localized status redirect use the real bootstrap chain"); - hiddenLessonRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(4); + hiddenLessonRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(2); hiddenLessonRequests.Single(request => request.Path != "/api/v1/public/site").Locale.Should() .Be(checkpoint == "presentation-protected" ? SeedData.Yoga.Curriculum!.Locales.Single(row => row.IsDefault).Locale : locale); hiddenLessonRequests.Single(request => request.Path != "/api/v1/public/site").Status.Should().Be(404); @@ -341,7 +341,7 @@ await SetPresentationLessonAsync(firstCourse, SeedData.English.Curriculum!.Conte break; case "product-html": case "product-rsc": - AssertProductReads(observed, foundationPosition, 12); + AssertProductReads(observed, foundationPosition, siteCalls: 4, contentCalls: 4); observed.VerifyBarrier(); var concurrent = observed.Requests.Skip(foundationPosition).Where(request => request.Path != "/api/v1/public/site").ToArray(); concurrent.Select(request => (request.Host, request.Locale)).Should().BeEquivalentTo( @@ -367,7 +367,7 @@ await SetPresentationLessonAsync(firstCourse, SeedData.English.Curriculum!.Conte break; case var name when name.StartsWith("product-", StringComparison.Ordinal): var missing = name is "product-draft-course" or "product-draft-lesson" or "product-protected-en" or "product-protected-tr"; - AssertProductReads(observed, foundationPosition, missing ? 5 : 3); + AssertProductReads(observed, foundationPosition, siteCalls: missing ? 2 : 1, contentCalls: 1); var productContent = observed.Requests.Skip(foundationPosition).Single(request => request.Path != "/api/v1/public/site"); productContent.Status.Should().Be(missing ? 404 : name switch { @@ -415,15 +415,19 @@ await SetPresentationLessonAsync(firstCourse, SeedData.English.Curriculum!.Conte "make-draft", "product-draft-catalog", "product-draft-course", "product-draft-lesson", "product-draft-other", "restore-published", "product-restored-catalog", "product-restored-course", "product-restored-lesson", "product-bad-cursor", "product-cursor-recovery", "product-arm-429", "product-content-429", "product-429-recovery", "product-arm-503", "product-content-503", "product-503-recovery", "verified"); - if (mode == "product-isolation") observed.Requests.Should().HaveCount(43); - if (mode == "product-freshness") observed.Requests.Should().HaveCount(52); + if (mode == "transport") observed.Requests.Should().HaveCount(39, + "sixteen HTML/RSC representations use two calls each, the root redirect uses one and six protocol probes use one each"); + if (mode == "product-isolation") observed.Requests.Should().HaveCount(28, + "eight concurrent representations and three theme documents use two calls each, plus two followed missing chains use three each"); + if (mode == "product-freshness") observed.Requests.Should().HaveCount(34, + "fourteen actual product representations use two calls each and two followed missing chains use three each"); if (product) foundationPosition.Should().Be(observed.Requests.Length, "every product request belongs to an asserted checkpoint"); - if (mode == "foundation") observed.Requests.Should().HaveCount(52, - "normal/repeat, both hosts and languages, restricted/empty/cross-tenant, status and refusal checks fit one visitor window"); - if (mode == "foundation-pagination") observed.Requests.Should().HaveCount(18, - "six actual catalog/outline documents each make exactly three API calls"); - if (mode == "presentation") observed.Requests.Should().HaveCount(46, - "twelve actual lesson HTML/RSC representations and two followed hidden lessons fit the unchanged visitor budget"); + if (mode == "foundation") observed.Requests.Should().HaveCount(33, + "nine product documents use two calls each, two followed missing chains use three each and nine status/entry requests use one each"); + if (mode == "foundation-pagination") observed.Requests.Should().HaveCount(12, + "six actual catalog/outline documents each make exactly two API calls"); + if (mode == "presentation") observed.Requests.Should().HaveCount(30, + "twelve actual lesson HTML/RSC representations use two calls each and two followed hidden lessons use three each"); observed.Requests.Length.Should().BeInRange(1, 59, "the fixture stays within one real anonymous visitor budget"); observed.Requests.Should().OnlyContain(request => request.ValidHop, "the real caller uses the closed authenticated hop"); observed.Logs.Should().BeGreaterThan(0, "API log containment needs a nonempty real logging subject"); @@ -631,11 +635,12 @@ private Task SetProductThemeAsync(string value) => fixture.ExecuteAsync(SeedData "UPDATE tenant_settings SET value=CAST(@value AS jsonb) WHERE tenant_id=@tenant AND key='branding.theme'", new Dictionary { ["value"] = value }); - private static void AssertProductReads(RenderingObservation observed, int position, int count) + private static void AssertProductReads(RenderingObservation observed, int position, int siteCalls, int contentCalls) { var requests = observed.Requests.Skip(position).ToArray(); - requests.Should().HaveCount(count, "only the actual middleware/bootstrap/content operations reach the API"); - requests.Count(request => request.Path == "/api/v1/public/site").Should().Be(count == 12 ? 8 : count == 5 ? 4 : 2); + requests.Should().HaveCount(siteCalls + contentCalls, "only the actual middleware admission and shared content operations reach the API"); + requests.Count(request => request.Path == "/api/v1/public/site").Should().Be(siteCalls); + requests.Count(request => request.Path != "/api/v1/public/site").Should().Be(contentCalls); requests.Where(request => request.Path == "/api/v1/public/site").Should().OnlyContain(request => request.Status == 200); } @@ -1022,7 +1027,7 @@ public Action Configure(Action next) = && Interlocked.CompareExchange(ref _fault, null, fault) == fault) { // A one-shot content boundary fault, not a real handler or - // limiter failure. Both site bootstraps still run normally. + // limiter failure. The middleware site bootstrap runs normally. Interlocked.Increment(ref _faults); context.Response.StatusCode = fault.Status; context.Response.ContentType = "application/problem+json"; diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index 936c6324..c441124e 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -41,12 +41,13 @@ theme, freshness and content-fault product proofs. The owns validation, completed reviews and passing manual accessibility closeout. [ADR-0055](../decisions/0055-public-renderer-bootstrap-failures.md) is -**Accepted — 2026-10-10; replacement implementation pending**: +**Accepted — 2026-10-10; implementation delivered and production proofs pass**: it replaces the duplicate RSC bootstrap with one middleware-owned live read and an immutable same-request snapshot, preserving exact bootstrap 429/503 before rendering. The -[three-step plan](../roadmap/phase-02d-walking-skeleton.md#adr-0055-acceptance-and-implementation-plan-2026-10-10) -owns implementation and two independent review rounds per step. The +[Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) +owns passing production replacement proofs and pending Step 3 reviews. Both review +rounds for Steps 1–2 are complete. The [review decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-systematic-review-decision-package-2026-10-10) separately records approval for Open Graph projection and bounded Next security maintenance. The isolated context-propagation experiment establishes feasibility; @@ -214,10 +215,12 @@ The three locale-prefixed catalog/course/lesson routes use minimal tenant chrome ordinary same-host relative anchors and no automatic prefetch. Both opaque pagination surfaces parse owned parameters only from the signed raw target. A new document re-reads API state; metadata/layout/page share request-local admission and -content loaders. ADR-0055 accepts one live middleware bootstrap and a native -same-request immutable snapshot: two API calls per completed product document and -three per followed missing-detail chain. Runtime retains its earlier three/five -calls until the replacement is delivered. No retry or shared DTO cache is added. +content loaders. ADR-0055's implemented boundary uses one live middleware bootstrap +and a native same-request immutable snapshot: two API calls per product document, +one per fixed status/scaffold document and three per followed missing-detail +chain. Metadata/layout/page/UI reuse adds no bootstrap. HEAD/RSC/prefetch and +Flight fallback require separately proven counts. No retry or shared DTO cache is +added. Missing/hidden resources return local **307** to the same host's fixed `/{locale}/status/not-found`, followed by a branded **404** document; the browser diff --git a/docs/decisions/0027-frontend-i18n.md b/docs/decisions/0027-frontend-i18n.md index 33bf02d1..96ce1e45 100644 --- a/docs/decisions/0027-frontend-i18n.md +++ b/docs/decisions/0027-frontend-i18n.md @@ -337,6 +337,16 @@ Acceptance delivers no runtime implementation or passing production proof. The [three-step plan](../roadmap/phase-02d-walking-skeleton.md#adr-0055-acceptance-and-implementation-plan-2026-10-10) owns implementation, two independent review rounds per step and delivery status. +### Amendment 5 — Single-bootstrap loader delivery (2026-10-10) + +ADR-0055's replacement admission loader is implemented. Middleware publishes one +validated immutable snapshot; provenance-checked request, resource and UI consumers +share it only during that native request. Content locale and UI fallback remain +separate. The [Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) +owns actual production proof, validation and independent review status. The original +acceptance metadata, body and earlier amendments remain historical. This delivery +note changes no decision or manual accessibility claim. + ## References - [ADR-0008 — Localization Schema](0008-localization-schema.md) diff --git a/docs/decisions/0053-trusted-public-server-rendering.md b/docs/decisions/0053-trusted-public-server-rendering.md index d96a4df4..8191bc2b 100644 --- a/docs/decisions/0053-trusted-public-server-rendering.md +++ b/docs/decisions/0053-trusted-public-server-rendering.md @@ -370,6 +370,18 @@ passing production proof; the [three-step plan](../roadmap/phase-02d-walking-skeleton.md#adr-0055-acceptance-and-implementation-plan-2026-10-10) owns execution and review evidence. +### Amendment 10 — Single-bootstrap production delivery (2026-10-10) + +ADR-0055's native request context, middleware publication and checked RSC consumers +are implemented. Actual production Next/API/PostgreSQL proofs preserve ingress, +isolation, content states and the two/one/three-call contract; the additional +fixture covers exact bootstrap refusals, concurrent snapshots, native lifetime, +stock browser fallback and fresh development admission. The +[Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) +owns execution and independent review status. This delivery note leaves the +original acceptance metadata, body and earlier amendments unchanged; it adds no +P7 browser-demo, full accessibility or production deployment claim. + ## References - [ADR-0036](0036-tenant-resolution-trusted-inputs.md) diff --git a/docs/decisions/0054-bounded-public-renderer-admission.md b/docs/decisions/0054-bounded-public-renderer-admission.md index d6a5feec..a1fed57c 100644 --- a/docs/decisions/0054-bounded-public-renderer-admission.md +++ b/docs/decisions/0054-bounded-public-renderer-admission.md @@ -354,6 +354,16 @@ accounting and numeric quotas remain unchanged. This is bounded supersession navigation, not a correction; original metadata, body and earlier amendments stay historical. Acceptance is not runtime delivery evidence. +### Amendment 9 — Renderer bootstrap qualification delivery (2026-10-10) + +ADR-0055's single-bootstrap reuse and bounded renderer Retry-After qualification +are implemented. Real wire proofs cover supported 404/429/503, eligible zero/sixty +boundaries, omitted invalid values and bodyless HEAD. API accounting, original API +metadata and numeric budgets remain unchanged. The +[Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) +owns execution and independent review status. This delivery note preserves the +original acceptance metadata, body and all earlier amendments. + ## References - [ADR-0053 — Trusted Public Server Rendering](0053-trusted-public-server-rendering.md) diff --git a/docs/decisions/0055-public-renderer-bootstrap-failures.md b/docs/decisions/0055-public-renderer-bootstrap-failures.md index ca8507ed..fe6acd11 100644 --- a/docs/decisions/0055-public-renderer-bootstrap-failures.md +++ b/docs/decisions/0055-public-renderer-bootstrap-failures.md @@ -2,6 +2,10 @@ ## Status +> **Delivery navigation — 2026-10-10:** [Amendment 1](#amendment-1--production-implementation-and-proof-2026-10-10) +> records implemented runtime and production proof. The pending statements below +> describe the acceptance baseline; the linked delivery record owns current reviews. + Accepted — 2026-10-10. The maintainer approved the revised decision after the isolated Next 15.5.27 context-propagation experiment and its two review rounds. @@ -306,6 +310,25 @@ pass. Together they own these mandatory obligations: - Invalid host/locale/stamp, stock launcher bypass, exempt asset/health fallback, native method/upgrade refusal, no build-time bootstrap and retained HMR behavior. +## Amendments + +### Amendment 1 — Production implementation and proof (2026-10-10) + +The three-step implementation delivers native-owned request context/lifetime, +middleware's sole live bootstrap and provenance-checked snapshot consumers. +The six existing production modes retain their isolation, content-state and +freshness controls with revised accounting. The additional real API/PostgreSQL +fixture covers exact wire failures, snapshot concurrency, cancellation, late HEAD, +shutdown, keep-alive, stock browser Flight fallback and development source/API +refresh. Public success and Problem Details media types are enforced before SDK +parsing under the existing API contract. + +The [Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) +owns executed checks, verified corrections and independent review status. Five +catalogue rules are Implemented with named executable owners. The original +acceptance metadata, decision and prior feasibility observations remain historical; +this delivery note changes no decision or P7/Phase 06 scope. + ## References - [ADR-0027 — Frontend UI Localization](0027-frontend-i18n.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 95b2065c..f0ce5e0d 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -70,7 +70,7 @@ an amendment is not a lifecycle status change. | 0052 | [Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md) | Accepted 2026-10-03: host-resolved bootstrap, immutable host ceiling, mandatory marked dispatch and read-only ambient frames; ADR-0036/0040 Amendment 8; P02d-4 Steps 1–3 deliver host provenance, physical read-only frames and all institution GET/HEAD reads; both Step 3 review rounds pass; Step 4 delivers OpenAPI/SDK/CI controls; both review rounds and focused fix verification pass; six real required CI jobs and live sixth-check activation verified; P02d-4 complete and merged through PR #25 on 2026-10-08 ([Amendment 13](0052-anonymous-public-read-boundary.md#amendment-13--merge-closeout-2026-10-08)) | | 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/three-call accounting. Isolated feasibility verified; P02d-6 remediation implementation and production proofs pending | +| 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; Steps 1–2 completed both review rounds. [Step 3](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) owns delivery evidence and its pending reviews; five new catalogue rules are Implemented | ## Proposed ADRs diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 5f262b2a..e4b3cdc9 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -73,8 +73,12 @@ not deferred to the showcase phase. catalog/course/lesson pages and product proofs are implemented; the [closeout record](phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) records completed reviews and scoped browser/VoiceOver observations. - Implementation is - complete; maintainer PR review and merge remain pending. P7 owns the browser/demo. + The original implementation is complete. ADR-0055 remediation delivers + single-bootstrap admission and passing production replacement proofs. Steps 1–2 + completed both independent review rounds; + the [Step 3 record](phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) + owns pending Step 3 reviews and delivery evidence. Maintainer + PR review and merge remain pending. P7 owns the browser/demo. - [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) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index e8f03ae5..8c52d7e6 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5334,6 +5334,63 @@ cross-store values before Education or result inspection, while preserving request cancellation and the server-only boundary. Step 2 is complete; the remaining production proof obligations stay with Step 3. +###### ADR-0055 Step 3 — Production admission proof and closeout + +**Implemented — 2026-10-10; independent review rounds not started.** The six +existing production modes consume the shipped native admission snapshot. +Release execution through real API/PostgreSQL passes all six cases, with zero +skips: transport **39**, foundation **33**, pagination **12**, presentation **30**, +product isolation **28** and product freshness **34** API calls. Exact checkpoint +assertions preserve the approved two/one/three-call contract, prior content-state +behavior and existing isolation, containment, build and stock-launch controls. + +`PublicAdmissionRenderingTests` and `verify-public-admission.mjs` add one actual +production case with three separately budgeted phases: wire **32**, lifetime +**48** and browser/HMR **10** API calls. Healthy calls use the real API and +PostgreSQL as `learnstack_app`, with positive READ ONLY and non-bypass observations. +Test-owned boundary faults exercise first-wire GET/HEAD 404/429/503, bounded +Retry-After, malformed/oversized responses, transport failures, deadlines and +invalid site admission. Refusals load no Education or tenant theme. A wrong-status +mutant fails the wire oracle, followed by a passing clean control. + +The lifetime phase forces overlapping identical HTML/RSC/prefetch requests and +mixed host/locale requests, proves distinct deeply frozen snapshots, and checks +zero retained contexts/snapshots after completion. Four concurrent prefetch lanes +make exactly eight API calls. Physical keep-alive reuse starts fresh admission; +client aborts cancel held bootstrap/content calls. A valid HEAD response finishes +before test-owned deferred work resumes; that work is refused before Education. +Shutdown cancels two held content requests, clears both snapshots and exits +without fixture escalation. Missing, duplicated and mismatched native contexts +fail closed without an API call. + +Stock Brave 154.1.96.61 proves actual refused Flight navigation at 429 and 503 +falls back to a new document request. The browser uses a disposable profile and +fixture certificate trust, without changing workstation trust. Development HMR +changes both source and live API data in the same process and proves fresh +admission with two site calls and no Education call. A bounded TLS WebSocket +watcher waits for the pinned Next compiler's `serverComponentChanges` message; +sync/client-only frames cannot release the source-change barrier. These are +narrow navigation/lifecycle proofs, not P02d-7's browser demo, Lighthouse or a new +manual accessibility audit. + +Two observed gaps were corrected before this commit. A successful JSON-shaped +response with the wrong media type was accepted by the configured caller; it now +requires the existing success/Problem Details media types before SDK parsing, +with cancellation, reader-release and positive controls. The initial full Docker +run exposed an HMR source-watcher race despite a passing focused run. The compiler +completion barrier replaces the racing source-write/request sequence; the focused +production case and subsequent full Docker run pass without retry or sleeps. + +Final local validation passes **2,946 backend cases**: **1,705 unit**, **198 +architecture**, **4 contract**, **172 Docker-free integration** and **867 Docker +integration**, zero skips. The guarded frontend run passes **1,396 cases**: +**1,336 web and 60 SDK**, zero skips/todos. Release build has zero warnings/errors; +format, workspace lint/typecheck, production build, native ingress and all +**19** ingress cleanup controls pass. The five admission catalogue rules are +Implemented with executable owners and planted controls. Accepted ADRs retain +their original bodies and receive dated delivery navigation. Exact-head CI and +two fresh independent Step 3 review rounds are still pending. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index 1eace866..32a8f069 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). +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) and the [Accepted P02d-6 package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09). @@ -211,9 +213,23 @@ pagination surfaces, exact schema swaps and inert authored values. Test-owned inventory exceeds the page bound; test-owned enabled RTL content proves direction and labelled UI fallback without rewriting historical seed inventory. Verify the approved local 307→404 chain, changed status URL, no direct internal bypass and -HTTP 200 noindex known content-failure states. Shared metadata/layout/page loaders -must preserve three API calls per normal document and five for a followed missing -document; the next document re-reads. These proofs are not browser E2E evidence. +HTTP 200 noindex known content-failure states. Under ADR-0055, completed product +documents cost two API calls, fixed status/scaffold documents one and followed +missing documents three. Metadata/layout/page/UI reuse adds no bootstrap; the next +document re-reads. HEAD/RSC/prefetch and Flight fallback need separately proven +counts. The +[Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) +records passing production replacement proofs; Step 3's two independent review +rounds remain pending. These HTTP/HTML/RSC proofs are not browser E2E evidence. + +ADR-0055's separate test-only stock-Next navigation probe observes a failed Flight +followed by a fresh document request and counts that chain separately. It requires +an installed stock Chromium browser, uses an isolated disposable profile with +certificate-specific public-key trust, and fails if the browser is absent. The +required CI integration job checks `/usr/bin/google-chrome` and its version. +Development HMR controls separately check recompilation and fresh admission in +one native process. The Step 3 record owns their actual execution and review status; +P7's browser demo and Lighthouse, and Phase 06's full Playwright/axe, remain separate. Applicable jsx-a11y rules run at error severity with actual-config planted controls. Catalogue checks cover nonempty/equal key sets, valid ICU and argument diff --git a/docs/standards/07-frontend-architecture.md b/docs/standards/07-frontend-architecture.md index ef2f598f..a1181064 100644 --- a/docs/standards/07-frontend-architecture.md +++ b/docs/standards/07-frontend-architecture.md @@ -137,7 +137,9 @@ evidence belongs to the [delivery record](../roadmap/phase-02d-walking-skeleton. ## Request-Local Bootstrap Admission -**ADR-0055 Accepted — 2026-10-10; replacement implementation pending.** +**ADR-0055 implementation delivered — 2026-10-10; production replacement proofs +pass.** Steps 1–2 completed both independent review rounds; Step 3 reviews remain +pending. Middleware owns one live site bootstrap and its exact neutral 404/429/503 before rendering. A native-created context binds the captured host/peer/method/signed target and request lifetime. Successful entry publishes one bounded, deeply @@ -155,9 +157,11 @@ appear in the next request's site snapshot. The accepted replacement costs two API calls for a completed product document, one for a completed fixed status/scaffold document and three for a followed missing-detail chain. HEAD/RSC/prefetch and Flight fallback require separately -proven counts. Visitor/peer limits remain API-call budgets. Until the -[implementation plan](../roadmap/phase-02d-walking-skeleton.md#adr-0055-acceptance-and-implementation-plan-2026-10-10) -is delivered, runtime retains its earlier duplicate-read three/five-call paths. +proven counts. Metadata/layout/page/UI reuse adds no bootstrap. Visitor/peer limits +remain API-call budgets. The +[Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) +owns passing production replacement proof and pending Step 3 review evidence; +the five new catalogue rules are Implemented. [Standards 09](09-error-handling.md#public-page-status-and-recovery) owns refusal and bounded renderer Retry-After rules. diff --git a/docs/standards/09-error-handling.md b/docs/standards/09-error-handling.md index e9bf8951..1c5cb0a8 100644 --- a/docs/standards/09-error-handling.md +++ b/docs/standards/09-error-handling.md @@ -459,7 +459,9 @@ Plain same-host relative anchors trigger fresh document requests. Admission completes before loading boundaries flush; [Standards 07](07-frontend-architecture.md#routing) owns placement and the shared request-local loader. -**ADR-0055 Accepted — 2026-10-10; replacement implementation pending.** +**ADR-0055 implementation delivered — 2026-10-10; production replacement proofs +pass.** Steps 1–2 completed both independent review rounds; Step 3 reviews remain +pending. Middleware owns the sole live bootstrap and emits neutral 404 for invalid entry or API 404, 429 for valid API 429 and 503 for other site/transport/configuration failures. Responses are no-store with fixed plain-text copy and bodyless HEAD; @@ -473,9 +475,9 @@ as integer delta-seconds from zero through sixty inclusive. Preserve eligible values; omit malformed/date/negative/out-of-range values without clamping or inventing a retry. This qualifies renderer forwarding only; the API's selected refusal metadata and accounting remain unchanged. The -[implementation plan](../roadmap/phase-02d-walking-skeleton.md#adr-0055-acceptance-and-implementation-plan-2026-10-10) -owns production proofs and review status. Earlier duplicate-read behavior remains -the runtime baseline until that replacement is delivered. +[Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) +records passing exact-wire failure and lifecycle proofs. Step 3's two independent +review rounds remain pending. ### User-Facing Copy diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 19619efb..edcf54e3 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -8,9 +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 Registered). The catalogue grows as -subsequent ADRs and phases land their tests; per-test ownership stays with -the originating ADR / standard. +(replacement admission proofs implemented; Step 3 reviews pending). The +catalogue grows as subsequent ADRs and phases land their tests; per-test ownership +stays with the originating ADR / standard. The single source of truth for the **identifier**, the **assertion**, the **source ADR / standard**, the **scope**, and the **implementation status** of every @@ -132,8 +132,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. -**165 rules in this catalogue are Implemented, and 107 of them are in that assembly.** -The other 58 are no less binding, and most could not live there. The table says where +**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 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. @@ -4331,11 +4331,17 @@ owns execution, review and manual evidence. ## P02d-6 request-local bootstrap admission controls -**Accepted — 2026-10-10; implementation pending.** ADR-0055 registers the five -rules below before dependent code. Planned files are ownership commitments, not -claims that new tests exist or pass. Existing P5/P6 controls remain mandatory. -The [implementation plan](../roadmap/phase-02d-walking-skeleton.md#adr-0055-acceptance-and-implementation-plan-2026-10-10) -owns delivery and two independent review rounds per step. +**Implemented — 2026-10-10; Step 3 reviews pending.** Production replacement +proofs and planted controls pass for the five rules below. Steps 1–2 completed both +independent review rounds. Existing P5/P6 controls remain mandatory. The +[Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) +owns actual execution and pending Step 3 reviews. Six existing production modes +pass with revised call accounting. The additional production Fact is +`Native_admission_preserves_wire_failures_request_lifetimes_and_browser_fallback` in +`backend/tests/LearnStack.Tests.Integration/Database/PublicAdmissionRenderingTests.cs`, +driven by `frontend/apps/web/scripts/verify-public-admission.mjs`. It passes its +wire, lifecycle and stock-browser/HMR phases against the real API/PostgreSQL; +test-only instrumentation stays in the disposable copied app. ### `Public_Admission_Uses_Only_The_Active_Native_Context` @@ -4347,7 +4353,9 @@ owns delivery and two independent review rounds per step. exercise both facade admission and actual framework propagation. - **Source:** ADR-0055; accepted P02d-6 G40 supersession. - **Type:** frontend runtime + actual production Next/API. **Kind:** behavioural. -- **Status:** **Registered** — planned +- **Status:** **Implemented** — `PublicAdmissionRenderingTests` proves actual + context propagation and missing/mismatched/duplicate-context refusal with + `verify-public-admission.mjs`. Unit controls live in `frontend/apps/web/src/server/public-admission-runtime.test.ts`, `frontend/apps/web/src/server/public-admission.test.ts` and existing `PublicServerRenderingTests` / `verify-public-rendering.mjs` integration. @@ -4364,7 +4372,10 @@ owns delivery and two independent review rounds per step. controls must fail beside positive controls. - **Source:** ADR-0055; ADR-0054 Amendment 8. - **Type:** frontend mapping + actual production HTTP. **Kind:** behavioural. -- **Status:** **Registered** — planned extensions to +- **Status:** **Implemented** — `PublicAdmissionRenderingTests` and + `verify-public-admission.mjs` prove first-wire refusals and a wrong-status mutant; + `frontend/apps/web/src/server/configured-public-client.test.ts` proves success + and Problem Details media-type admission. Mapping and lifetime units live in `frontend/apps/web/src/middleware.test.ts`, `frontend/apps/web/src/server/public-request.test.ts` and existing `PublicServerRenderingTests` / `verify-public-rendering.mjs` integration. @@ -4381,9 +4392,13 @@ owns delivery and two independent review rounds per step. counts, while content 200/noindex and missing 307-to-404 remain unchanged. - **Source:** ADR-0055; accepted P02d-6 G40 supersession. - **Type:** frontend snapshot/memo + production rendering. **Kind:** behavioural. -- **Status:** **Registered** — planned +- **Status:** **Implemented** — `PublicAdmissionRenderingTests` and + `verify-public-admission.mjs` prove overlapping HTML/RSC/prefetch stores, exact + calls and keep-alive freshness; original product modes retain live-state proof. + `frontend/apps/web/src/test/admission-browser.test.ts` supplies clean/planted + Flight-to-document observation controls. Snapshot and consumer units live in `frontend/apps/web/src/server/public-admission-runtime.test.ts`, - `frontend/apps/web/src/server/public-admission.test.ts`, extensions to existing + `frontend/apps/web/src/server/public-admission.test.ts`, `frontend/apps/web/src/server/public-request.test.ts` and `PublicServerRenderingTests` / `verify-public-rendering.mjs` integration. - **Phase:** 02d (P02d-6 remediation Steps 1–3). @@ -4398,10 +4413,19 @@ owns delivery and two independent review rounds per step. zero; this does not claim all React work or captured local references ended. - **Source:** ADR-0055. - **Type:** runtime lifecycle + native production fixture. **Kind:** behavioural. -- **Status:** **Registered** — planned +- **Status:** **Implemented** — `PublicAdmissionRenderingTests` and + `verify-public-admission.mjs` prove abort, shutdown, valid HEAD late refusal and + zero retained stores/snapshots. `frontend/apps/web/scripts/admission-hmr.mjs` + proves fresh admission after source/API change in the same development process; + `frontend/apps/web/src/test/admission-hmr.test.ts` supplies clean/planted freshness, + lifetime and cleanup controls. `admission-hmr-watch.mjs` waits for the pinned + Next compiler's real completion frame before the sole refresh request; + `frontend/apps/web/src/test/admission-hmr-watch.test.ts` proves that sync/client + frames cannot release this source-change barrier. Unit and retained ingress + controls live in `frontend/apps/web/src/server/public-admission-runtime.test.ts`, - `frontend/apps/web/src/server/public-admission.test.ts` and extensions to - existing `PublicServerRenderingTests`, `verify-public-rendering.mjs` and + `frontend/apps/web/src/server/public-admission.test.ts`, + `PublicServerRenderingTests`, `verify-public-rendering.mjs` and `frontend/apps/web/scripts/verify-ingress.mjs`. - **Phase:** 02d (P02d-6 remediation Steps 1–3). @@ -4416,7 +4440,11 @@ owns delivery and two independent review rounds per step. - **Source:** ADR-0055; ADR-0052/0053/0054. - **Type:** source/build + actual production Next/API/PostgreSQL. **Kind:** structural + behavioural. -- **Status:** **Registered** — planned extensions to existing +- **Status:** **Implemented** — `PublicAdmissionRenderingTests` and + `verify-public-admission.mjs` prove stock-browser fallback, development HMR, + no build bootstrap and private-value containment. Browser/HMR assertion controls + live in `frontend/apps/web/src/test/admission-browser.test.ts` and + `frontend/apps/web/src/test/admission-hmr.test.ts`. Retained boundary controls are `frontend/apps/web/src/test/public-boundaries.test.ts`, `PublicServerRenderingTests`, `verify-public-rendering.mjs` and `frontend/apps/web/scripts/verify-ingress.mjs`, with facade/runtime unit controls. diff --git a/docs/standards/README.md b/docs/standards/README.md index d0f3fc67..70cb07a6 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -82,7 +82,12 @@ 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 -demo/browser/Lighthouse. Acceptance alone promotes no standard or test status. +demo/browser/Lighthouse. ADR-0055 remediation delivers single-bootstrap admission +and passing production replacement proofs. Steps 1–2 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 pending Step 3 reviews and delivery evidence; its five new catalogue rules +are Implemented. Acceptance alone promotes no standard or test 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 diff --git a/frontend/apps/web/scripts/admission-browser.d.mts b/frontend/apps/web/scripts/admission-browser.d.mts new file mode 100644 index 00000000..3ad70eb2 --- /dev/null +++ b/frontend/apps/web/scripts/admission-browser.d.mts @@ -0,0 +1,26 @@ +type NavigationEvent = { + readonly url: string; + readonly type: string; + readonly rsc: boolean; + readonly status: number | null; +}; +export function assertFlightFallback( + events: readonly NavigationEvent[], + origin: string, + target: string, + status: number, +): void; +export function verifyBrowserFallback(options: { + readonly owner: ReturnType; + readonly app: string; + readonly root: string; + readonly certificate: string | Buffer; + readonly origin: string; + readonly initialPath: string; + readonly target: string; + readonly status: number; + readonly beforeNavigation: () => Promise; + readonly afterNavigation: () => Promise; + readonly secrets: readonly string[]; +}): Promise>; +import type { createFixtureOwner, createPrivateScanner } from './fixture-support.mjs'; diff --git a/frontend/apps/web/scripts/admission-browser.mjs b/frontend/apps/web/scripts/admission-browser.mjs new file mode 100644 index 00000000..ccf79e46 --- /dev/null +++ b/frontend/apps/web/scripts/admission-browser.mjs @@ -0,0 +1,256 @@ +// Test-only stock browser proof; no production route or runtime dependency. +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createHash, X509Certificate } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; + +import { createPrivateScanner, fixtureEnvironment } from './fixture-support.mjs'; +import { stopTestChild } from './stop-test-child.mjs'; + +/** A failed Flight must precede a fresh successful document for the same target. */ +export function assertFlightFallback(events, origin, target, status) { + const matching = events.filter((event) => { + const url = new URL(event.url); + return url.origin === origin && url.pathname === target; + }); + assert.equal(matching.length, 2, 'Exactly one Flight and one document navigation'); + const [flight, document] = matching; + assert.equal(flight.rsc, true, 'The failed request is a genuine Flight'); + assert.notEqual(flight.type, 'Document', 'Flight cannot be a direct document'); + assert.equal(flight.status, status, 'Flight preserves the bootstrap HTTP refusal'); + assert.equal(document.rsc, false, 'Fallback is a fresh document request'); + assert.equal(document.type, 'Document', 'Stock Next performs document navigation'); + assert.equal(document.status, 200, 'The one-shot fault recovers on the new request'); +} + +function browserExecutable() { + const candidates = + process.platform === 'darwin' + ? [ + '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + ] + : [ + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + ]; + const binary = candidates.find(existsSync); + assert.ok(binary, 'A stock Chromium browser is required; this proof cannot skip'); + return binary; +} + +async function until(predicate, message, timeout = 20_000) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const value = await predicate(); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(message); +} + +/** Isolated profile and certificate-specific trust; never touches a user's browser. */ +export async function verifyBrowserFallback({ + owner, + app, + root, + certificate, + origin, + initialPath, + target, + status, + beforeNavigation, + afterNavigation, + secrets, +}) { + const originUrl = new URL(origin); + assert.equal(originUrl.protocol, 'https:'); + assert.equal(originUrl.origin, origin); + assert.match(originUrl.hostname, /^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/); + const profile = mkdtempSync(join(root, 'admission-browser-profile-')); + const fingerprint = createHash('sha256') + .update(new X509Certificate(certificate).publicKey.export({ type: 'spki', format: 'der' })) + .digest('base64'); + const child = owner.ownChild( + spawn( + browserExecutable(), + [ + '--headless=new', + '--no-first-run', + '--no-default-browser-check', + '--disable-background-networking', + '--disable-component-update', + '--disable-sync', + '--disable-extensions', + '--disable-breakpad', + '--disable-dev-shm-usage', + '--password-store=basic', + '--use-mock-keychain', + '--remote-debugging-address=127.0.0.1', + '--remote-debugging-port=0', + '--host-resolver-rules=MAP ' + originUrl.hostname + ' 127.0.0.1', + '--user-data-dir=' + profile, + '--ignore-certificate-errors-spki-list=' + fingerprint, + 'about:blank', + ], + { + env: fixtureEnvironment({ root }), + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + }, + ), + ); + const scanner = createPrivateScanner(secrets); + for (const stream of [child.stdout, child.stderr]) { + stream.on('data', (chunk) => scanner.push(chunk)); + stream.once('end', () => scanner.finish()); + } + let failed = false; + child.once('error', () => { + failed = true; + }); + const portFile = join(profile, 'DevToolsActivePort'); + const port = await until(() => { + owner.assertActive(); + assert.equal( + failed || child.exitCode !== null || child.signalCode !== null, + false, + 'The owned browser remains live', + ); + if (!existsSync(portFile)) return null; + const value = Number(readFileSync(portFile, 'utf8').split('\n')[0]); + return Number.isInteger(value) && value > 0 && value < 65536 ? value : null; + }, 'Owned browser debugging deadline'); + // eslint-disable-next-line no-restricted-globals -- Owned local browser CDP discovery, never a LearnStack API request. + const response = await fetch('http://127.0.0.1:' + port + '/json/list', { + signal: AbortSignal.timeout(5000), + }); + const pages = await response.json(); + const page = pages.find((candidate) => candidate.type === 'page'); + assert.ok(page, 'Owned browser exposes its initial page'); + const endpoint = new URL(page.webSocketDebuggerUrl); + assert.equal(endpoint.hostname, '127.0.0.1'); + assert.equal(endpoint.port, String(port)); + const WebSocket = createRequire(join(app, 'package.json'))('next/dist/compiled/ws'); + const socket = new WebSocket(endpoint.href, { maxPayload: 2 * 1024 * 1024 }); + const pending = new Map(); + const events = []; + const requests = new Map(); + let sequence = 0; + const rejectPending = () => { + for (const operation of pending.values()) { + clearTimeout(operation.timer); + operation.reject(new Error('Browser protocol closed')); + } + pending.clear(); + }; + socket.on('error', rejectPending); + socket.on('close', rejectPending); + const command = (method, params = {}) => + new Promise((resolve, reject) => { + const id = ++sequence; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error('Browser protocol deadline')); + }, 10_000); + pending.set(id, { resolve, reject, timer }); + socket.send(JSON.stringify({ id, method, params })); + }); + socket.on('message', (value) => { + const message = JSON.parse(value.toString()); + if (message.id !== undefined) { + const operation = pending.get(message.id); + if (!operation) return; + clearTimeout(operation.timer); + pending.delete(message.id); + if (message.error) operation.reject(new Error('Browser protocol refused')); + else operation.resolve(message.result); + return; + } + if (message.method === 'Network.requestWillBeSent') { + const { requestId, request, type } = message.params; + if (events.length >= 256) return; + const event = { + url: request.url, + type, + rsc: Object.entries(request.headers).some( + ([key, value]) => key.toLowerCase() === 'rsc' && value === '1', + ), + status: null, + }; + requests.set(requestId, event); + events.push(event); + } + if (message.method === 'Network.responseReceived') { + const event = requests.get(message.params.requestId); + if (event) event.status = message.params.response.status; + } + }); + const evaluateBoolean = async (expression) => { + try { + const result = await command('Runtime.evaluate', { expression, returnByValue: true }); + return result.result.value === true; + } catch { + // A genuine document navigation replaces its execution context. Poll the + // new context within the fixed deadline; network observations stay mandatory. + return false; + } + }; + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Browser protocol startup deadline')), 5000); + socket.once('open', () => { + clearTimeout(timer); + resolve(); + }); + socket.once('error', () => { + clearTimeout(timer); + reject(new Error('Browser protocol startup')); + }); + }); + await command('Network.enable'); + await command('Page.enable'); + await command('Runtime.enable'); + await command('Page.navigate', { url: origin + initialPath }); + await until( + () => evaluateBoolean('Boolean(document.querySelector(\'[data-admission-ready="true"]\'))'), + 'Navigation probe hydration deadline', + ); + await beforeNavigation(); + events.length = 0; + requests.clear(); + await command('Runtime.evaluate', { + expression: "document.querySelector('[data-admission-navigation]').click()", + }); + await until(async () => { + const complete = await evaluateBoolean( + 'location.pathname === ' + + JSON.stringify(target) + + ' && document.readyState === "complete" && Boolean(document.querySelector("h1"))', + ); + return ( + complete && + events.some( + (event) => + event.type === 'Document' && + new URL(event.url).pathname === target && + event.status === 200, + ) + ); + }, 'Stock Next document fallback deadline'); + assertFlightFallback(events, origin, target, status); + await afterNavigation(); + assert.equal(scanner.leaked, false, 'Browser diagnostics contain no private carrier'); + return scanner; + } finally { + rejectPending(); + socket.terminate(); + await stopTestChild(child); + scanner.finish(); + assert.equal(scanner.leaked, false, 'Browser shutdown diagnostics contain no private carrier'); + } +} diff --git a/frontend/apps/web/scripts/admission-hmr-watch.d.mts b/frontend/apps/web/scripts/admission-hmr-watch.d.mts new file mode 100644 index 00000000..5df82151 --- /dev/null +++ b/frontend/apps/web/scripts/admission-hmr-watch.d.mts @@ -0,0 +1,20 @@ +import type { EventEmitter } from 'node:events'; + +type HmrSocket = Pick & { + readonly readyState: number; + terminate(): void; +}; +type SocketOptions = { + readonly ca: string | Buffer; + readonly servername: 'localhost'; + readonly rejectUnauthorized: true; + readonly maxPayload: number; +}; +export function watchDevelopmentSource(options: { + readonly app: string; + readonly certificate: string | Buffer; + readonly createSocket?: (url: string, options: SocketOptions) => HmrSocket; +}): Promise<{ + afterWrite(writeSource: () => void): Promise; + close(): void; +}>; diff --git a/frontend/apps/web/scripts/admission-hmr-watch.mjs b/frontend/apps/web/scripts/admission-hmr-watch.mjs new file mode 100644 index 00000000..bbf495ca --- /dev/null +++ b/frontend/apps/web/scripts/admission-hmr-watch.mjs @@ -0,0 +1,162 @@ +// Test-only observer of the pinned Next compiler's real HMR completion frame. +import { createRequire } from 'node:module'; +import { join } from 'node:path'; + +const DEADLINE_MS = 15_000; +const MAX_FRAME_BYTES = 256 * 1024; + +export async function watchDevelopmentSource({ app, certificate, createSocket }) { + let socket; + try { + const open = + createSocket ?? + ((url, options) => { + const WebSocket = createRequire(join(app, 'package.json'))('next/dist/compiled/ws'); + return new WebSocket(url, options); + }); + socket = open('wss://127.0.0.1:3000/_next/webpack-hmr', { + ca: certificate, + servername: 'localhost', + rejectUnauthorized: true, + maxPayload: MAX_FRAME_BYTES, + }); + } catch { + throw new Error('Development HMR watcher unavailable'); + } + + let phase = 'connecting'; + let accepted = false; + let opened = false; + let synchronized = false; + let resolveReady; + let rejectReady; + let pending; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + const readyTimer = setTimeout(() => stop(), DEADLINE_MS); + + function detach() { + for (const [event, listener] of [ + ['upgrade', upgrade], + ['open', open], + ['message', message], + ['error', error], + ['close', close], + ]) + socket.off(event, listener); + } + + function stop(terminate = true) { + if (phase === 'closed') return; + const wasConnecting = phase === 'connecting'; + phase = 'closed'; + clearTimeout(readyTimer); + const failure = new Error('Development HMR watcher failed'); + if (wasConnecting) rejectReady(failure); + if (pending) { + clearTimeout(pending.timer); + pending.reject(failure); + pending = undefined; + } + socket.off('upgrade', upgrade); + socket.off('open', open); + socket.off('message', message); + if (terminate) { + // ws may emit an error while terminating a connecting socket. Keep the + // fixed error handler until close, then remove all owned listeners. + try { + socket.terminate(); + } catch { + detach(); + } + } else detach(); + } + + function completeReady() { + if (phase !== 'connecting' || !accepted || !opened || !synchronized) return; + if (socket.readyState !== 1) return stop(); + phase = 'ready'; + clearTimeout(readyTimer); + resolveReady(); + } + + function upgrade(response) { + if (response.statusCode !== 101) return stop(); + accepted = true; + completeReady(); + } + + function open() { + opened = true; + completeReady(); + } + + function message(bytes) { + let frame; + try { + const value = bytes.toString(); + if (Buffer.byteLength(value) > MAX_FRAME_BYTES) return stop(); + frame = JSON.parse(value); + if (frame === null || typeof frame !== 'object' || Array.isArray(frame)) return stop(); + } catch { + return stop(); + } + if (frame.action === 'serverError' || (Array.isArray(frame.errors) && frame.errors.length > 0)) + return stop(); + if (frame.action === 'sync') { + if (typeof frame.hash !== 'string' || !frame.hash.trim()) return stop(); + synchronized = true; + completeReady(); + } else if (frame.action === 'serverComponentChanges' && pending) { + if (typeof frame.hash !== 'string' || !frame.hash.trim()) return stop(); + clearTimeout(pending.timer); + const resolve = pending.resolve; + pending = undefined; + resolve(); + } + // 'built' is only the client compiler. Initial sync and earlier change + // frames cannot release a waiter installed for a later source write. + } + + function error() { + stop(); + } + + function close() { + stop(false); + detach(); + } + + socket.on('upgrade', upgrade); + socket.on('open', open); + socket.on('message', message); + socket.on('error', error); + socket.on('close', close); + await ready; + + return Object.freeze({ + afterWrite(writeSource) { + if (phase !== 'ready' || pending) { + stop(); + return Promise.reject(new Error('Development HMR watcher failed')); + } + const result = new Promise((resolve, reject) => { + pending = { + resolve, + reject, + timer: setTimeout(() => stop(), DEADLINE_MS), + }; + }); + try { + // Register first: a fast compiler can send its update during the write. + writeSource(); + } catch { + stop(); + } + return result; + }, + close: () => stop(), + }); +} diff --git a/frontend/apps/web/scripts/admission-hmr.d.mts b/frontend/apps/web/scripts/admission-hmr.d.mts new file mode 100644 index 00000000..6289c565 --- /dev/null +++ b/frontend/apps/web/scripts/admission-hmr.d.mts @@ -0,0 +1,26 @@ +type NativeChild = { readonly pid: number; readonly exitCode: number | null }; +export function verifyHmrAdmission(options: { + readonly app: string; + readonly configuration: { readonly tenants: readonly { readonly name: string }[] }; + readonly checkpoint: (name: string) => Promise; + readonly startNative: (dev: boolean) => Promise; + readonly stopNative: (child: NativeChild) => Promise; + readonly call: ( + path: string, + options?: { + readonly headers?: Readonly>; + }, + ) => Promise<{ + readonly status: number; + readonly headers: Readonly>; + readonly body: string; + }>; + readonly observe: (child: NativeChild) => Promise<{ + readonly active: number; + readonly snapshots: number; + }>; + readonly watchSource: () => Promise<{ + readonly afterWrite: (write: () => void) => Promise; + readonly close: () => void; + }>; +}): Promise; diff --git a/frontend/apps/web/scripts/admission-hmr.mjs b/frontend/apps/web/scripts/admission-hmr.mjs new file mode 100644 index 00000000..f7dc953c --- /dev/null +++ b/frontend/apps/web/scripts/admission-hmr.mjs @@ -0,0 +1,103 @@ +// Test-only development recompile and live admission proof over the shipped launcher. +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export async function verifyHmrAdmission({ + app, + configuration, + checkpoint, + startNative, + stopNative, + call, + observe, + watchSource, +}) { + const assertReleased = async (child) => { + const counts = await observe(child); + assert.equal(counts.active, 0, 'Completed development render releases its admission'); + assert.equal(counts.snapshots, 0, 'Completed development render releases its snapshot'); + }; + const path = join(app, 'src/app/(studio)/studio/page.tsx'); + const original = readFileSync(path, 'utf8'); + const page = ( + marker, + ) => `import { getPublicRequest, assertPublicRequestActive } from '@/server/public-request'; +export const dynamic = 'force-dynamic'; +export default async function HmrAdmissionProbe() { + const request = await getPublicRequest(); + if (!request) throw new Error('HMR admission unavailable'); + assertPublicRequestActive(request); + return

{request.site.displayName}

${marker}

; +} +`; + let child; + let watcher; + let changed = false; + let stage = 'hmr-setup'; + try { + writeFileSync(path, page('admission-hmr-before')); + child = await startNative(true); + const pid = child.pid; + await checkpoint('hmr-before'); + stage = 'hmr-initial-request'; + const before = await call('/studio'); + stage = 'hmr-initial-status'; + assert.equal(before.status, 200, 'Development admission positive control'); + stage = 'hmr-initial-source'; + assert.match(before.body, /admission-hmr-before/); + stage = 'hmr-initial-site'; + assert.ok(before.body.includes(configuration.tenants[0].name), 'Original live site name'); + stage = 'hmr-initial-release'; + await assertReleased(child); + stage = 'hmr-watch-connect'; + watcher = await watchSource(); + stage = 'hmr-change'; + await checkpoint('hmr-change'); + changed = true; + stage = 'hmr-source-barrier'; + await watcher.afterWrite(() => writeFileSync(path, page('admission-hmr-after'))); + stage = 'hmr-refresh-request'; + const after = await call('/studio', { + headers: { RSC: '1', 'Next-Hmr-Refresh': '1' }, + }); + stage = 'hmr-refresh-status'; + assert.equal(after.status, 200, 'Development HMR render succeeds'); + stage = 'hmr-refresh-type'; + assert.match(after.headers['content-type'], /^text\/x-component/); + stage = 'hmr-refresh-source'; + assert.match( + after.body, + /admission-hmr-after/, + 'Changed source recompiles in the same process', + ); + assert.equal(after.body.includes('admission-hmr-before'), false, 'Old source is not reused'); + stage = 'hmr-refresh-site'; + assert.ok( + after.body.includes(configuration.tenants[0].name + ' HMR refresh'), + 'HMR reads the changed live API site instead of retaining the old snapshot', + ); + assert.equal(child.pid, pid, 'Both renders use the same native development process'); + assert.equal(child.exitCode, null); + stage = 'hmr-refresh-release'; + await assertReleased(child); + stage = 'hmr-after'; + await checkpoint('hmr-after'); + } catch { + // Closed diagnostic token only; never forward request, response or provider text. + const failure = new Error('Development admission proof failed'); + Object.defineProperty(failure, 'admissionStage', { value: stage }); + throw failure; + } finally { + watcher?.close(); + try { + writeFileSync(path, original); + } finally { + try { + if (child) await stopNative(child); + } finally { + if (changed) await checkpoint('hmr-restore'); + } + } + } +} diff --git a/frontend/apps/web/scripts/verify-public-admission.mjs b/frontend/apps/web/scripts/verify-public-admission.mjs new file mode 100644 index 00000000..cef14154 --- /dev/null +++ b/frontend/apps/web/scripts/verify-public-admission.mjs @@ -0,0 +1,804 @@ +// Test-only copied production app, driven by PublicAdmissionRenderingTests over private stdin. +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { Agent, request as httpsRequest } from 'node:https'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; + +import { verifyBrowserFallback } from './admission-browser.mjs'; +import { watchDevelopmentSource } from './admission-hmr-watch.mjs'; +import { verifyHmrAdmission } from './admission-hmr.mjs'; +import { + createFixtureOwner, + createPrivateScanner, + fixtureEnvironment, +} from './fixture-support.mjs'; + +const sourceApp = fileURLToPath(new URL('../', import.meta.url)); +const owner = createFixtureOwner(); +const scanners = []; +const pendingMessages = new Map(); +let nextMessage = 0; +let root; +let app; +let configuration; +let certificate; +let input; +let lines; +let privateScanner; +let stage = 'configuration'; + +async function bounded(promise, milliseconds, label) { + let timer; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(label)), milliseconds); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +async function checkpoint(name) { + assert.match(name, /^[a-z0-9-]{1,63}$/); + process.stdout.write(name + '\n'); + const answer = await bounded(lines.next(), 30_000, 'Parent control deadline'); + owner.assertActive(); + assert.equal(answer.done, false, 'Parent control closed'); + const value = JSON.parse(answer.value); + assert.equal(value.kind, 'continue'); + if (value.apiOrigin !== null && value.apiOrigin !== undefined) { + assert.match(value.apiOrigin, /^http:\/\/127\.0\.0\.1:\d+$/); + configuration.apiOrigin = value.apiOrigin; + } + return value; +} + +function environment(dev = false) { + return fixtureEnvironment({ + root, + nodeEnv: dev ? 'development' : 'production', + nodePath: join(sourceApp, '../../node_modules/.pnpm/node_modules'), + privateValues: { + LEARNSTACK_PUBLIC_API_ORIGIN: configuration.apiOrigin, + LEARNSTACK_PUBLIC_HOP_SECRET: configuration.secret, + LEARNSTACK_PUBLIC_TLS_CERT: join(root, 'cert.pem'), + LEARNSTACK_PUBLIC_TLS_KEY: join(root, 'key.pem'), + }, + }); +} + +function start(command, args, dev = false, ipc = false) { + owner.assertActive(); + const child = owner.ownChild( + spawn(command, args, { + cwd: app, + env: environment(dev), + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe', ...(ipc ? ['ipc'] : [])], + }), + ); + child.failed = false; + child.on('error', () => { + child.failed = true; + }); + const outputs = []; + for (const stream of [child.stdout, child.stderr]) { + const scanner = createPrivateScanner([configuration.secret, configuration.privateMarker]); + outputs.push(scanner); + scanners.push(scanner); + stream.on('data', (chunk) => scanner.push(chunk)); + stream.once('end', () => scanner.finish()); + stream.once('close', () => scanner.finish()); + } + Object.defineProperty(child, 'output', { + get: () => outputs.map((scanner) => scanner.tail).join('\n'), + }); + child.on('message', (message) => { + if (message?.kind === 'shutdown') child.shutdownCounts = message.counts; + else if (message?.id) pendingMessages.get(child)?.get(message.id)?.(message); + }); + return child; +} + +async function completion(child, milliseconds = 180_000) { + if (child.exitCode !== null) return child.exitCode; + return bounded( + new Promise((resolve, reject) => { + child.once('error', () => reject(new Error('Child startup'))); + child.once('close', (code) => resolve(code)); + }), + milliseconds, + 'Child completion deadline', + ); +} + +async function control(child, op, value) { + assert.equal(child.connected, true, 'Owned native IPC remains connected'); + const id = ++nextMessage; + let messages = pendingMessages.get(child); + if (!messages) { + messages = new Map(); + pendingMessages.set(child, messages); + } + try { + const result = new Promise((resolve) => messages.set(id, resolve)); + child.send({ id, op, value }); + return await bounded(result, 10_000, 'Native observation deadline'); + } finally { + messages.delete(id); + } +} +const observe = (child) => control(child, 'observe'); + +function beginCall( + path, + { host = configuration.tenants[0].host, method = 'GET', headers = {}, agent } = {}, +) { + owner.assertActive(); + let request; + let socket; + const result = new Promise((resolve, reject) => { + request = httpsRequest( + { + hostname: '127.0.0.1', + port: 3000, + path, + method, + agent, + headers: { Host: host + ':3000', ...headers }, + ca: certificate, + servername: host, + rejectUnauthorized: true, + timeout: 20_000, + }, + (response) => { + const chunks = []; + let size = 0; + response.on('data', (chunk) => { + size += chunk.length; + if (size > 4 * 1024 * 1024) request.destroy(new Error('Renderer response 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'), + socket, + }), + ); + }, + ); + request.on('socket', (value) => { + socket = value; + }); + request.once('timeout', () => request.destroy(new Error('Renderer request deadline'))); + request.once('error', reject); + request.end(); + }); + // Attach immediately: deliberate aborts must never become unhandled rejections. + const outcome = result.then( + (response) => ({ response }), + () => ({ aborted: true }), + ); + return { result, outcome, abort: () => request.destroy(new Error('Owned client abort')) }; +} +const call = (path, options) => beginCall(path, options).result; + +async function vacant(port) { + const probe = owner.ownServer(createServer()); + await new Promise((resolve, reject) => { + probe.once('error', () => reject(new Error('Required fixture port occupied'))); + probe.listen(port, '127.0.0.1', resolve); + }); + await new Promise((resolve, reject) => + probe.close((error) => (error ? reject(error) : resolve())), + ); +} + +async function startNative(dev = false) { + const child = start( + process.execPath, + [join(app, 'scripts/public-server.mjs'), ...(dev ? ['--dev'] : [])], + dev, + true, + ); + for (let attempt = 0; attempt < 250; attempt++) { + assert.equal( + child.failed || child.exitCode !== null || child.signalCode !== null, + false, + 'Native startup', + ); + if (/Public HTTPS listener ready on 127\.0\.0\.1:3000/.test(child.output)) { + try { + const health = await call('/api/healthz'); + if (health.status === 200 && JSON.parse(health.body).status === 'healthy') return child; + } catch { + /* Only the exempt readiness request is retried. */ + } + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error('Native readiness deadline'); +} + +function empty(counts) { + assert.equal(counts.active, 0, 'No native request retained'); + assert.equal(counts.snapshots, 0, 'No admitted snapshot retained'); +} + +async function stopNative(child, signal = 'SIGTERM') { + assert.equal(child.exitCode, null, 'Native server remains live before shutdown'); + child.kill(signal); + assert.equal(await completion(child, 10_000), 0, 'Native shutdown without escalation'); + empty(child.shutdownCounts); + pendingMessages.delete(child); +} + +function safe(response) { + assert.match(response.headers['cache-control'], /(?:^|,\s*)no-store(?:,|$)/); + for (const header of ['set-cookie', 'x-powered-by', 'x-learnstack-ingress-provenance']) + assert.equal(response.headers[header], undefined); + assert.equal( + privateScanner.contains(JSON.stringify({ headers: response.headers, body: response.body })), + false, + ); +} + +function refused(response, status, method = 'GET', retryAfter) { + safe(response); + assert.equal(response.status, status, 'Original wire refusal status'); + assert.match(response.headers['content-type'], /^text\/plain;\s*charset=utf-8$/i); + assert.equal(response.headers['retry-after'], retryAfter); + assert.equal( + response.body, + method === 'HEAD' + ? '' + : status === 404 + ? 'Not found' + : status === 429 + ? 'Too many requests' + : 'Service unavailable', + ); + for (const tenant of configuration.tenants) + assert.equal(response.body.includes(tenant.name), false); + assert.equal(response.body.includes('data-public-theme'), false); +} + +function scanAssets(directory) { + let scripts = 0; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) scripts += scanAssets(path); + else { + if (entry.name.endsWith('.js')) scripts++; + const asset = readFileSync(path, 'utf8'); + assert.equal(privateScanner.contains(asset), false, 'Client private containment'); + assert.equal( + asset.includes('learnstack.public-admission.v1'), + false, + 'Native holder stays server-only', + ); + assert.equal( + asset.includes('learnstack.public-ingress.v1'), + false, + 'Provenance HMAC stays server-only', + ); + } + } + return scripts; +} + +function replaceOnce(path, needle, replacement) { + const source = readFileSync(path, 'utf8'); + assert.equal(source.split(needle).length, 2, 'Disposable instrumentation anchor is unique'); + writeFileSync(path, source.replace(needle, replacement)); +} + +function instrumentCopy() { + // IPC-only observations in this disposable app. No diagnostic route, DTO, + // binding, envelope or secret is sent to the parent or added to production. + const launcher = join(app, 'scripts/public-server.mjs'); + replaceOnce( + launcher, + 'import { installPublicAdmissionRuntime }', + 'import { createPublicAdmissionRuntime, installPublicAdmissionRuntime }', + ); + replaceOnce( + launcher, + ' const admission = installPublicAdmissionRuntime();', + ` const admission = installPublicAdmissionRuntime(); + const snapshotIds = new WeakMap(); + let snapshotsSeen = 0; + let reads = 0; + let immutable = true; + let lateRefused = 0; + let lateAccepted = 0; + let finishLate; + const originalOrigin = process.env.LEARNSTACK_PUBLIC_API_ORIGIN; + const fixture = { + mode: 'normal', finished: Promise.resolve(), + record(site) { + reads++; + if (!snapshotIds.has(site)) snapshotIds.set(site, ++snapshotsSeen); + immutable &&= Object.isFrozen(site) && Object.isFrozen(site.enabledLocales) + && (site.theme === null || Object.isFrozen(site.theme)); + const name = site.displayName; + const locale = site.enabledLocales[0]; + try { site.displayName = 'fixture mutation'; } catch {} + try { site.enabledLocales[0] = 'fixture mutation'; } catch {} + immutable &&= site.displayName === name && site.enabledLocales[0] === locale; + if (site.theme !== null) { + const primary = site.theme.primary; + try { site.theme.primary = 'fixture mutation'; } catch {} + immutable &&= site.theme.primary === primary; + } + }, + late(refused) { if (refused) lateRefused++; else lateAccepted++; } + }; + Object.defineProperty(globalThis, Symbol.for('learnstack.fixture.admission'), { value: fixture }); + Object.defineProperty(globalThis, Symbol.for('learnstack.fixture.duplicate'), { + value: createPublicAdmissionRuntime().holder, writable: false, enumerable: false, configurable: false + }); + process.on('message', (message) => { + if (!message || !Number.isInteger(message.id)) return; + if (message.op === 'mode') { + if (!['normal','missing','mismatch','duplicate','late','configuration','wrong-status'].includes(message.value)) return; + fixture.mode = message.value; + process.env.LEARNSTACK_PUBLIC_API_ORIGIN = message.value === 'configuration' ? 'invalid' : originalOrigin; + if (message.value === 'late') fixture.finished = new Promise((resolve) => { finishLate = resolve; }); + } else if (message.op !== 'observe') return; + process.send?.({ id: message.id, ...admission.counts(), snapshotsSeen, reads, immutable, lateRefused, lateAccepted }); + });`, + ); + replaceOnce( + launcher, + ' if (!binding) return refuseIngress(response);', + ` if (!binding) return refuseIngress(response); + if (fixture.mode === 'late') response.once('finish', () => finishLate?.());`, + ); + replaceOnce( + launcher, + '.then(() => admission.run(binding, request, response, () => handle(request, response)))', + `.then(() => fixture.mode === 'missing' + ? handle(request, response) + : admission.run(fixture.mode === 'mismatch' ? { ...binding, target: '/mismatch' } : binding, + request, response, () => handle(request, response)))`, + ); + replaceOnce( + launcher, + ' stopping = true;\n admission.shutdown();', + ` stopping = true; + admission.shutdown(); + await new Promise((resolve) => process.send?.({ kind: 'shutdown', counts: admission.counts() }, resolve));`, + ); + + writeFileSync( + join(app, 'src/server/admission-fixture.ts'), + `import 'server-only'; +type Fixture = { mode: string; finished: Promise; record(site: unknown): void; late(refused: boolean): void }; +export function admissionFixture() { + return (globalThis as unknown as Record)[Symbol.for('learnstack.fixture.admission')]; +} +`, + ); + const facade = join(app, 'src/server/public-admission.ts'); + replaceOnce( + facade, + "import 'server-only';", + "import 'server-only';\nimport { admissionFixture } from './admission-fixture';", + ); + replaceOnce( + facade, + "Symbol.for('learnstack.public-admission.v1')", + "Symbol.for(admissionFixture()?.mode === 'duplicate' ? 'learnstack.fixture.duplicate' : 'learnstack.public-admission.v1')", + ); + replaceOnce( + facade, + "return holder().read(binding) as Omit & {", + "const admitted = holder().read(binding);\n admissionFixture()?.record(admitted.site);\n return admitted as Omit & {", + ); + const middleware = join(app, 'src/middleware.ts'); + replaceOnce( + middleware, + "import { NextResponse, type NextRequest } from 'next/server';", + "import { NextResponse, type NextRequest } from 'next/server';\nimport { admissionFixture } from '@/server/admission-fixture';", + ); + replaceOnce( + middleware, + ' status,\n headers:', + " status: admissionFixture()?.mode === 'wrong-status' ? 200 : status,\n headers:", + ); + + writeFileSync( + join(app, 'src/server/late-admission-probe.tsx'), + `import 'server-only'; +import { admissionFixture } from './admission-fixture'; +import { getPublicResource } from './public-resource'; +export async function LateAdmissionProbe() { + const fixture = admissionFixture(); + if (!fixture || fixture.mode !== 'late') return null; + await fixture.finished; + try { await getPublicResource(); fixture.late(false); } + catch (error) { fixture.late(error instanceof Error && error.message === 'Public admission request completed'); } + return null; +} +`, + ); + const layout = join(app, 'src/app/layout.tsx'); + replaceOnce( + layout, + "import type { ReactNode } from 'react';", + "import { Suspense, type ReactNode } from 'react';\nimport { LateAdmissionProbe } from '@/server/late-admission-probe';", + ); + replaceOnce( + layout, + '{children}', + '{children}', + ); + const studio = join(app, 'src/app/(studio)/studio'); + writeFileSync( + join(studio, 'admission-navigation.tsx'), + `'use client'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +export default function AdmissionNavigation({ target }: { target: string }) { + const [ready, setReady] = useState(false); + const router = useRouter(); + useEffect(() => setReady(true), []); + return ; +} +`, + ); + writeFileSync( + join(studio, 'page.tsx'), + `import AdmissionNavigation from './admission-navigation'; +export default function AdmissionPage() { return

Admission fixture

; } +`, + ); +} + +async function wireProof(native) { + const catalog = '/' + configuration.locale + '/courses'; + for (const fault of configuration.wireCases) { + stage = 'wire ' + fault.name; + await checkpoint('arm-' + fault.name); + const response = await call(catalog, { method: fault.method }); + refused(response, fault.status, fault.method, fault.retryAfter ?? undefined); + empty(await observe(native)); + await checkpoint('done-' + fault.name); + } + stage = 'wire mutant'; + await control(native, 'mode', 'wrong-status'); + await checkpoint('arm-rate-zero'); + const mutant = await call(catalog); + assert.throws(() => refused(mutant, 429, 'GET', '0'), /Original wire refusal status/); + await checkpoint('done-rate-zero'); + await control(native, 'mode', 'normal'); + await checkpoint('arm-rate-zero'); + refused(await call(catalog), 429, 'GET', '0'); + await checkpoint('done-rate-zero'); + stage = 'wire positive'; + const healthy = await call(catalog); + safe(healthy); + assert.equal(healthy.status, 200); + assert.ok(healthy.body.includes(configuration.tenants[0].name)); + empty(await observe(native)); + await checkpoint('done-healthy-wire'); + refused(await call('/unadmitted-fixture-path'), 404); + await checkpoint('done-entry-refusal'); +} + +async function lifetimes(native) { + const catalog = '/' + configuration.locale + '/courses'; + for (const mode of ['missing', 'mismatch', 'duplicate', 'configuration']) { + stage = 'context ' + mode; + await control(native, 'mode', mode); + refused(await call(catalog), 503); + empty(await observe(native)); + await checkpoint(mode === 'configuration' ? 'done-configuration' : 'done-context-' + mode); + } + await control(native, 'mode', 'normal'); + const healthy = await call(catalog); + assert.equal(healthy.status, 200); + await checkpoint('done-healthy-lifetime'); + for (const protocol of ['html', 'rsc', 'prefetch']) { + stage = protocol + ' overlap'; + const before = await observe(native); + const headers = + protocol === 'html' + ? {} + : { RSC: '1', ...(protocol === 'prefetch' ? { 'Next-Router-Prefetch': '1' } : {}) }; + await checkpoint('arm-overlap'); + const pending = Array.from({ length: 4 }, () => beginCall(catalog, { headers })); + await checkpoint('held-overlap'); + const held = await observe(native); + assert.equal(held.active, 4); + assert.equal(held.snapshots, 0); + await checkpoint('release-overlap'); + const responses = await Promise.all(pending.map((request) => request.result)); + for (const response of responses) { + safe(response); + assert.equal(response.status, 200); + } + const after = await observe(native); + empty(after); + assert.equal(after.immutable, true); + assert.equal( + after.snapshotsSeen - before.snapshotsSeen, + 4, + 'Identical incoming requests have separate snapshots', + ); + assert.ok(after.reads - before.reads > 4, 'Multiple consumers share the four snapshots'); + await checkpoint('done-' + protocol + '-overlap'); + } + stage = 'mixed overlap'; + const lanes = configuration.tenants.flatMap((tenant) => + [false, true].map((rsc) => ({ tenant, rsc })), + ); + await checkpoint('arm-overlap'); + const pendingMixed = lanes.map(({ tenant, rsc }) => + beginCall('/' + tenant.locale + '/courses', { + host: tenant.host, + headers: rsc ? { RSC: '1' } : {}, + }), + ); + await checkpoint('held-overlap'); + const mixedHeld = await observe(native); + assert.equal(mixedHeld.active, 4); + assert.equal(mixedHeld.snapshots, 0); + await checkpoint('release-overlap'); + const mixed = await Promise.all(pendingMixed.map((request) => request.result)); + mixed.forEach((response, index) => { + safe(response); + assert.equal(response.status, 200); + assert.ok(response.body.includes(lanes[index].tenant.name)); + const other = configuration.tenants.find((tenant) => tenant.host !== lanes[index].tenant.host); + assert.equal(response.body.includes(other.name), false); + }); + empty(await observe(native)); + await checkpoint('done-mixed-overlap'); + stage = 'keep-alive'; + const agent = new Agent({ keepAlive: true, maxSockets: 1 }); + try { + const first = await call(catalog, { agent }); + const second = await call(catalog, { agent }); + assert.equal(first.status, 200); + assert.equal(second.status, 200); + assert.equal( + first.socket, + second.socket, + 'One physical keep-alive connection serves fresh native requests', + ); + empty(await observe(native)); + } finally { + agent.destroy(); + } + await checkpoint('done-keep-alive'); + stage = 'valid HEAD'; + const head = await call(catalog, { method: 'HEAD' }); + safe(head); + assert.equal(head.status, 200); + assert.equal(head.body, ''); + empty(await observe(native)); + await checkpoint('done-head'); + stage = 'late HEAD'; + await control(native, 'mode', 'late'); + const late = await call('/studio', { method: 'HEAD' }); + assert.equal(late.status, 200); + assert.equal(late.body, ''); + for (let attempt = 0; attempt < 100; attempt++) { + const state = await observe(native); + if (state.lateRefused === 1) break; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + const lateState = await observe(native); + empty(lateState); + assert.equal(lateState.lateRefused, 1); + assert.equal(lateState.lateAccepted, 0); + await checkpoint('done-late-head'); + await control(native, 'mode', 'normal'); + for (const kind of ['bootstrap', 'content']) { + stage = kind + ' client abort'; + await checkpoint('arm-' + kind); + const pending = beginCall(catalog); + await checkpoint('held-' + kind); + const active = await observe(native); + assert.equal(active.active, 1); + assert.equal(active.snapshots, kind === 'bootstrap' ? 0 : 1); + pending.abort(); + assert.equal((await pending.outcome).aborted, true); + await checkpoint('aborted-' + kind); + empty(await observe(native)); + } + stage = 'active shutdown'; + await checkpoint('arm-shutdown'); + const pending = Array.from({ length: 2 }, () => beginCall(catalog)); + await checkpoint('held-shutdown'); + const active = await observe(native); + assert.equal(active.active, 2); + assert.equal(active.snapshots, 2); + await stopNative(native); + await Promise.all(pending.map((request) => request.outcome)); + await checkpoint('aborted-shutdown'); +} + +try { + input = createInterface({ input: process.stdin, terminal: false }); + owner.install({ control: input, event: 'close' }); + lines = input[Symbol.asyncIterator](); + const first = await bounded(lines.next(), 30_000, 'Configuration deadline'); + assert.equal(first.done, false); + configuration = JSON.parse(first.value); + assert.match(configuration.apiOrigin, /^http:\/\/127\.0\.0\.1:\d+$/); + assert.match(configuration.secret, /^[a-f0-9]{48}$/); + assert.equal(configuration.tenants.length, 2); + privateScanner = createPrivateScanner([configuration.secret, configuration.privateMarker]); + root = owner.ownRoot(mkdtempSync(join(tmpdir(), 'learnstack-public-admission-'))); + app = join(root, 'frontend/apps/web'); + await vacant(3000); + await vacant(3011); + mkdirSync(app, { recursive: true }); + for (const name of [ + 'src', + 'scripts', + 'package.json', + 'next.config.ts', + 'next-env.d.ts', + 'tsconfig.json', + 'tsconfig.server.json', + 'postcss.config.cjs', + 'tailwind.config.ts', + '.eslintrc.cjs', + ]) { + cpSync(join(sourceApp, name), join(app, name), { + recursive: true, + filter: (path) => + !/\.test\.[jt]sx?$/.test(path) && + path !== join(sourceApp, 'src/test') && + !path.startsWith(join(sourceApp, 'src/test/')), + }); + } + symlinkSync(join(sourceApp, 'node_modules'), join(app, 'node_modules'), 'dir'); + instrumentCopy(); + stage = 'production build'; + const compiler = start(process.execPath, [ + join(sourceApp, 'node_modules/typescript/bin/tsc'), + '--project', + 'tsconfig.server.json', + ]); + assert.equal(await completion(compiler), 0, 'Ingress build'); + const build = start(process.execPath, [ + join(sourceApp, 'node_modules/next/dist/bin/next'), + 'build', + ]); + assert.equal(await completion(build), 0, 'Copied product build'); + assert.ok(scanAssets(join(app, '.next/static')) > 0); + await checkpoint('build-complete'); + stage = 'isolated TLS'; + writeFileSync( + join(root, 'tls.cnf'), + '[req]\ndistinguished_name=dn\nx509_extensions=ext\nprompt=no\n[dn]\nCN=localhost\n[ext]\nsubjectAltName=DNS:localhost,' + + configuration.tenants.map((tenant) => 'DNS:' + tenant.host).join(',') + + '\nbasicConstraints=critical,CA:TRUE\n', + ); + const openssl = start('openssl', [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-days', + '1', + '-config', + join(root, 'tls.cnf'), + '-keyout', + join(root, 'key.pem'), + '-out', + join(root, 'cert.pem'), + ]); + assert.equal(await completion(openssl, 20_000), 0); + certificate = readFileSync(join(root, 'cert.pem')); + await checkpoint('phase-wire'); + let native = await startNative(); + await wireProof(native); + await stopNative(native); + await checkpoint('phase-lifetime'); + native = await startNative(); + await lifetimes(native); + await checkpoint('phase-browser'); + native = await startNative(); + for (const status of [429, 503]) { + stage = 'browser ' + status; + scanners.push( + await verifyBrowserFallback({ + owner, + app, + root, + certificate, + origin: 'https://' + configuration.tenants[0].host + ':3000', + initialPath: '/studio', + target: '/' + configuration.locale + '/courses/' + configuration.courseSlug, + status, + secrets: [configuration.secret, configuration.privateMarker], + beforeNavigation: async () => { + await checkpoint('done-browser-initial'); + await checkpoint('arm-browser-' + status); + }, + afterNavigation: async () => { + empty(await observe(native)); + await checkpoint('done-browser-' + status); + }, + }), + ); + } + await stopNative(native); + stage = 'development HMR'; + await verifyHmrAdmission({ + app, + configuration, + checkpoint, + startNative, + stopNative, + call, + observe, + watchSource: () => watchDevelopmentSource({ app, certificate }), + }); + stage = 'private containment'; + assert.equal( + scanners.some((scanner) => scanner.leaked), + false, + ); + await checkpoint('verified'); +} catch (error) { + const hmrStages = new Set([ + 'hmr-setup', + 'hmr-initial-request', + 'hmr-initial-status', + 'hmr-initial-source', + 'hmr-initial-site', + 'hmr-initial-release', + 'hmr-watch-connect', + 'hmr-change', + 'hmr-source-barrier', + 'hmr-refresh-request', + 'hmr-refresh-status', + 'hmr-refresh-type', + 'hmr-refresh-source', + 'hmr-refresh-site', + 'hmr-refresh-release', + 'hmr-after', + ]); + const detail = hmrStages.has(error?.admissionStage) ? ' ' + error.admissionStage : ''; + process.stderr.write('Public admission fixture failed during ' + stage + detail + '.\n'); + process.exitCode = 1; +} finally { + try { + await owner.dispose(); + assert.equal( + scanners.some((scanner) => scanner.leaked), + false, + ); + } catch { + process.stderr.write('Public admission fixture cleanup failed.\n'); + process.exitCode = 1; + } + input?.close(); +} diff --git a/frontend/apps/web/scripts/verify-public-rendering.mjs b/frontend/apps/web/scripts/verify-public-rendering.mjs index 94977015..409c390a 100644 --- a/frontend/apps/web/scripts/verify-public-rendering.mjs +++ b/frontend/apps/web/scripts/verify-public-rendering.mjs @@ -283,35 +283,31 @@ export default function FixtureClient({ displayName }: { displayName: string }) } `; -// This source exists only inside the disposable app. Every public value comes -// from the real configured SDK; headers, envelopes and errors are never serialized. -const page = `import { headers } from 'next/headers'; -import { notFound } from 'next/navigation'; -import { createConfiguredPublicClient } from '@/server/configured-public-client'; -import { INGRESS_HEADER } from '@/server/ingress'; +// This source exists only inside the disposable app. It consumes the shipped +// middleware admission and configured SDK; private carriers are never serialized. +const page = `import { notFound } from 'next/navigation'; +import { assertPublicRequestActive, getPublicRequest } from '@/server/public-request'; import FixtureClient from './fixture-client'; export default async function FixturePage({ params }: { params: Promise<{ locale: string; segments?: string[] }>; }) { - const incoming = await headers(); - const client = createConfiguredPublicClient(incoming.get(INGRESS_HEADER), { - traceparent: incoming.get('traceparent'), - }); - if (!client) notFound(); - const site = await client.getSite(); - if (site.kind !== 'success') throw new Error('Fixture bootstrap failed'); + const request = await getPublicRequest(); + if (!request) notFound(); + const { client, site } = request; const { segments = [] } = await params; + assertPublicRequestActive(request); const result = segments.length === 0 ? await client.getCourses() : segments.length === 1 ? await client.getCourse({ slug: segments[0]! }) : await client.getLesson({ slug: segments[0]!, lessonSlug: segments[2]! }); + assertPublicRequestActive(request); if (result.kind === 'api-error' && result.status === 404) notFound(); if (result.kind !== 'success') throw new Error('Fixture public read failed'); - return

{site.data.displayName}

{JSON.stringify(result.data)}
; + return

{site.displayName}

{JSON.stringify(result.data)}
; } `; -// P5 deliberately keeps its own neutral document and independent SDK calls. +// P5 keeps its neutral document while sharing the shipped middleware admission. // Replacing only this disposable copy prevents later product layouts from adding // reads to the transport proof or its synthetic catch-all shadowing product pages. const transportRoot = `import type { ReactNode } from 'react'; diff --git a/frontend/apps/web/src/server/configured-public-client.test.ts b/frontend/apps/web/src/server/configured-public-client.test.ts index cba2211b..aeeca92a 100644 --- a/frontend/apps/web/src/server/configured-public-client.test.ts +++ b/frontend/apps/web/src/server/configured-public-client.test.ts @@ -68,7 +68,15 @@ function client( function stubResponse(body: unknown = site, status = 200, headers?: HeadersInit) { const transport = vi.fn(); - transport.mockImplementation(async () => new Response(JSON.stringify(body), { status, headers })); + const responseHeaders = new Headers(headers); + if (!responseHeaders.has('content-type')) + responseHeaders.set( + 'content-type', + status < 300 ? 'application/json' : 'application/problem+json', + ); + transport.mockImplementation( + async () => new Response(JSON.stringify(body), { status, headers: responseHeaders }), + ); vi.stubGlobal('fetch', transport); return transport; } @@ -201,6 +209,49 @@ describe('configured public authority and operation boundary', () => { }); describe('trace context and SDK outcomes', () => { + it.each(['application/json', 'Application/JSON; charset=utf-8'])( + 'accepts the public success media type %s', + async (contentType) => { + stubResponse(site, 200, { 'content-type': contentType }); + expect(await client().getSite()).toEqual({ kind: 'success', status: 200, data: site }); + }, + ); + + it.each(['application/problem+json', 'Application/Problem+JSON; charset=utf-8'])( + 'accepts the public failure media type %s', + async (contentType) => { + stubResponse(problem, 429, { 'content-type': contentType }); + expect(await client().getSite()).toMatchObject({ kind: 'api-error', status: 429 }); + }, + ); + + it.each([ + [200, null], + [200, 'text/plain'], + [200, 'application/problem+json'], + [200, 'application/json-extra'], + [429, null], + [429, 'text/plain'], + [429, 'application/json'], + ])('refuses status %s with media type %s and releases its body', async (status, contentType) => { + vi.useFakeTimers(); + const cancel = vi.fn(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode(JSON.stringify(status === 200 ? site : problem)), + ); + }, + cancel, + }); + const headers = contentType === null ? undefined : { 'content-type': contentType }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body, { status, headers }))); + expect(await client().getSite()).toEqual({ kind: 'transport-error' }); + expect(cancel).toHaveBeenCalledOnce(); + expect(body.locked).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + it('preserves the incoming W3C trace and the Problem Details trace identifier', async () => { const transport = stubResponse(problem, 429, { 'Retry-After': '27' }); const result = await client({ traceparent }).getSite(); @@ -249,12 +300,16 @@ describe('trace context and SDK outcomes', () => { it('preserves malformed JSON, invalid wire shape and network failure as distinct SDK outcomes', async () => { const transport = stubResponse(); - transport.mockResolvedValueOnce(new Response('{')); + transport.mockResolvedValueOnce( + new Response('{', { headers: { 'content-type': 'application/json' } }), + ); expect(await client().getSite()).toEqual({ kind: 'invalid-response', reason: 'malformed-json', }); - transport.mockResolvedValueOnce(new Response('{}')); + transport.mockResolvedValueOnce( + new Response('{}', { headers: { 'content-type': 'application/json' } }), + ); expect(await client().getSite()).toEqual({ kind: 'invalid-response', reason: 'invalid-success', @@ -287,7 +342,12 @@ describe('trace context and SDK outcomes', () => { }, cancel, }); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body))); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue(new Response(body, { headers: { 'content-type': 'application/json' } })), + ); expect(await client({ signal: cancellation.signal }).getSite()).toEqual({ kind: 'transport-error', }); @@ -301,7 +361,12 @@ describe('trace context and SDK outcomes', () => { vi.useFakeTimers(); const cancel = vi.fn(() => new Promise(() => {})); const body = new ReadableStream({ cancel }); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body))); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue(new Response(body, { headers: { 'content-type': 'application/json' } })), + ); const pending = client().getSite(); await vi.advanceTimersByTimeAsync(10_000); expect(await pending).toEqual({ kind: 'transport-error' }); diff --git a/frontend/apps/web/src/server/configured-public-client.ts b/frontend/apps/web/src/server/configured-public-client.ts index 0c5438a9..5ac6e1be 100644 --- a/frontend/apps/web/src/server/configured-public-client.ts +++ b/frontend/apps/web/src/server/configured-public-client.ts @@ -82,6 +82,12 @@ async function boundedResponse( })(); const response = await Promise.race([pending, interrupted]); reader = response.body?.getReader(); + const mediaType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); + const expectedMediaType = response.ok ? 'application/json' : 'application/problem+json'; + if (mediaType !== expectedMediaType) { + controller.abort(); + throw new Error('Invalid public API response media type'); + } const chunks: Uint8Array[] = []; let size = 0; if (reader) { diff --git a/frontend/apps/web/src/test/admission-browser.test.ts b/frontend/apps/web/src/test/admission-browser.test.ts new file mode 100644 index 00000000..aaf1a49a --- /dev/null +++ b/frontend/apps/web/src/test/admission-browser.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +import { assertFlightFallback } from '../../scripts/admission-browser.mjs'; + +const origin = 'https://fixture.localhost:3000'; +const target = '/en/courses/fixture-course'; +const flight = { + url: origin + target + '?_rsc=fixture', + type: 'Fetch', + rsc: true, + status: 503, +}; +const document = { url: origin + target, type: 'Document', rsc: false, status: 200 }; + +describe('stock browser Flight fallback observation', () => { + it.each([429, 503])('accepts one refused Flight then recovered document: %s', (status) => { + expect(() => + assertFlightFallback( + [ + { url: origin + '/_next/static/fixture.js', type: 'Script', rsc: false, status: 200 }, + { ...flight, status }, + document, + ], + origin, + target, + status, + ), + ).not.toThrow(); + }); + + it.each([ + ['direct document only', [document]], + ['failed Flight only', [flight]], + ['reversed order', [document, flight]], + ['extra Flight', [flight, flight, document]], + ['Flight without RSC', [{ ...flight, rsc: false }, document]], + ['Flight misclassified as document', [{ ...flight, type: 'Document' }, document]], + ['wrong refusal status', [{ ...flight, status: 200 }, document]], + ['failed recovered document', [flight, { ...document, status: 503 }]], + ['document still marked RSC', [flight, { ...document, rsc: true }]], + ['different target', [{ ...flight, url: origin + '/en/courses/other' }, document]], + ['opposite origin', [{ ...flight, url: 'https://other.localhost:3000' + target }, document]], + ['response not observed', [{ ...flight, status: null }, document]], + ])('rejects a broken navigation proof: %s', (_name, events) => { + expect(() => assertFlightFallback(events, origin, target, 503)).toThrow(); + }); +}); diff --git a/frontend/apps/web/src/test/admission-hmr-watch.test.ts b/frontend/apps/web/src/test/admission-hmr-watch.test.ts new file mode 100644 index 00000000..0240bf12 --- /dev/null +++ b/frontend/apps/web/src/test/admission-hmr-watch.test.ts @@ -0,0 +1,204 @@ +// @vitest-environment node +import { EventEmitter } from 'node:events'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { watchDevelopmentSource } from '../../scripts/admission-hmr-watch.mjs'; + +class FixtureSocket extends EventEmitter { + readyState = 0; + terminated = 0; + + terminate() { + this.terminated++; + this.readyState = 3; + this.emit('close'); + } + + accept() { + this.emit('upgrade', { statusCode: 101 }); + this.readyState = 1; + this.emit('open'); + } + + frame(value: unknown) { + this.emit('message', Buffer.from(JSON.stringify(value))); + } +} + +function start(socket = new FixtureSocket()) { + const createSocket = vi.fn((_url: string, _options: unknown) => socket); + const ready = watchDevelopmentSource({ + app: '/disposable/app', + certificate: 'fixture CA', + createSocket, + }); + return { socket, ready, createSocket }; +} + +async function connected(socket = new FixtureSocket()) { + const setup = start(socket); + socket.accept(); + socket.frame({ action: 'sync', hash: 'initial-client-hash', errors: [] }); + return { ...setup, watcher: await setup.ready }; +} + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + expect(vi.getTimerCount()).toBe(0); + vi.useRealTimers(); +}); + +describe('development source compilation barrier', () => { + it('requires the real 101, open socket and sync before exposing the source writer', async () => { + const { socket, ready, createSocket } = start(); + let completed = false; + void ready.then(() => { + completed = true; + }); + socket.accept(); + socket.frame({ action: 'built', hash: 'client-only', errors: [] }); + await Promise.resolve(); + expect(completed).toBe(false); + socket.frame({ action: 'sync', hash: 'initial', errors: [] }); + const watcher = await ready; + expect(createSocket).toHaveBeenCalledWith('wss://127.0.0.1:3000/_next/webpack-hmr', { + ca: 'fixture CA', + servername: 'localhost', + rejectUnauthorized: true, + maxPayload: 256 * 1024, + }); + watcher.close(); + expect(socket.terminated).toBe(1); + expect(socket.eventNames()).toEqual([]); + }); + + it.each(['missing-upgrade', 'missing-open', 'missing-sync'])( + 'refuses %s at the bounded handshake deadline', + async (mode) => { + const { socket, ready } = start(); + const rejected = expect(ready).rejects.toThrow('Development HMR watcher failed'); + if (mode !== 'missing-upgrade') socket.emit('upgrade', { statusCode: 101 }); + if (mode !== 'missing-open') { + socket.readyState = 1; + socket.emit('open'); + } + if (mode !== 'missing-sync') socket.frame({ action: 'sync', hash: 'initial' }); + await vi.advanceTimersByTimeAsync(15_000); + await rejected; + expect(socket.terminated).toBe(1); + expect(socket.eventNames()).toEqual([]); + }, + ); + + it('ignores old changes, later sync and client builds until the server compilation completes', async () => { + const { socket, watcher } = await connected(); + socket.frame({ action: 'serverComponentChanges', hash: 'old-server-hash' }); + let completed = false; + const write = vi.fn(); + const changed = watcher.afterWrite(write).then(() => { + completed = true; + }); + expect(write).toHaveBeenCalledOnce(); + socket.frame({ action: 'sync', hash: 'new-client-hash', errors: [] }); + socket.frame({ action: 'building' }); + socket.frame({ action: 'built', hash: 'new-client-hash', errors: [] }); + await Promise.resolve(); + expect(completed).toBe(false); + socket.frame({ action: 'serverComponentChanges', hash: 'new-multi-compiler-hash' }); + await changed; + watcher.close(); + expect(socket.eventNames()).toEqual([]); + }); + + it('installs the waiter before invoking the write and supports a later independent write', async () => { + const { socket, watcher } = await connected(); + await watcher.afterWrite(() => socket.frame({ action: 'serverComponentChanges', hash: 'one' })); + await watcher.afterWrite(() => socket.frame({ action: 'serverComponentChanges', hash: 'two' })); + watcher.close(); + watcher.close(); + expect(socket.terminated).toBe(1); + }); + + it.each(['no-frame', 'sync-only', 'built-only'])( + 'fails %s at the source-change deadline instead of allowing a speculative HTTP retry', + async (mode) => { + const { socket, watcher } = await connected(); + const changed = watcher.afterWrite(() => {}); + const rejected = expect(changed).rejects.toThrow('Development HMR watcher failed'); + if (mode === 'sync-only') socket.frame({ action: 'sync', hash: 'unrelated' }); + if (mode === 'built-only') socket.frame({ action: 'built', hash: 'client' }); + await vi.advanceTimersByTimeAsync(15_000); + await rejected; + expect(socket.terminated).toBe(1); + expect(socket.eventNames()).toEqual([]); + }, + ); + + it.each([ + 'error', + 'close', + 'malformed', + 'oversized', + 'compile-error', + 'server-error', + 'empty-hash', + 'write-throws', + 'explicit-close', + ])('fails %s safely and cleans the active update wait', async (mode) => { + const { socket, watcher } = await connected(); + const changed = watcher.afterWrite(() => { + if (mode === 'write-throws') throw new Error('private fixture source value'); + }); + const rejected = expect(changed).rejects.toThrow(/^Development HMR watcher failed$/); + if (mode === 'error') socket.emit('error', new Error('private fixture provider value')); + if (mode === 'close') { + socket.readyState = 3; + socket.emit('close'); + } + if (mode === 'malformed') socket.emit('message', Buffer.from('private invalid JSON')); + if (mode === 'oversized') socket.emit('message', Buffer.alloc(256 * 1024 + 1, 120)); + if (mode === 'compile-error') + socket.frame({ action: 'built', errors: ['private compiler text'] }); + if (mode === 'server-error') + socket.frame({ action: 'serverError', errorJSON: 'private error' }); + if (mode === 'empty-hash') socket.frame({ action: 'serverComponentChanges', hash: '' }); + if (mode === 'explicit-close') watcher.close(); + await rejected; + expect(socket.eventNames()).toEqual([]); + await expect(watcher.afterWrite(() => {})).rejects.toThrow('Development HMR watcher failed'); + }); + + it.each(['bad-status', 'malformed', 'error', 'close'])( + 'cleans %s before the HMR connection is ready', + async (mode) => { + const { socket, ready } = start(); + const rejected = expect(ready).rejects.toThrow(/^Development HMR watcher failed$/); + if (mode === 'bad-status') socket.emit('upgrade', { statusCode: 200 }); + if (mode === 'malformed') socket.emit('message', Buffer.from('{')); + if (mode === 'error') socket.emit('error', new Error('private handshake error')); + if (mode === 'close') { + socket.readyState = 3; + socket.emit('close'); + } + await rejected; + expect(socket.eventNames()).toEqual([]); + }, + ); + + it('absorbs the connecting-socket error emitted by ws during termination', async () => { + class ConnectingSocket extends FixtureSocket { + override terminate() { + this.terminated++; + this.emit('error', new Error('WebSocket was closed before the connection was established')); + this.readyState = 3; + this.emit('close'); + } + } + const { socket, ready } = start(new ConnectingSocket()); + const rejected = expect(ready).rejects.toThrow('Development HMR watcher failed'); + await vi.advanceTimersByTimeAsync(15_000); + await rejected; + expect(socket.eventNames()).toEqual([]); + }); +}); diff --git a/frontend/apps/web/src/test/admission-hmr.test.ts b/frontend/apps/web/src/test/admission-hmr.test.ts new file mode 100644 index 00000000..e801e279 --- /dev/null +++ b/frontend/apps/web/src/test/admission-hmr.test.ts @@ -0,0 +1,81 @@ +// @vitest-environment node +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { verifyHmrAdmission } from '../../scripts/admission-hmr.mjs'; + +describe('development admission proof controls', () => { + it.each(['clean', 'old-site', 'old-source', 'active-context', 'retained-snapshot', 'bad-status'])( + '%s observes the intended freshness/lifetime boundary and restores owned source', + async (mode) => { + const app = mkdtempSync(join(tmpdir(), 'learnstack-hmr-control-')); + const page = join(app, 'src/app/(studio)/studio/page.tsx'); + mkdirSync(join(app, 'src/app/(studio)/studio'), { recursive: true }); + writeFileSync(page, 'original disposable studio source'); + const child = { pid: 42, exitCode: null }; + const checkpoints: string[] = []; + let calls = 0; + let stopped = false; + let watcherClosed = false; + try { + const run = verifyHmrAdmission({ + app, + configuration: { tenants: [{ name: 'First institution' }] }, + checkpoint: async (name) => { + checkpoints.push(name); + }, + startNative: async (dev) => { + expect(dev).toBe(true); + return child; + }, + stopNative: async (value) => { + expect(value).toBe(child); + stopped = true; + }, + observe: async () => ({ + active: mode === 'active-context' ? 1 : 0, + snapshots: mode === 'retained-snapshot' ? 1 : 0, + }), + watchSource: async () => ({ + afterWrite: async (write) => { + expect(calls).toBe(1); + write(); + }, + close: () => { + watcherClosed = true; + }, + }), + call: async (_path, options) => { + calls++; + const after = calls === 2; + expect(readFileSync(page, 'utf8')).toContain( + after ? 'admission-hmr-after' : 'admission-hmr-before', + ); + if (after) expect(options?.headers).toEqual({ RSC: '1', 'Next-Hmr-Refresh': '1' }); + return { + status: mode === 'bad-status' ? 503 : 200, + headers: { 'content-type': after ? 'text/x-component' : 'text/html' }, + body: + 'First institution' + + (after && mode !== 'old-site' ? ' HMR refresh' : '') + + (after && mode !== 'old-source' ? ' admission-hmr-after' : ' admission-hmr-before'), + }; + }, + }); + if (mode === 'clean') { + await expect(run).resolves.toBeUndefined(); + expect(checkpoints).toEqual(['hmr-before', 'hmr-change', 'hmr-after', 'hmr-restore']); + expect(calls).toBe(2); + expect(watcherClosed).toBe(true); + } else await expect(run).rejects.toThrow(); + expect(stopped).toBe(true); + expect(readFileSync(page, 'utf8')).toBe('original disposable studio source'); + } finally { + rmSync(app, { recursive: true, force: true }); + } + }, + ); +}); From 9f785fc7c3a6fe29de0fe33a6e4adc8a13f90690 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 15:59:58 +0300 Subject: [PATCH 48/58] test(public): isolate healthy admission failure stages Retain fixed stage diagnostics for the Linux-only production proof failure without exposing response bodies, provider errors or request identifiers. Keep runtime behavior and exact API accounting unchanged. ADR: 0055 --- frontend/apps/web/scripts/verify-public-admission.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/apps/web/scripts/verify-public-admission.mjs b/frontend/apps/web/scripts/verify-public-admission.mjs index cef14154..53a2f7ce 100644 --- a/frontend/apps/web/scripts/verify-public-admission.mjs +++ b/frontend/apps/web/scripts/verify-public-admission.mjs @@ -495,12 +495,17 @@ async function wireProof(native) { await checkpoint('arm-rate-zero'); refused(await call(catalog), 429, 'GET', '0'); await checkpoint('done-rate-zero'); - stage = 'wire positive'; + stage = 'wire positive request'; const healthy = await call(catalog); + stage = 'wire positive safety'; safe(healthy); + stage = 'wire positive status'; assert.equal(healthy.status, 200); + stage = 'wire positive site'; assert.ok(healthy.body.includes(configuration.tenants[0].name)); + stage = 'wire positive release'; empty(await observe(native)); + stage = 'wire positive accounting'; await checkpoint('done-healthy-wire'); refused(await call('/unadmitted-fixture-path'), 404); await checkpoint('done-entry-refusal'); From b8d5199da5d111e3361419fdd94586297035176b Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 16:09:22 +0300 Subject: [PATCH 49/58] test(public): await native events before asserting release Client EOF does not establish server finish ordering. Observe native terminal events independently before asserting disposal, preserving immediate active checks and planted controls that still reject missing runtime cleanup. ADR: 0055 --- docs/roadmap/phase-02d-walking-skeleton.md | 24 +++- .../21-architecture-tests-catalogue.md | 6 +- .../web/scripts/admission-completion.d.mts | 6 + .../apps/web/scripts/admission-completion.mjs | 28 +++++ .../web/scripts/verify-public-admission.mjs | 36 +++--- .../web/src/test/admission-completion.test.ts | 111 ++++++++++++++++++ 6 files changed, 193 insertions(+), 18 deletions(-) create mode 100644 frontend/apps/web/scripts/admission-completion.d.mts create mode 100644 frontend/apps/web/scripts/admission-completion.mjs create mode 100644 frontend/apps/web/src/test/admission-completion.test.ts diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 8c52d7e6..7a6d2026 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5383,14 +5383,34 @@ production case and subsequent full Docker run pass without retry or sleeps. Final local validation passes **2,946 backend cases**: **1,705 unit**, **198 architecture**, **4 contract**, **172 Docker-free integration** and **867 Docker -integration**, zero skips. The guarded frontend run passes **1,396 cases**: -**1,336 web and 60 SDK**, zero skips/todos. Release build has zero warnings/errors; +integration**, zero skips. The guarded frontend run passes **1,402 cases**: +**1,342 web and 60 SDK**, zero skips/todos. Release build has zero warnings/errors; format, workspace lint/typecheck, production build, native ingress and all **19** ingress cleanup controls pass. The five admission catalogue rules are Implemented with executable owners and planted controls. Accepted ADRs retain their original bodies and receive dated delivery navigation. Exact-head CI and two fresh independent Step 3 review rounds are still pending. + +**CI investigation and native completion proof.** The first Linux run on +`37ec51e` passes 866 Docker cases but fails the new production Fact during its +healthy wire control, after both real API calls return 200. An independent initial +console-only local attempt also fails without a retained substage; its captured +unchanged replay and five root replays pass. A diagnostic commit splits the closed +wire-control stages without printing provider or response data. + +The proof had inferred native completion from client response EOF. Node's socket +write callback emits server `finish`; client EOF and a later IPC message do not +establish that event has run. A test-only independent finish/close observer now +registers after production disposal listeners and waits for actual native terminal +events before asserting zero counts. Held-active observations stay immediate. +Six dirty/clean cases prove client/delegation completion cannot release the barrier +and missing runtime cleanup still fails after the barrier resolves. No sleep, +count-poll, production lifetime change or API retry is added. The actual production +Fact and guarded 1,402-case frontend run pass locally with this proof correction. +The exact first Linux substage, focused fix reviews and replacement CI remain +pending; no unproved causal attribution is made. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index edcf54e3..1a2898e9 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -4415,7 +4415,11 @@ test-only instrumentation stays in the disposable copied app. - **Type:** runtime lifecycle + native production fixture. **Kind:** behavioural. - **Status:** **Implemented** — `PublicAdmissionRenderingTests` and `verify-public-admission.mjs` prove abort, shutdown, valid HEAD late refusal and - zero retained stores/snapshots. `frontend/apps/web/scripts/admission-hmr.mjs` + zero retained stores/snapshots. `frontend/apps/web/scripts/admission-completion.mjs` + waits for native finish/close independently of runtime counts; + `frontend/apps/web/src/test/admission-completion.test.ts` proves that client EOF + cannot release it and planted missing cleanup still fails the zero-count check. + `frontend/apps/web/scripts/admission-hmr.mjs` proves fresh admission after source/API change in the same development process; `frontend/apps/web/src/test/admission-hmr.test.ts` supplies clean/planted freshness, lifetime and cleanup controls. `admission-hmr-watch.mjs` waits for the pinned diff --git a/frontend/apps/web/scripts/admission-completion.d.mts b/frontend/apps/web/scripts/admission-completion.d.mts new file mode 100644 index 00000000..05cf954c --- /dev/null +++ b/frontend/apps/web/scripts/admission-completion.d.mts @@ -0,0 +1,6 @@ +import type { ServerResponse } from 'node:http'; + +export function createNativeCompletionObserver(): Readonly<{ + track(response: Pick): void; + settled(): Promise; +}>; diff --git a/frontend/apps/web/scripts/admission-completion.mjs b/frontend/apps/web/scripts/admission-completion.mjs new file mode 100644 index 00000000..b3a97465 --- /dev/null +++ b/frontend/apps/web/scripts/admission-completion.mjs @@ -0,0 +1,28 @@ +// Test-only native-event barrier; observing client EOF is not server completion. +export function createNativeCompletionObserver() { + const pending = new Set(); + const tracked = new WeakSet(); + return Object.freeze({ + track(response) { + if (tracked.has(response)) throw new Error('Native response already observed'); + tracked.add(response); + let resolve; + const completion = new Promise((done) => { + resolve = done; + }); + pending.add(completion); + let completed = false; + const complete = () => { + if (completed) return; + completed = true; + response.off('finish', complete); + response.off('close', complete); + pending.delete(completion); + resolve(); + }; + response.once('finish', complete); + response.once('close', complete); + }, + settled: () => Promise.all([...pending]).then(() => undefined), + }); +} diff --git a/frontend/apps/web/scripts/verify-public-admission.mjs b/frontend/apps/web/scripts/verify-public-admission.mjs index 53a2f7ce..6a5eb85e 100644 --- a/frontend/apps/web/scripts/verify-public-admission.mjs +++ b/frontend/apps/web/scripts/verify-public-admission.mjs @@ -145,6 +145,7 @@ async function control(child, op, value) { } } const observe = (child) => control(child, 'observe'); +const settled = (child) => control(child, 'settled'); function beginCall( path, @@ -319,12 +320,13 @@ function instrumentCopy() { replaceOnce( launcher, 'import { installPublicAdmissionRuntime }', - 'import { createPublicAdmissionRuntime, installPublicAdmissionRuntime }', + "import { createNativeCompletionObserver } from './admission-completion.mjs';\nimport { createPublicAdmissionRuntime, installPublicAdmissionRuntime }", ); replaceOnce( launcher, ' const admission = installPublicAdmissionRuntime();', ` const admission = installPublicAdmissionRuntime(); + const completions = createNativeCompletionObserver(); const snapshotIds = new WeakMap(); let snapshotsSeen = 0; let reads = 0; @@ -357,14 +359,15 @@ function instrumentCopy() { Object.defineProperty(globalThis, Symbol.for('learnstack.fixture.duplicate'), { value: createPublicAdmissionRuntime().holder, writable: false, enumerable: false, configurable: false }); - process.on('message', (message) => { + process.on('message', async (message) => { if (!message || !Number.isInteger(message.id)) return; if (message.op === 'mode') { if (!['normal','missing','mismatch','duplicate','late','configuration','wrong-status'].includes(message.value)) return; fixture.mode = message.value; process.env.LEARNSTACK_PUBLIC_API_ORIGIN = message.value === 'configuration' ? 'invalid' : originalOrigin; if (message.value === 'late') fixture.finished = new Promise((resolve) => { finishLate = resolve; }); - } else if (message.op !== 'observe') return; + } else if (!['observe', 'settled'].includes(message.op)) return; + if (message.op === 'settled') await completions.settled(); process.send?.({ id: message.id, ...admission.counts(), snapshotsSeen, reads, immutable, lateRefused, lateAccepted }); });`, ); @@ -380,7 +383,10 @@ function instrumentCopy() { `.then(() => fixture.mode === 'missing' ? handle(request, response) : admission.run(fixture.mode === 'mismatch' ? { ...binding, target: '/mismatch' } : binding, - request, response, () => handle(request, response)))`, + request, response, () => { + completions.track(response); + return handle(request, response); + }))`, ); replaceOnce( launcher, @@ -482,7 +488,7 @@ async function wireProof(native) { await checkpoint('arm-' + fault.name); const response = await call(catalog, { method: fault.method }); refused(response, fault.status, fault.method, fault.retryAfter ?? undefined); - empty(await observe(native)); + empty(await settled(native)); await checkpoint('done-' + fault.name); } stage = 'wire mutant'; @@ -504,7 +510,7 @@ async function wireProof(native) { stage = 'wire positive site'; assert.ok(healthy.body.includes(configuration.tenants[0].name)); stage = 'wire positive release'; - empty(await observe(native)); + empty(await settled(native)); stage = 'wire positive accounting'; await checkpoint('done-healthy-wire'); refused(await call('/unadmitted-fixture-path'), 404); @@ -517,7 +523,7 @@ async function lifetimes(native) { stage = 'context ' + mode; await control(native, 'mode', mode); refused(await call(catalog), 503); - empty(await observe(native)); + empty(await settled(native)); await checkpoint(mode === 'configuration' ? 'done-configuration' : 'done-context-' + mode); } await control(native, 'mode', 'normal'); @@ -543,7 +549,7 @@ async function lifetimes(native) { safe(response); assert.equal(response.status, 200); } - const after = await observe(native); + const after = await settled(native); empty(after); assert.equal(after.immutable, true); assert.equal( @@ -578,7 +584,7 @@ async function lifetimes(native) { const other = configuration.tenants.find((tenant) => tenant.host !== lanes[index].tenant.host); assert.equal(response.body.includes(other.name), false); }); - empty(await observe(native)); + empty(await settled(native)); await checkpoint('done-mixed-overlap'); stage = 'keep-alive'; const agent = new Agent({ keepAlive: true, maxSockets: 1 }); @@ -592,7 +598,7 @@ async function lifetimes(native) { second.socket, 'One physical keep-alive connection serves fresh native requests', ); - empty(await observe(native)); + empty(await settled(native)); } finally { agent.destroy(); } @@ -602,7 +608,7 @@ async function lifetimes(native) { safe(head); assert.equal(head.status, 200); assert.equal(head.body, ''); - empty(await observe(native)); + empty(await settled(native)); await checkpoint('done-head'); stage = 'late HEAD'; await control(native, 'mode', 'late'); @@ -614,7 +620,7 @@ async function lifetimes(native) { if (state.lateRefused === 1) break; await new Promise((resolve) => setTimeout(resolve, 25)); } - const lateState = await observe(native); + const lateState = await settled(native); empty(lateState); assert.equal(lateState.lateRefused, 1); assert.equal(lateState.lateAccepted, 0); @@ -631,7 +637,7 @@ async function lifetimes(native) { pending.abort(); assert.equal((await pending.outcome).aborted, true); await checkpoint('aborted-' + kind); - empty(await observe(native)); + empty(await settled(native)); } stage = 'active shutdown'; await checkpoint('arm-shutdown'); @@ -748,7 +754,7 @@ try { await checkpoint('arm-browser-' + status); }, afterNavigation: async () => { - empty(await observe(native)); + empty(await settled(native)); await checkpoint('done-browser-' + status); }, }), @@ -763,7 +769,7 @@ try { startNative, stopNative, call, - observe, + observe: settled, watchSource: () => watchDevelopmentSource({ app, certificate }), }); stage = 'private containment'; diff --git a/frontend/apps/web/src/test/admission-completion.test.ts b/frontend/apps/web/src/test/admission-completion.test.ts new file mode 100644 index 00000000..29eabd61 --- /dev/null +++ b/frontend/apps/web/src/test/admission-completion.test.ts @@ -0,0 +1,111 @@ +// @vitest-environment node +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { describe, expect, it } from 'vitest'; + +import { createNativeCompletionObserver } from '../../scripts/admission-completion.mjs'; +import { createPublicAdmissionRuntime } from '../server/public-admission-runtime'; + +function lifetime() { + return { + request: Object.assign(new EventEmitter(), { aborted: false }) as IncomingMessage, + response: Object.assign(new EventEmitter(), { + destroyed: false, + writableEnded: false, + }) as ServerResponse, + }; +} +const binding = { + host: 'institution.example', + peer: '203.0.113.8', + method: 'GET' as const, + target: '/en/courses', +}; +const site = { + displayName: 'Institution', + enabledLocales: ['en'], + defaultLocale: 'en', + showPlatformAttribution: true, + theme: null, +}; + +function start() { + const runtime = createPublicAdmissionRuntime(); + const owner = lifetime(); + runtime.run(binding, owner.request, owner.response, () => + runtime.holder.begin(binding).publish(site), + ); + return { runtime, owner }; +} + +describe('native completion proof barrier', () => { + it.each(['finish', 'close'] as const)( + 'waits for native %s after apparent client completion', + async (event) => { + const { runtime, owner } = start(); + const observer = createNativeCompletionObserver(); + observer.track(owner.response); // Production disposal listeners were registered first. + let settled = false; + const completion = observer.settled().then(() => { + settled = true; + }); + await Promise.resolve(); // A completed client/delegation promise cannot release this barrier. + expect(settled).toBe(false); + expect(runtime.counts()).toEqual({ active: 1, snapshots: 1 }); + owner.response.emit(event); + await completion; + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + expect(owner.response.listenerCount('finish')).toBe(0); + expect(owner.response.listenerCount('close')).toBe(0); + await observer.settled(); + }, + ); + + it.each(['finish', 'close'] as const)('does not conceal missing cleanup on %s', async (event) => { + const { runtime, owner } = start(); + owner.response.removeAllListeners('finish'); + owner.response.removeAllListeners('close'); // Planted missing production cleanup. + const observer = createNativeCompletionObserver(); + observer.track(owner.response); + owner.response.emit(event); + await observer.settled(); // Independent native event completes even though the runtime leaks. + expect(() => assert.deepEqual(runtime.counts(), { active: 0, snapshots: 0 })).toThrow(); + runtime.shutdown(); + expect(runtime.counts()).toEqual({ active: 0, snapshots: 0 }); + }); + + it('awaits every overlapping native response and removes both owned listeners', async () => { + const observer = createNativeCompletionObserver(); + const first = lifetime().response; + const second = lifetime().response; + observer.track(first); + observer.track(second); + let settled = false; + const completion = observer.settled().then(() => { + settled = true; + }); + first.emit('finish'); + await Promise.resolve(); + expect(settled).toBe(false); + second.emit('close'); + await completion; + expect(settled).toBe(true); + for (const response of [first, second]) { + expect(response.listenerCount('finish')).toBe(0); + expect(response.listenerCount('close')).toBe(0); + } + }); + + it('rejects duplicate registration without adding another pair of listeners', async () => { + const observer = createNativeCompletionObserver(); + const response = lifetime().response; + observer.track(response); + expect(() => observer.track(response)).toThrow('Native response already observed'); + expect(response.listenerCount('finish')).toBe(1); + expect(response.listenerCount('close')).toBe(1); + response.emit('finish'); + await observer.settled(); + }); +}); From 100534f631aec74a512be190907058a33ad13370 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 16:15:00 +0300 Subject: [PATCH 50/58] test(public): detect premature completion of native proof Cross an event-loop turn before asserting pending state so an observer that drops its pending responses cannot satisfy the lifetime controls. The empty-pending mutant now fails three cases and the clean six pass. ADR: 0055 --- frontend/apps/web/src/test/admission-completion.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/apps/web/src/test/admission-completion.test.ts b/frontend/apps/web/src/test/admission-completion.test.ts index 29eabd61..9479ebfa 100644 --- a/frontend/apps/web/src/test/admission-completion.test.ts +++ b/frontend/apps/web/src/test/admission-completion.test.ts @@ -51,7 +51,7 @@ describe('native completion proof barrier', () => { const completion = observer.settled().then(() => { settled = true; }); - await Promise.resolve(); // A completed client/delegation promise cannot release this barrier. + await new Promise((resolve) => setImmediate(resolve)); // Drain any early completion chain. expect(settled).toBe(false); expect(runtime.counts()).toEqual({ active: 1, snapshots: 1 }); owner.response.emit(event); @@ -87,7 +87,7 @@ describe('native completion proof barrier', () => { settled = true; }); first.emit('finish'); - await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); expect(settled).toBe(false); second.emit('close'); await completion; From 77978428c1cf9cbf55f924b99e416dbbe83b065c Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 16:21:13 +0300 Subject: [PATCH 51/58] docs(roadmap): close reviewed single-bootstrap admission delivery Align current status surfaces with completed review and fix loops while retaining the CI failure, unproved cause and early status-promotion record. Keep final exact-head CI in the PR and preserve original manual evidence. ADR: 0055 --- .claude/skills/add-frontend-route/SKILL.md | 2 +- .claude/skills/run-tests-locally/SKILL.md | 4 +- CLAUDE.md | 8 +-- README.md | 6 +- docs/architecture/14-frontend-architecture.md | 4 +- docs/decisions/README.md | 2 +- docs/roadmap/README.md | 6 +- docs/roadmap/phase-02d-walking-skeleton.md | 63 +++++++++++++++++-- docs/standards/07-frontend-architecture.md | 6 +- docs/standards/09-error-handling.md | 8 +-- .../21-architecture-tests-catalogue.md | 13 ++-- docs/standards/README.md | 6 +- 12 files changed, 90 insertions(+), 38 deletions(-) diff --git a/.claude/skills/add-frontend-route/SKILL.md b/.claude/skills/add-frontend-route/SKILL.md index 5582d354..f695a9c8 100644 --- a/.claude/skills/add-frontend-route/SKILL.md +++ b/.claude/skills/add-frontend-route/SKILL.md @@ -297,7 +297,7 @@ document. Metadata/layout/page/UI share the admitted snapshot without another bootstrap or cross-request cache. HEAD/RSC/prefetch and Flight fallback require separately proven counts. The [Step 3 record](../../../docs/roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) -records passing production replacement proofs and pending Step 3 reviews. +records passing production replacement proofs, completed reviews and verified fixes. ### Step 10: Tests diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index 2306839d..f77fd761 100644 --- a/.claude/skills/run-tests-locally/SKILL.md +++ b/.claude/skills/run-tests-locally/SKILL.md @@ -185,8 +185,8 @@ 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 and remaining reviews. This probe does not deliver P7's -browser demo, full Playwright/axe or Lighthouse. +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 diff --git a/CLAUDE.md b/CLAUDE.md index 95f7f5e2..a0632c14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,11 +139,11 @@ records both completed review rounds. **P02d-6 implementation is complete — [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 -single-bootstrap admission and passing production replacement proofs. Steps 1–2 -completed both independent review rounds; +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 pending Step 3 reviews and delivery evidence. The five new catalogue rules -are Implemented. +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. diff --git a/README.md b/README.md index 9c8b9ec5..ffe643e6 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,10 @@ catalog/course/lesson pages, ordered text fields, safe themes and metadata; its [product closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) records automated verification/reviews and scoped browser/VoiceOver observations. The original P02d-6 implementation is complete. ADR-0055 remediation delivers -single-bootstrap admission and passing production replacement proofs; Steps 1–2 -completed both independent review rounds, and +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 pending Step 3 reviews and delivery evidence. Maintainer +owns completed Step 3 reviews, verified fixes and delivery evidence. Maintainer PR review and merge remain pending. The browser demo remains P02d-7. 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 diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index c441124e..33cc9f5a 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -46,8 +46,8 @@ it replaces the duplicate RSC bootstrap with one middleware-owned live read and an immutable same-request snapshot, preserving exact bootstrap 429/503 before rendering. The [Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) -owns passing production replacement proofs and pending Step 3 reviews. Both review -rounds for Steps 1–2 are complete. The +owns passing production replacement proofs and completed Step 3 reviews. Both +review rounds for all three steps and focused fix verification are complete. The [review decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-systematic-review-decision-package-2026-10-10) separately records approval for Open Graph projection and bounded Next security maintenance. The isolated context-propagation experiment establishes feasibility; diff --git a/docs/decisions/README.md b/docs/decisions/README.md index f0ce5e0d..6083b93f 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -70,7 +70,7 @@ an amendment is not a lifecycle status change. | 0052 | [Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md) | Accepted 2026-10-03: host-resolved bootstrap, immutable host ceiling, mandatory marked dispatch and read-only ambient frames; ADR-0036/0040 Amendment 8; P02d-4 Steps 1–3 deliver host provenance, physical read-only frames and all institution GET/HEAD reads; both Step 3 review rounds pass; Step 4 delivers OpenAPI/SDK/CI controls; both review rounds and focused fix verification pass; six real required CI jobs and live sixth-check activation verified; P02d-4 complete and merged through PR #25 on 2026-10-08 ([Amendment 13](0052-anonymous-public-read-boundary.md#amendment-13--merge-closeout-2026-10-08)) | | 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; Steps 1–2 completed both review rounds. [Step 3](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) owns delivery evidence and its pending reviews; five new catalogue rules are Implemented | +| 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 | ## Proposed ADRs diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index e4b3cdc9..e9e837d8 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -74,10 +74,10 @@ not deferred to the showcase phase. [closeout record](phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) records completed reviews and scoped browser/VoiceOver observations. The original implementation is complete. ADR-0055 remediation delivers - single-bootstrap admission and passing production replacement proofs. Steps 1–2 - completed both independent review rounds; + 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 pending Step 3 reviews and delivery evidence. Maintainer + owns completed Step 3 reviews, verified fixes and delivery evidence. Maintainer PR review and merge remain pending. P7 owns the browser/demo. - [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) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 7a6d2026..d557af02 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5336,7 +5336,7 @@ remaining production proof obligations stay with Step 3. ###### ADR-0055 Step 3 — Production admission proof and closeout -**Implemented — 2026-10-10; independent review rounds not started.** The six +**Implemented — 2026-10-10; both independent review rounds complete.** The six existing production modes consume the shipped native admission snapshot. Release execution through real API/PostgreSQL passes all six cases, with zero skips: transport **39**, foundation **33**, pagination **12**, presentation **30**, @@ -5388,9 +5388,8 @@ integration**, zero skips. The guarded frontend run passes **1,402 cases**: format, workspace lint/typecheck, production build, native ingress and all **19** ingress cleanup controls pass. The five admission catalogue rules are Implemented with executable owners and planted controls. Accepted ADRs retain -their original bodies and receive dated delivery navigation. Exact-head CI and -two fresh independent Step 3 review rounds are still pending. - +their original bodies and receive dated delivery navigation. The reviews and +subsequent CI investigation are recorded below. **CI investigation and native completion proof.** The first Linux run on `37ec51e` passes 866 Docker cases but fails the new production Fact during its @@ -5408,8 +5407,60 @@ Six dirty/clean cases prove client/delegation completion cannot release the barr and missing runtime cleanup still fails after the barrier resolves. No sleep, count-poll, production lifetime change or API retry is added. The actual production Fact and guarded 1,402-case frontend run pass locally with this proof correction. -The exact first Linux substage, focused fix reviews and replacement CI remain -pending; no unproved causal attribution is made. +The diagnostic-only `9f785fc` then passes all six required jobs, including all +867 Docker cases and the new production Fact in +[run 38054051927](https://github.com/HodeTech/LearnStack/actions/runs/38054051927). +The exact first Linux substage remains unknown; this passing replay does not +establish the original cause. The completion correction addresses an independently +verified ordering assumption, not a claimed reconstruction of that failure. + +**Review round 1 — `7a2b4b2..37ec51e`.** GPT-6-astra and GPT-6.1-sol, both at +xhigh effort, review runtime/security and production proof/documentation +respectively. Both approve without actionable findings and independently pass +94 focused cases. They do not claim another full production-suite run. + +**Review round 2 — `7a2b4b2..37ec51e`.** Fresh GPT-6-astra and GPT-6.1-sol +reviewers at xhigh effort check runtime/lifecycle and proof/documentation. Static +runtime checks and 94 focused cases pass, but the runtime review's first +console-only production attempt fails as recorded above; its retained unchanged +replay passes. The proof reviewer records the early catalogue-status promotion: +`37ec51e` marked five rules Implemented after passing execution but before both +review rounds, contrary to the approved sequence. This process deviation is +preserved here rather than rewriting the plan. The later completed reviews and +fix verification support the current Implemented status. + +**Focused fix review round 1 — `9f785fc..b8d5199`.** Fresh GPT-6-astra at xhigh +effort verifies native observer ordering, independence from runtime counts and +unchanged active observations. It finds a real proof gap: one microtask wait lets +an already-resolved empty-pending mutant pass all six controls. Independent +verification reproduces that behavior; `100534f` crosses a deterministic +`setImmediate` turn before both pending assertions. The actual Vitest mutant now +fails three cases; restored production code passes all six. No wall-clock sleep or runtime change is added. + +**Focused fix review round 2 — `9f785fc..100534f`.** Fresh GPT-6.1-sol at xhigh +effort approves without actionable findings, independently passes all six cases +and rejects the empty-pending mutant at all three corrected assertions. It +verifies native completion cannot conceal missing runtime cleanup and that the +existing ten-second IPC bound and API accounting remain unchanged. The guarded +full frontend replay passes 1,402 cases, zero skips/todos. + +The repeated optimizer-child cleanup comment is not applicable: `start()` +registers the child with its owner immediately, and failure unwinds through the +existing top-level `finally`/`owner.dispose()`. The implementation agent and an +independent proof reviewer verify that path; no redundant local cleanup is added. +The original manual browser/VoiceOver evidence retains its tested scope and commit. + +Closeout verification passes 198 architecture cases and seven ADR-workflow cases, +with no failures or skips. A local audit of the 37 changed Markdown files checks +2,392 relative links and 735 fragments without missing targets; inline/fenced +examples are excluded. Added prose respects 88 columns. CI's link gate still +checks file existence only. + +All three ADR-0055 implementation steps and their review/fix loops are complete. +The [PR #27 description](https://github.com/HodeTech/LearnStack/pull/27) owns final +exact-head CI after this documentation commit. Maintainer review and merge remain +pending. P02d-7 retains the full browser demo and Lighthouse; Phase 06/11 retain +their named broader accessibility and production-hardening work. ### P02d-1 decision pass (2026-09-14) diff --git a/docs/standards/07-frontend-architecture.md b/docs/standards/07-frontend-architecture.md index a1181064..58766ac6 100644 --- a/docs/standards/07-frontend-architecture.md +++ b/docs/standards/07-frontend-architecture.md @@ -138,8 +138,8 @@ evidence belongs to the [delivery record](../roadmap/phase-02d-walking-skeleton. ## Request-Local Bootstrap Admission **ADR-0055 implementation delivered — 2026-10-10; production replacement proofs -pass.** Steps 1–2 completed both independent review rounds; Step 3 reviews remain -pending. +pass.** All three steps completed both independent review rounds and focused fix +verification. Middleware owns one live site bootstrap and its exact neutral 404/429/503 before rendering. A native-created context binds the captured host/peer/method/signed target and request lifetime. Successful entry publishes one bounded, deeply @@ -160,7 +160,7 @@ missing-detail chain. HEAD/RSC/prefetch and Flight fallback require separately proven counts. Metadata/layout/page/UI reuse adds no bootstrap. Visitor/peer limits remain API-call budgets. The [Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) -owns passing production replacement proof and pending Step 3 review evidence; +owns passing production replacement proof, completed reviews and verified fixes; the five new catalogue rules are Implemented. [Standards 09](09-error-handling.md#public-page-status-and-recovery) owns refusal and bounded renderer Retry-After rules. diff --git a/docs/standards/09-error-handling.md b/docs/standards/09-error-handling.md index 1c5cb0a8..e2546573 100644 --- a/docs/standards/09-error-handling.md +++ b/docs/standards/09-error-handling.md @@ -460,8 +460,8 @@ completes before loading boundaries flush; [Standards 07](07-frontend-architectu owns placement and the shared request-local loader. **ADR-0055 implementation delivered — 2026-10-10; production replacement proofs -pass.** Steps 1–2 completed both independent review rounds; Step 3 reviews remain -pending. +pass.** All three steps completed both independent review rounds and focused fix +verification. Middleware owns the sole live bootstrap and emits neutral 404 for invalid entry or API 404, 429 for valid API 429 and 503 for other site/transport/configuration failures. Responses are no-store with fixed plain-text copy and bodyless HEAD; @@ -476,8 +476,8 @@ values; omit malformed/date/negative/out-of-range values without clamping or inventing a retry. This qualifies renderer forwarding only; the API's selected refusal metadata and accounting remain unchanged. The [Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) -records passing exact-wire failure and lifecycle proofs. Step 3's two independent -review rounds remain pending. +records passing exact-wire failure/lifecycle proofs, both independent review +rounds and focused fix verification. ### User-Facing Copy diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 1a2898e9..243d8ee0 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -8,7 +8,7 @@ [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; Step 3 reviews pending). The +(replacement admission proofs implemented; both review rounds complete). The catalogue grows as subsequent ADRs and phases land their tests; per-test ownership stays with the originating ADR / standard. @@ -4331,12 +4331,13 @@ owns execution, review and manual evidence. ## P02d-6 request-local bootstrap admission controls -**Implemented — 2026-10-10; Step 3 reviews pending.** Production replacement -proofs and planted controls pass for the five rules below. Steps 1–2 completed both -independent review rounds. Existing P5/P6 controls remain mandatory. The +**Implemented — 2026-10-10; both independent review rounds complete.** Production +replacement proofs and planted controls pass for the five rules below. All three +steps completed both independent review rounds and focused fix verification. +Existing P5/P6 controls remain mandatory. The [Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) -owns actual execution and pending Step 3 reviews. Six existing production modes -pass with revised call accounting. The additional production Fact is +owns actual execution, completed reviews and verified fixes. Six existing +production modes pass with revised call accounting. The additional production Fact is `Native_admission_preserves_wire_failures_request_lifetimes_and_browser_fallback` in `backend/tests/LearnStack.Tests.Integration/Database/PublicAdmissionRenderingTests.cs`, driven by `frontend/apps/web/scripts/verify-public-admission.mjs`. It passes its diff --git a/docs/standards/README.md b/docs/standards/README.md index 70cb07a6..0d6dbf7e 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -83,10 +83,10 @@ 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 demo/browser/Lighthouse. ADR-0055 remediation delivers single-bootstrap admission -and passing production replacement proofs. Steps 1–2 completed both independent -review rounds. The +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 pending Step 3 reviews and delivery evidence; its five new catalogue rules +owns completed Step 3 reviews, verified fixes and delivery evidence; its five rules are Implemented. Acceptance alone promotes no standard or test status. **The documents and this table say the same thing, and a test holds them to it.** From ef05ab0b49b318922a7aca70e7976e7668681061 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 16:28:55 +0300 Subject: [PATCH 52/58] test(public): isolate renderer log capture from other hosts Prevent parallel API fixtures from replacing or disposing the global Serilog logger while public proofs inspect real structured events. Keep production logging, nonempty capture assertions and concurrent HTTP lanes unchanged; record the observed CI failure without claiming its exact historical interleaving was reconstructed. ADR: 0055 --- .../Database/PublicReadFixture.cs | 5 ++++- docs/roadmap/phase-02d-walking-skeleton.md | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicReadFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicReadFixture.cs index 2e507fd9..f52dd5d0 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicReadFixture.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicReadFixture.cs @@ -16,7 +16,10 @@ namespace LearnStack.Tests.Integration.Database; -[CollectionDefinition(Name)] +// Production Serilog owns a process-global logger. Other collections' API hosts +// must not replace/dispose it while these fixtures inspect real emitted logs. +// Concurrent HTTP lanes inside each renderer proof remain concurrent. +[CollectionDefinition(Name, DisableParallelization = true)] public sealed class PublicReadTestGroup : ICollectionFixture { public const string Name = "Public read HTTP"; diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index d557af02..82b571eb 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5456,8 +5456,20 @@ with no failures or skips. A local audit of the 37 changed Markdown files checks examples are excluded. Added prose respects 88 columns. CI's link gate still checks file existence only. -All three ADR-0055 implementation steps and their review/fix loops are complete. -The [PR #27 description](https://github.com/HodeTech/LearnStack/pull/27) owns final +**Subsequent Linux capture failure — `b8d5199`.** Native completion checks pass, +but the admission Fact rejects an empty real API log capture; 866 other Docker +cases and five required jobs pass. The pinned Serilog hosting implementation uses +a process-global logger by default: another concurrent API host can replace it, +and disposing that host can close it. This verified shared-state mechanism does +not establish the exact historical interleaving. The public-read xUnit collection +now explicitly opts out of parallel scheduling with other collections, preserving +production logger configuration, enrichment, sink disposal and the nonempty-log +assertion. Forced overlapping HTTP requests inside each proof remain concurrent. +No logger replacement, synthetic event, polling or weakened assertion is added. +This focused fix still awaits its reviews and full-suite/CI verification. + +The three implementation steps have completed their review loops; the subsequent +log-capture fix remains under verification. The [PR #27 description](https://github.com/HodeTech/LearnStack/pull/27) owns final exact-head CI after this documentation commit. Maintainer review and merge remain pending. P02d-7 retains the full browser demo and Lighthouse; Phase 06/11 retain their named broader accessibility and production-hardening work. From d1cc01116f52c60445c5b658e988810740a04983 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 16:35:30 +0300 Subject: [PATCH 53/58] docs(roadmap): record verified admission capture fix reviews Close the focused log-capture correction with two independent source and lifetime reviews plus the full passing Docker replay. Preserve the historical CI uncertainty and leave final-head CI evidence with the PR. ADR: 0055 --- docs/roadmap/phase-02d-walking-skeleton.md | 38 +++++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 82b571eb..40afe442 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -4431,7 +4431,8 @@ state and assert translated label and destination together. The proof reviewer independently confirms a stale narrow ADR-0051 catalogue row. It now names the delivered component and actual presentation fixture as -Implemented, with its published counts reconciled. The five broader P6 rules stay Registered +Implemented, with its published counts reconciled. The five broader P6 rules stay +Registered and Accessibility stays Adopted pending Step 4. Each reviewer passes 164 focused frontend cases, zero failures/skips; neither runs native/Docker or manual checks. The root passes 167 focused frontend cases, five Release corpus cases, workspace @@ -5435,7 +5436,8 @@ unchanged active observations. It finds a real proof gap: one microtask wait let an already-resolved empty-pending mutant pass all six controls. Independent verification reproduces that behavior; `100534f` crosses a deterministic `setImmediate` turn before both pending assertions. The actual Vitest mutant now -fails three cases; restored production code passes all six. No wall-clock sleep or runtime change is added. +fails three cases; restored production code passes all six. No wall-clock sleep or +runtime change is added. **Focused fix review round 2 — `9f785fc..100534f`.** Fresh GPT-6.1-sol at xhigh effort approves without actionable findings, independently passes all six cases @@ -5466,13 +5468,31 @@ now explicitly opts out of parallel scheduling with other collections, preservin production logger configuration, enrichment, sink disposal and the nonempty-log assertion. Forced overlapping HTTP requests inside each proof remain concurrent. No logger replacement, synthetic event, polling or weakened assertion is added. -This focused fix still awaits its reviews and full-suite/CI verification. - -The three implementation steps have completed their review loops; the subsequent -log-capture fix remains under verification. The [PR #27 description](https://github.com/HodeTech/LearnStack/pull/27) owns final -exact-head CI after this documentation commit. Maintainer review and merge remain -pending. P02d-7 retains the full browser demo and Lighthouse; Phase 06/11 retain -their named broader accessibility and production-hardening work. +**Log-capture fix review round 1 — `ef05ab0`.** Fresh GPT-6-astra at xhigh +effort approves without actionable findings. It verifies the pinned xUnit runner +awaits parallel collections and their fixture disposal before starting an isolated +collection. It independently confirms the pinned Serilog global-logger mechanism, +sequential API phase ownership and the unchanged forced HTTP overlap gates. + +**Log-capture fix review round 2 — `ef05ab0`.** Fresh GPT-6.1-sol at xhigh +effort independently approves without actionable findings. It checks collection +and child-host lifetimes, awaited/idempotent fixture disposal, real-log assertions +and the four-arrival request barriers. Neither reviewer starts another Docker or +browser run or claims to reconstruct the earlier Linux interleaving. + +**Fix verification.** The full local Release Docker suite passes **867 cases**, +including the admission Fact and all six retained production modes, with zero +failures or skips. Release build has zero warnings/errors; format verification, +all **198 architecture cases**, seven ADR-workflow cases and the local link audit +pass. The previous guarded frontend execution remains **1,402 cases**, zero +skips/todos; this scheduling fix changes no frontend source. Two overlong prose +lines in this P6 delivery record are wrapped without changing their meaning. + +All three implementation steps and both subsequent focused fixes have completed +their two independent review rounds. The [PR #27 description](https://github.com/HodeTech/LearnStack/pull/27) +owns final exact-head CI after this documentation commit. Maintainer review and +merge remain pending. P02d-7 retains the full browser demo and Lighthouse; +Phase 06/11 retain their named broader accessibility and production-hardening work. ### P02d-1 decision pass (2026-09-14) From 1496269c2a6d23c76a6780c065fcb0230095753f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 17:38:52 +0300 Subject: [PATCH 54/58] fix(public): align listener port and prove scaffold isolation Keep redirects and the native HTTPS listener on one fixed-port source. Exercise both scaffold routes under forced cross-host overlap so the shared root layout cannot silently reuse another tenant's theme. Explicitly narrow the verified middleware envelope without an assertion. ADR: 0053, 0054, 0055 --- .../Database/PublicAdmissionRenderingTests.cs | 11 ++++- docs/roadmap/phase-02d-walking-skeleton.md | 27 +++++++++++ frontend/apps/web/scripts/public-server.mjs | 12 +++-- .../web/scripts/verify-public-admission.mjs | 48 +++++++++++++++++++ frontend/apps/web/src/middleware.ts | 4 +- frontend/apps/web/src/server/ingress.ts | 1 + frontend/apps/web/src/server/public-entry.ts | 4 +- 7 files changed, 98 insertions(+), 9 deletions(-) diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicAdmissionRenderingTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicAdmissionRenderingTests.cs index ed97a83e..17f2691f 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicAdmissionRenderingTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicAdmissionRenderingTests.cs @@ -96,9 +96,11 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new courseSlug = english.Slug, tenants = new[] { - new { host = SeedData.English.Host, name = SeedData.English.DisplayName, locale = english.Locale }, + new { host = SeedData.English.Host, name = SeedData.English.DisplayName, locale = english.Locale, + theme = SeedData.English.Curriculum!.Theme.Value }, new { host = SeedData.Yoga.Host, name = SeedData.Yoga.DisplayName, - locale = SeedData.Yoga.Curriculum!.Locales.Single(row => row.IsDefault).Locale }, + locale = SeedData.Yoga.Curriculum!.Locales.Single(row => row.IsDefault).Locale, + theme = SeedData.Yoga.Curriculum.Theme.Value }, }, wireCases = WireCases.Select(row => new { row.Name, row.Method, row.Status, retryAfter = row.ExpectedRetryAfter }), }, JsonOptions)); @@ -202,6 +204,11 @@ await fixture.ExecuteAsync(SeedData.English, "UPDATE tenants SET display_name=@n AssertCalls(host, ref position, 8, 4); host.Observation.VerifyGate(); } + else if (name == "scaffold-overlap") + { + AssertCalls(host, ref position, 4, 4); + host.Observation.VerifyGate(); + } else if (name == "keep-alive") AssertCalls(host, ref position, 4, 2); else if (name == "late-head" || name == "browser-initial" || name == "entry-refusal") AssertCalls(host, ref position, 1, 1); diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 40afe442..652167db 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5494,6 +5494,33 @@ owns final exact-head CI after this documentation commit. Maintainer review and merge remain pending. P02d-7 retains the full browser demo and Lighthouse; Phase 06/11 retain their named broader accessibility and production-hardening work. +**Latest external review follow-up — 2026-10-10.** The review against `d1cc011` +reports no Blocker or Major. Its fixed-port observation is valid: native listening, +Next's port option and redirect authority now share `PUBLIC_HTTPS_PORT`, preserving +the Accepted local HTTPS port 3000. Middleware explicitly narrows the verified +envelope instead of using a non-null assertion. + +The admission proof now also holds four scaffold requests at one real API barrier: +two hosts, each requesting `/studio` and `/portal`. Distinct seed palettes provide +positive controls; each HTML response contains its own palette and excludes the +other tenant's palette and name. Four separate immutable snapshots are released +after native completion. The driver asserts exactly four site calls and no +Education call for this checkpoint. The lifetime phase now uses **52** API calls; +wire **32** and browser/HMR **10** remain unchanged. The earlier **48**-call receipt +above describes the previous proof revision. + +The suggested silent/shared logger replacement is not applied. The unsupported +renderer warning is intentionally bounded to one event per request, contains only +fixed state/count fields and has a passing privacy/once-only test. Phase 11 owns +the broader frontend observability contract; removing this signal would weaken +the current diagnostic contract. + +Focused local verification passes: the real admission Fact **1/1**, architecture +**198/198**, guarded frontend **1,402/1,402**, typecheck, native compilation, lint +and changed C# format verification. No skipped/todo cases are accepted. Two fresh +independent review rounds and final PR-head CI verification remain pending for +this follow-up. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/frontend/apps/web/scripts/public-server.mjs b/frontend/apps/web/scripts/public-server.mjs index 0dde8ec6..06e9f7cf 100644 --- a/frontend/apps/web/scripts/public-server.mjs +++ b/frontend/apps/web/scripts/public-server.mjs @@ -8,6 +8,7 @@ import { admitIncomingRequest, loadLocalEnvironment, PUBLIC_ENV_KEYS, + PUBLIC_HTTPS_PORT, INGRESS_HEADER, publicServerConfiguration, refuseIngress, @@ -30,7 +31,12 @@ try { // Next attaches its automatic upgrade handler to this documented httpServer // option. The sink never listens: only the native TLS admission may emit to it. const upgradeSink = createUpgradeSink(); - const app = next({ dev, hostname: '127.0.0.1', port: 3000, httpServer: upgradeSink }); + const app = next({ + dev, + hostname: '127.0.0.1', + port: PUBLIC_HTTPS_PORT, + httpServer: upgradeSink, + }); await app.prepare(); const handle = app.getRequestHandler(); const upgradeSockets = new Set(); @@ -94,8 +100,8 @@ try { }; process.once('SIGTERM', shutdown); process.once('SIGINT', shutdown); - server.listen(3000, '127.0.0.1', () => { - console.warn('Public HTTPS listener ready on 127.0.0.1:3000'); + server.listen(PUBLIC_HTTPS_PORT, '127.0.0.1', () => { + console.warn(`Public HTTPS listener ready on 127.0.0.1:${PUBLIC_HTTPS_PORT}`); }); } catch { console.error('Public server configuration or startup failed'); diff --git a/frontend/apps/web/scripts/verify-public-admission.mjs b/frontend/apps/web/scripts/verify-public-admission.mjs index 6a5eb85e..9fba75c6 100644 --- a/frontend/apps/web/scripts/verify-public-admission.mjs +++ b/frontend/apps/web/scripts/verify-public-admission.mjs @@ -17,6 +17,8 @@ import { join } from 'node:path'; import { createInterface } from 'node:readline'; import { fileURLToPath } from 'node:url'; +import { JSDOM } from 'jsdom'; + import { verifyBrowserFallback } from './admission-browser.mjs'; import { watchDevelopmentSource } from './admission-hmr-watch.mjs'; import { verifyHmrAdmission } from './admission-hmr.mjs'; @@ -586,6 +588,52 @@ async function lifetimes(native) { }); empty(await settled(native)); await checkpoint('done-mixed-overlap'); + stage = 'scaffold overlap'; + const scaffoldBefore = await observe(native); + const scaffolds = configuration.tenants.flatMap((tenant) => + ['/studio', '/portal'].map((path) => ({ tenant, path })), + ); + const themeCss = (tenant) => { + const theme = JSON.parse(tenant.theme); + return `:root{--ls-primary:${theme.primary};--ls-bg:${theme.background};--ls-fg:${theme.foreground};--ls-muted:${theme.muted};}`; + }; + assert.notEqual(themeCss(configuration.tenants[0]), themeCss(configuration.tenants[1])); + await checkpoint('arm-overlap'); + const pendingScaffolds = scaffolds.map(({ tenant, path }) => + beginCall(path, { host: tenant.host }), + ); + await checkpoint('held-overlap'); + const scaffoldHeld = await observe(native); + assert.equal(scaffoldHeld.active, 4); + assert.equal(scaffoldHeld.snapshots, 0); + await checkpoint('release-overlap'); + const scaffoldResponses = await Promise.all(pendingScaffolds.map((request) => request.result)); + scaffoldResponses.forEach((response, index) => { + safe(response); + assert.equal(response.status, 200); + const tenant = scaffolds[index].tenant; + const other = configuration.tenants.find((row) => row.host !== tenant.host); + const dom = new JSDOM(response.body); + try { + const styles = [...dom.window.document.querySelectorAll('style')].map( + (style) => style.textContent, + ); + assert.ok(styles.includes(themeCss(tenant)), 'Scaffold renders its own tenant palette'); + assert.equal( + styles.includes(themeCss(other)), + false, + 'Scaffold excludes the other tenant palette', + ); + assert.equal(response.body.includes(other.name), false); + } finally { + dom.window.close(); + } + }); + const scaffoldAfter = await settled(native); + empty(scaffoldAfter); + assert.equal(scaffoldAfter.immutable, true); + assert.equal(scaffoldAfter.snapshotsSeen - scaffoldBefore.snapshotsSeen, 4); + await checkpoint('done-scaffold-overlap'); stage = 'keep-alive'; const agent = new Agent({ keepAlive: true, maxSockets: 1 }); try { diff --git a/frontend/apps/web/src/middleware.ts b/frontend/apps/web/src/middleware.ts index 5037741c..fda610c9 100644 --- a/frontend/apps/web/src/middleware.ts +++ b/frontend/apps/web/src/middleware.ts @@ -44,7 +44,7 @@ export async function middleware(request: NextRequest) { method: request.method, target: `${url.pathname}${url.search}`, }); - if (!context) return refusal(request.method, 404); + if (!context || envelope === null) return refusal(request.method, 404); let admission: ReturnType | undefined; try { admission = beginPublicAdmission(context); @@ -108,7 +108,7 @@ export async function middleware(request: NextRequest) { const downstream = new Headers(); const accept = request.headers.get('accept'); if (accept !== null) downstream.set('accept', accept); - downstream.set(INGRESS_HEADER, envelope!); // Verified above; the caller verifies again. + downstream.set(INGRESS_HEADER, envelope); // Verified above; the caller verifies again. downstream.set('traceparent', traceparent); const response = NextResponse.next({ status: isPublicStatusTarget(context.target) ? 404 : 200, diff --git a/frontend/apps/web/src/server/ingress.ts b/frontend/apps/web/src/server/ingress.ts index f041a274..141c685d 100644 --- a/frontend/apps/web/src/server/ingress.ts +++ b/frontend/apps/web/src/server/ingress.ts @@ -5,6 +5,7 @@ import { isIP } from 'node:net'; import { resolve } from 'node:path'; export const INGRESS_HEADER = 'x-learnstack-ingress-provenance'; +export const PUBLIC_HTTPS_PORT = 3000; export const MAX_TARGET_BYTES = 8192; const MAX_ENVELOPE_BYTES = 12288; const MAC_DOMAIN = 'learnstack.public-ingress.v1\0'; diff --git a/frontend/apps/web/src/server/public-entry.ts b/frontend/apps/web/src/server/public-entry.ts index 4d69d214..2a23e826 100644 --- a/frontend/apps/web/src/server/public-entry.ts +++ b/frontend/apps/web/src/server/public-entry.ts @@ -1,6 +1,6 @@ import type { ServerSdk } from '@learnstack/sdk/server'; -import { normalizeHost, validTarget } from './ingress'; +import { normalizeHost, PUBLIC_HTTPS_PORT, validTarget } from './ingress'; type SiteResult = Awaited>; export type PublicSite = Extract['data']; @@ -117,5 +117,5 @@ export function publicRedirect(host: string, path: string): string { const normalized = normalizeHost(host); if (normalized !== host || !validTarget(path)) throw new Error('Invalid public redirect'); const hostname = host.startsWith('[') ? host.slice(0, host.indexOf(']') + 1) : host.split(':')[0]; - return `https://${hostname}:3000${path}`; + return `https://${hostname}:${PUBLIC_HTTPS_PORT}${path}`; } From b073f353b1540ae1ce34735a2c76f8d65fcae794 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 17:44:00 +0300 Subject: [PATCH 55/58] docs(roadmap): record scaffold isolation review evidence Attribute both independent follow-up reviews and focused runtime checks so the new scaffold proof and its narrow coverage remain reviewable. Keep exact-head CI evidence in the PR description after this commit. ADR: 0053, 0054, 0055 --- docs/roadmap/phase-02d-walking-skeleton.md | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 652167db..7d6947cb 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5515,11 +5515,22 @@ fixed state/count fields and has a passing privacy/once-only test. Phase 11 owns the broader frontend observability contract; removing this signal would weaken the current diagnostic contract. -Focused local verification passes: the real admission Fact **1/1**, architecture -**198/198**, guarded frontend **1,402/1,402**, typecheck, native compilation, lint -and changed C# format verification. No skipped/todo cases are accepted. Two fresh -independent review rounds and final PR-head CI verification remain pending for -this follow-up. +**Follow-up review round 1 — `1496269`.** Fresh GPT-6-astra at xhigh effort +approves without actionable findings. It verifies shared port authority, explicit +envelope narrowing, real overlap, independent seed palettes, snapshot identity, +native completion, exact accounting and the bounded scaffold coverage claim. + +**Follow-up review round 2 — `1496269`.** Fresh GPT-6.1-sol at xhigh effort +independently approves without actionable findings. It reconciles the **32/52/10** +phase totals and checks provenance, cleanup, falsifiability and documentation. +Both rounds are read-only source reviews; runtime execution belongs to the parent. + +Focused local verification passes: the real admission Fact **1/1**, retained +rendering **20/20** (including all six production modes), architecture **198/198**, +guarded frontend **1,402/1,402**, typecheck, native compilation, lint and changed +C# format verification. TRX counters confirm zero failures/skips; the frontend +runner also refuses skips/todos. The PR description owns final exact-head CI +after this review-record commit. Maintainer review and merge remain pending. ### P02d-1 decision pass (2026-09-14) From 7ea14c5f79ed4f8d5115b5cfa8f7b4b767ea4764 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 17:48:41 +0300 Subject: [PATCH 56/58] fix(public): preserve ingress proof instrumentation after port refactor Match the shared listener constant in the disposable native fixture so upgrade observations remain attached. Refuse missing or duplicate instrumentation anchors explicitly rather than silently copying an unobserved listener. Preserve the existing production and cleanup gates. ADR: 0053, 0054, 0055 --- docs/roadmap/phase-02d-walking-skeleton.md | 10 ++++++++++ frontend/apps/web/scripts/verify-ingress.mjs | 10 +++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 7d6947cb..ec72c71b 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5532,6 +5532,16 @@ C# format verification. TRX counters confirm zero failures/skips; the frontend runner also refuses skips/todos. The PR description owns final exact-head CI after this review-record commit. Maintainer review and merge remain pending. +**Ingress instrumentation follow-up — `b073f35`.** The shared-port refactor also +changes a literal launcher anchor used by the separate native ingress fixture. +Its first [CI execution](https://github.com/HodeTech/LearnStack/actions/runs/38060818584) +rejects missing automatic-upgrade observations. The disposable copy now matches +the shared constant and checks that both instrumentation anchors occur exactly +once before rewriting. The fix changes no production code or assertion strength. +Local production build, native TLS/ingress proof, all **19** ingress cleanup/ +negative controls and lint pass. Two fresh focused review rounds remain pending +for this instrumentation fix. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/frontend/apps/web/scripts/verify-ingress.mjs b/frontend/apps/web/scripts/verify-ingress.mjs index a6310f73..c92cab92 100644 --- a/frontend/apps/web/scripts/verify-ingress.mjs +++ b/frontend/apps/web/scripts/verify-ingress.mjs @@ -224,11 +224,15 @@ try { // Observe automatic Next upgrade registrations in the disposable fixture only. // No diagnostic route, request value or probe is added to the shipped launcher. const launcher = readFileSync(join(appRoot, 'scripts/public-server.mjs'), 'utf8'); + const handlerAnchor = ' const handle = app.getRequestHandler();'; + const listenerAnchor = " server.listen(PUBLIC_HTTPS_PORT, '127.0.0.1',"; + for (const anchor of [handlerAnchor, listenerAnchor]) + assert.equal(launcher.split(anchor).length, 2, 'Ingress instrumentation anchor must be unique'); writeFileSync( join(app, 'scripts/public-server.mjs'), launcher .replace( - ' const handle = app.getRequestHandler();', + handlerAnchor, ` let nextCalls = 0; let sinkCalls = 0; const nextHandle = app.getRequestHandler(); @@ -247,7 +251,7 @@ try { });`, ) .replace( - " server.listen(3000, '127.0.0.1',", + listenerAnchor, ` for (const [surface, target] of [['native', server], ['sink', upgradeSink]]) { const register = target.on.bind(target); target.on = (event, listener) => register(event, event !== 'upgrade' ? listener : @@ -260,7 +264,7 @@ try { return listener(request, socket, head); }); } - server.listen(3000, '127.0.0.1',`, + ${listenerAnchor.trimStart()}`, ), ); assert.notEqual(readFileSync(join(app, 'scripts/public-server.mjs'), 'utf8'), launcher); From dd5ec953403a3dc2257501f99cd47f645887070c Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 17:52:06 +0300 Subject: [PATCH 57/58] docs(roadmap): close ingress instrumentation fix reviews Record independent transformation and ordering checks for the fixture fix without attributing parent runtime evidence to the reviewers. Keep the earlier CI failure visible and final-head CI in the PR. ADR: 0053, 0054, 0055 --- docs/roadmap/phase-02d-walking-skeleton.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index ec72c71b..e7073dd5 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -5539,8 +5539,19 @@ rejects missing automatic-upgrade observations. The disposable copy now matches the shared constant and checks that both instrumentation anchors occur exactly once before rewriting. The fix changes no production code or assertion strength. Local production build, native TLS/ingress proof, all **19** ingress cleanup/ -negative controls and lint pass. Two fresh focused review rounds remain pending -for this instrumentation fix. +negative controls and lint pass. + +**Instrumentation fix review round 1 — `7ea14c5`.** Fresh GPT-6-astra at high +effort approves without actionable findings. In-memory checks reject missing and +duplicate anchors and confirm that the transformed launcher parses. It verifies +observer installation before Next's first-request upgrade registration, unchanged +upgrade assertions and compatible cleanup controls. + +**Instrumentation fix review round 2 — `7ea14c5`.** Fresh GPT-6.1-sol at high +effort independently approves without actionable findings. It checks actual +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-1 decision pass (2026-09-14) From 46ed971bf976514fb451a162cc9450d147ed0441 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:05:30 +0000 Subject: [PATCH 58/58] docs(standards): Mark ADR-0055 Step 3 review rounds complete --- docs/standards/06-testing.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index 32a8f069..1e3eb8b7 100644 --- a/docs/standards/06-testing.md +++ b/docs/standards/06-testing.md @@ -219,8 +219,8 @@ missing documents three. Metadata/layout/page/UI reuse adds no bootstrap; the ne document re-reads. HEAD/RSC/prefetch and Flight fallback need separately proven counts. The [Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) -records passing production replacement proofs; Step 3's two independent review -rounds remain pending. These HTTP/HTML/RSC proofs are not browser E2E evidence. +records passing production replacement proofs; both independent Step 3 review +rounds are complete. These HTTP/HTML/RSC proofs are not browser E2E evidence. ADR-0055's separate test-only stock-Next navigation probe observes a failed Flight followed by a fresh document request and counts that chain separately. It requires