diff --git a/.claude/skills/add-feature-gated-ui/SKILL.md b/.claude/skills/add-feature-gated-ui/SKILL.md index 61754715..3cb635b9 100644 --- a/.claude/skills/add-feature-gated-ui/SKILL.md +++ b/.claude/skills/add-feature-gated-ui/SKILL.md @@ -30,8 +30,10 @@ to a `mailto:` on Self-Hosted). All of this lives in ## When not to use -- Authorisation (permission checks). Those use `auth().permissions.includes(...)`, - not feature flags. +- Authentication or authorization. The current app-local + `@/server/auth/auth` helper returns only a safe session projection, with no + permissions/roles. Phase 03 owns permission-aware UI, backed by API enforcement; + a feature flag never grants a permission. - Greying-out a feature instead of hiding. Three UI patterns the project picked: **hide** for missing features, **visualise** for limits, **block-then-link** for exceeded hard limits. @@ -48,6 +50,13 @@ to a `mailto:` on Self-Hosted). All of this lives in ## Workflow +The hook/package examples below describe the Phase 06 entitlement-aware UI +target, not exports in the current SDK. P02b-7 supplies session UI only; do not +invent entitlement hooks, a permissions array or SSE transport while adding a +session-protected page. Follow +[add-frontend-route](../add-frontend-route/SKILL.md#step-5-authentication--permission-gating) +for the implemented authentication boundary. + ### Step 1: Feature gating with `useFeatureFlag` ```tsx diff --git a/.claude/skills/add-frontend-route/SKILL.md b/.claude/skills/add-frontend-route/SKILL.md index 38442f38..dbfa847b 100644 --- a/.claude/skills/add-frontend-route/SKILL.md +++ b/.claude/skills/add-frontend-route/SKILL.md @@ -3,12 +3,12 @@ name: add-frontend-route description: > Add a Next.js App Router route under `frontend/apps/web/src/app/(public)/` / `(studio)/` / `(portal)/` with the right route group, tenant + organization + - locale resolution, Server Component default, permission guards (for studio / - portal), and SDK-based data fetching. USE FOR: a new public page, Studio screen, - or learner / instructor portal screen. DO NOT USE FOR: thin BFF proxy endpoints - (those live in `app/api/`), routes for the operator portal (that's the separate - `operator-portal` app), or hand-rolled `fetch` to the backend (use the typed - SDK). + locale resolution, Server Component default, the owning phase's permission + guards, and admitted SDK-based data fetching. USE FOR: a new public page, + Studio screen, or learner / instructor portal screen. DO NOT USE FOR: native + BFF protocol changes or generic proxy endpoints, routes for the operator + portal (that's the separate `operator-portal` app), or hand-rolled `fetch` to + the backend (use the typed SDK). --- # Adding a frontend route @@ -26,7 +26,6 @@ SDK contract per - A new public page (marketing / CMS-rendered). - A new Studio screen for tenant admin / org admin. - A new portal screen for learner / instructor. -- A new BFF route handler under `api/` (rare; mostly auth callbacks). ## When not to use @@ -36,6 +35,8 @@ SDK contract per `frontend/packages/config/eslint/index.cjs`). - Routes that bypass tenant resolution — every authenticated route requires a resolved tenant. +- Generic BFF proxies or a new authentication action. ADR-0070's auth protocol + belongs to its native response owner; `app/api` currently contains only healthz. ## Inputs @@ -54,8 +55,8 @@ SDK contract per | Group | Purpose | Auth | Default render | |-------|---------|------|----------------| | `(public)` | Tenant public site (marketing, catalog, blog). | Anonymous by default; auth optional. | Dynamic SSR, no-store per ADR-0053; see Step 8. | -| `(studio)` | Tenant admin Studio. | Tenant-admin or org-admin. Required at the edge. | SSR, no cache (always fresh). | -| `(portal)` | Learner / instructor portal. | Membership in the resolved tenant. | SSR shell + Client Component for interactivity. | +| `(studio)` | Current `/studio` account scaffold; future admin screens. | Native BFF session admission; permission-sensitive screens require Phase 03's real authorization. | SSR, no cache (always fresh). | +| `(portal)` | Current `/portal` account scaffold; future learner/instructor screens. | Native BFF session admission; the current projection proves no membership or permission. | SSR shell + Client Component for interactivity. | ### Step 2: Create the route folder @@ -70,13 +71,15 @@ localized section names. ``` frontend/apps/web/src/app/ (studio)/ - dashboard/ - users/ - page.tsx - loading.tsx - error.tsx + studio/page.tsx + (portal)/ + portal/page.tsx ``` +These exact paths are the current authenticated scaffold census. New screens +need their owning phase's admission/authorization decision and matching route +census controls; a route-group folder alone grants no access. + Use the `layout.tsx` already present in the route group; do not add a new layout unless the screen genuinely needs one. @@ -117,8 +120,10 @@ Rules: - Default to Server Component. `"use client"` only when the screen needs hooks, browser APIs, or third-party client-only libs. -- The SDK (`@learnstack/sdk/server`) is the **only** sanctioned way to call the - API. Hand-rolled `fetch('/v1/...')` is blocked by lint. +- Public pages use admitted resource loaders backed by `@learnstack/sdk/server`. + The exact authenticated AccountSite caller stays in the native BFF owner; + neither a page nor the SDK selects credentials, host or tenancy headers. + Hand-rolled `fetch('/v1/...')` is blocked by lint. - Pass typed primitives across the RSC → Client boundary — no class instances, no closures. @@ -146,9 +151,11 @@ through bootstrap and the configured caller; Phase 11 owns participation/samplin **ADR-0054 native/URL controls delivered — 2026-10-09.** [The replacement contract](../../../docs/decisions/0054-bounded-public-renderer-admission.md) requires native GET/HEAD admission on every HTTP callback path, including -middleware-exempt health/assets/scaffolds, with masked no-store refusal of other -methods and bodyless HEAD. Production upgrades close; development retains only -validated GET HMR. The [remediation record](../../../docs/roadmap/phase-02d-delivery-log.md#remediation-step-2--native-ingress-and-url-boundary) +middleware-exempt health/assets/scaffolds. ADR-0070 adds POST only on exact +`/api/auth/signin/keycloak`, `/api/auth/signout` and `/api/auth/extend`, +with their closed Origin/CSRF/body controls. Other methods retain masked +no-store refusal; HEAD remains bodyless. Production upgrades close; development +retains only validated GET HMR. The [remediation record](../../../docs/roadmap/phase-02d-delivery-log.md#remediation-step-2--native-ingress-and-url-boundary) owns validation and both independent review rounds. Do not add Server Actions/write routes or WebSocket consumers without an explicit owning Phase 02b/Phase 06 @@ -159,33 +166,25 @@ accepted HTTPS port and local path select the destination. ### Step 5: Authentication + permission gating -Authentication below is the Phase 02b target, not current scaffold behavior. -Today's exact `/studio` and `/portal` roots pass only after public host bootstrap; -no sign-in/session helper is implemented. Phase 02b must explicitly admit its -login/callback namespace before adding authenticated routes. - -For those future `(studio)` and `(portal)` routes: - -- Unauthenticated requests redirect through the accepted BFF/Keycloak sign-in flow. -- A page-level permission check uses the future `auth()` helper: - -```tsx -import { auth } from "@learnstack/auth/server"; // illustrative: no such package exists yet; Phase 02b's session work owns the real helper -import { redirect } from "next/navigation"; - -export default async function UsersPage() { - const session = await auth(); - if (!session) redirect("/login"); - if (!session.permissions.includes("identity.user.read")) { - redirect("/dashboard"); // or render a 403 UI - } - // ... -} -``` - -`auth()` reads from the HttpOnly cookie session set by the BFF; never touch tokens -in a Client Component. The frontend permission check is **mirror-only** — the API -is authoritative. +P02b-7 implements the optional native BFF under +[ADR-0070](../../../docs/decisions/0070-owned-tenant-bff-sessions.md). +Host bootstrap precedes cookie/provider work. Disabled mode refuses auth paths +with 404 and the exact `/studio` and `/portal` shells with 503. Enabled mode +redirects anonymous shell requests to `/api/auth/signin` with a closed local +return destination. Add no generic proxy or new auth action. + +The app-local `auth()` is a passive safe projection lookup in the admitted native +request; it does not parse cookies, refresh or mutate idle state. Layout and page +consumers still check their own admission after awaits. Follow the actual +[`components/auth/shell.tsx`](../../../frontend/apps/web/src/components/auth/shell.tsx) +owner for those checks and the exact `/studio` or `/portal` return destination, +rather than constructing a second authentication path in a page. + +Never touch tokens in a Client Component. The projection has no permissions or +roles. Phase 03 owns permission-based screens and their mirror-only UI checks; +the API remains authoritative. Current shells grant no authoring capability. +Use full document anchors, no-store responses and the protected history guard; +do not introduce prefetch/Router Cache reuse for protected navigation. ### Step 6: Feature gating (entitlement-aware UI) @@ -222,7 +221,8 @@ export default async function CoursesPage() { ``` Messages live in `frontend/apps/web/src/i18n/messages//.json`; -P02d-6 supplies complete `en/public.json` and `tr/public.json`. General UI keys +P02d-6 supplies complete `en/public.json` and `tr/public.json`; P02b-7 supplies +`en/auth.json` and `tr/auth.json` through the same catalogue guard. 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). @@ -236,8 +236,8 @@ 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. -The guarded frontend suite supplies ICU, argument and callsite checks. No -`lint:i18n` command or screenshot/axe tooling exists. The +The guarded frontend suite supplies ICU, argument and callsite checks; there is +no separate `lint:i18n` command. The full Playwright/axe suite belongs to Phase 06. [Step 1 delivery record](../../../docs/roadmap/phase-02d-delivery-log.md#p02d-6-step-1-localization-and-document-foundation) owns foundation validation; the [packet closeout](../../../docs/roadmap/phase-02d-delivery-log.md#p02d-6-packet-closeout-2026-10-10) @@ -330,8 +330,10 @@ records passing production replacement proofs, completed reviews and verified fi which bans a direct `fetch` call outside the SDK. - The route renders under the resolved tenant/org/locale and rejects mismatched authn. -- A `(studio)` route returns 403 when the actor lacks the required permission; - the API was already authoritative — confirm. +- Current `/studio` and `/portal` follow the native enabled/disabled session + outcomes, with no authoring permission implied. Before adding a screen that + requires permission, Phase 03's API authorization must refuse unauthorized work; + a hidden button or successful session projection is insufficient. - The public-route budgets in [Performance Standards](../../../docs/standards/15-performance.md) hold on representative routes — judged by reading until CI's `lighthouse budget` job is @@ -340,7 +342,8 @@ records passing production replacement proofs, completed reviews and verified fi ## Common pitfalls - **Mounting under the wrong route group.** Public institution routes are dynamic - and no-store under ADR-0053; Studio/Portal authentication remains Phase 02b. + and no-store under ADR-0053; exact Studio/Portal scaffolds use ADR-0070's native + BFF session admission. Future screens do not inherit permission from that shell. - **Hand-rolled `fetch`.** Only the configured server adapter has an API transport exemption. Page consumers call its SDK; no second header setter is permitted. - **Reading `host` inside a page.** The API resolves authority. The native ingress diff --git a/.claude/skills/add-i18n-key/SKILL.md b/.claude/skills/add-i18n-key/SKILL.md index a09bcb78..7fafb1d9 100644 --- a/.claude/skills/add-i18n-key/SKILL.md +++ b/.claude/skills/add-i18n-key/SKILL.md @@ -70,11 +70,13 @@ frontend/apps/web/src/i18n/ messages/ en/public.json tr/public.json + en/auth.json + tr/auth.json ``` -P02d-6 supplies only `public`; later features add a namespace beneath each locale. -`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 +P02d-6 supplies `public`; P02b-7 adds `auth`. `catalogues.ts` and +`auth-catalogues.ts` statically import their complete en/tr files; `request.ts` +explicitly mounts both namespaces. There is no automatic file discovery. JSON nesting supplies dotted identifiers: `public.catalog.course_count` is `catalog.course_count` within the `public` translator. @@ -141,7 +143,7 @@ same layout; they do not introduce an enrollment catalogue in P02d-6. 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 +its typed translator and callsite-census coverage (`auth` uses typed `copy`). Do not pass a catalogue to a Client Component. For public keys with new ICU arguments, update `PublicMessageArguments` alongside the messages and their callsites. diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index ada41747..980c953e 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -84,7 +84,7 @@ Walk every change against the project's | **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 validated authoritative job row 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. 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-delivery-log.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. | +| **CSRF** | ADR-0070's closed BFF POSTs require the exact verified Origin and explicit CSRF proof; OIDC callbacks require their bound state/nonce/PKCE transaction. Server Actions remain unadmitted until their owning phase authorizes them. | | **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. | | **File upload** | MIME sniff + extension allowlist + size limit + EXIF strip + tenant-scoped key. | diff --git a/.env.example b/.env.example index a432ad9f..9be57c6d 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,12 @@ KEYCLOAK_ADMIN_PASSWORD=admin-dev-secret # reconciliation verifies an existing confidential secret and never rotates it. # Never pass this value to the API, public renderer or .NET Seeder. KEYCLOAK_WEB_CLIENT_SECRET=learnstack-web-dev-secret +# Optional native tenant BFF (ADR-0070). Explicit false keeps the anonymous demo +# independent of Keycloak. For local watch mode, opt in with true and the issuer +# below; production launch requires an HTTPS issuer and a trusting private CA. +# All present root/web/process values must agree. Private source files are 0600. +LEARNSTACK_BFF_ENABLED=false +# LEARNSTACK_BFF_ISSUER=http://localhost:8080/realms/learnstack # ─── Vault (-dev mode; token shared with Dapr secret-store component) ──── # Boots the `vault` compose service via `-dev-root-token-id` AND is passed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c677fc1a..78a24d05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -289,6 +289,10 @@ jobs: working-directory: frontend run: pnpm install --frozen-lockfile + - name: Compile native web and authenticated SDK fixture entries + working-directory: frontend + run: pnpm --filter @learnstack/web build:ingress + - name: Prepare pinned provider fixture images within the whole-job budget timeout-minutes: 2 run: python3 scripts/ci-test-shards.py prepare-fixtures diff --git a/CLAUDE.md b/CLAUDE.md index 34e6ef61..ced986ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,16 +40,10 @@ repository holds only LearnStack's side of the boundary, in ## What state this is in **Phase 01, Phase 02a and Phase 02d are complete; Phase 02b is the live phase.** -[Phase 02d](docs/roadmap/phase-02d-walking-skeleton.md), the two-tenant walking -skeleton and the first user-visible milestone, merged through PR #28 on -2026-10-11: two hosts, two tenants, two education sites, one binary and one -database. In [Phase 02b](docs/roadmap/phase-02b-events-auth.md), P02b-0 through -P02b-5 are complete and P02b-1–5 merged through PRs #29–#33 — transactional event -production, durable dispatch, subscriber delivery, background jobs and tenant API -authentication — and P02b-6's realm and identity implementation is merged through -PR #34, with post-merge CI corrections continuing on `development`. P02b-7 (BFF -session) and P02b-8 (exit enforcement) are pending, and G13's session part stays -Open, so browser sign-in is not implemented. Phase 06 retains the full +[Phase 02d](docs/roadmap/phase-02d-walking-skeleton.md) owns the two-tenant walking +skeleton. [Phase 02b](docs/roadmap/phase-02b-events-auth.md) owns current packet +and decision state for events, background jobs, identity and sessions. +[Phase 06](docs/roadmap/phase-06-renderer-admin-studio.md) owns the full renderer/Studio expansion. **Status lives in one place.** Each phase document's Status block owns packet diff --git a/README.md b/README.md index 06a3136b..8c54570f 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ the boundary between customization and capabilities that require platform code. > **Under active development.** The backend foundation and Education schema are > implemented. The first working browser experience is the current milestone; -> browser sign-in, enrolment, payments and live teaching are still ahead. +> the optional native BFF supplies guarded sign-in/session scaffolds; enrolment, +> payments and live teaching are still ahead. > See [current status](#where-it-is-today) before trying the local scaffold. ## What it does @@ -65,12 +66,10 @@ items, rules, custom fields and notification templates; their delivery is tracke ## Where it is today **Phase 01, Phase 02a and Phase 02d are complete; Phase 02b is in progress.** -Phase 02d, the two-tenant walking skeleton, merged through PR #28 on 2026-10-11. In -Phase 02b, P02b-0 through P02b-5 are complete, P02b-1–5 merged through PRs #29–#33, -and P02b-6's realm and identity implementation is merged through PR #34; BFF -sessions and phase exit remain P02b-7–8, so browser sign-in is not implemented. -Packet state lives in the [Phase 02b](docs/roadmap/phase-02b-events-auth.md) -document's Status block; the [roadmap](docs/roadmap/README.md) lists every phase. +[Phase 02d](docs/roadmap/phase-02d-walking-skeleton.md) owns the two-tenant walking +skeleton. Current packet state lives in the +[Phase 02b](docs/roadmap/phase-02b-events-auth.md) document's Status block; the +[roadmap](docs/roadmap/README.md) lists every phase. | Area | Delivered now | Next milestone | |---|---|---| @@ -78,8 +77,8 @@ document's Status block; the [roadmap](docs/roadmap/README.md) lists every phase | **Customization** | Content types, level taxonomies, exact-definition and generation-cached batched display readers, text-card metadata validation, create-only provisioning defaults and semantic built-in seed verification | 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 | Full versioned curriculum in [Phase 05](docs/roadmap/phase-05-education-learning-content.md) | -| **API foundation** | Error contracts, validation, tenancy, concurrency, observability, durable events/subscriber delivery, background jobs, the closed tenant bearer/AccountSite boundary and development realm reconciliation | BFF sessions and phase-exit enforcement in [Phase 02b](docs/roadmap/phase-02b-events-auth.md) | -| **Frontend** | Native TLS ingress, live locale entry, trusted server caller, localized catalog/course/lesson pages, safe themes, accessibility controls/manual smoke and generated typed SDK | Full renderer/Studio and Playwright Test/axe in [Phase 06](docs/roadmap/phase-06-renderer-admin-studio.md) | +| **API foundation** | Error contracts, validation, tenancy, concurrency, observability, durable events/subscriber delivery, background jobs, the closed tenant bearer/AccountSite boundary and development realm reconciliation | Phase-exit enforcement in [Phase 02b](docs/roadmap/phase-02b-events-auth.md) | +| **Frontend** | Native TLS ingress, live locale entry, trusted server caller, localized catalog/course/lesson pages, safe themes, accessibility controls/manual smoke, generated typed SDK and optional bounded native BFF session scaffolds | Full renderer/Studio and Playwright Test/axe in [Phase 06](docs/roadmap/phase-06-renderer-admin-studio.md) | **Four modules contain domain implementations:** Tenancy, Customization, Audit and Education. Identity, Content and Media remain scaffolded. @@ -196,8 +195,9 @@ Supply `Authentication:Tenant:Issuer` too: the canonical HTTPS tenant-realm URL, with loopback HTTP allowed only in `Development`. The development configuration defaults to `http://localhost:8080/realms/learnstack`; issuer configuration is validated without contacting Keycloak at startup. Credential-free public reads -remain available when Keycloak is stopped. P02b-6 owns current realm issuance/ -reconciliation qualification; browser sessions remain P02b-7. +remain available when Keycloak is stopped. Optional tenant sign-in uses native +server custody; see the [web setup](frontend/apps/web/README.md#optional-tenant-sign-in). +The [phase document](docs/roadmap/phase-02b-events-auth.md) owns qualification state. **Ingress, visitor budgets and public entry.** The web bootstraps the captured host through the API, redirects using enabled/default locales and refuses @@ -235,7 +235,7 @@ three product surfaces. See the | Database | PostgreSQL 18 | Migrations, tenant / organization RLS and integration proofs | | Frontend | Next.js 15 · React 19 · TypeScript | App/UI scaffolds; generated SDK and injected transport implemented | | Observability | OpenTelemetry · Serilog → OTLP | Cross-cutting backend instrumentation implemented | -| Identity | Keycloak | Tenant API bearer boundary, real issuance and safe development realm reconciliation implemented; browser sessions remain P02b-7 | +| Identity | Keycloak | Tenant API bearer boundary, real issuance, safe development realm reconciliation and optional native BFF sign-in | | Storage and search | SeaweedFS · PostgreSQL full-text search | Selected architecture; feature delivery belongs to Phases 04–05 | | Live classroom | LiveKit through a provider adapter | Planned in Phase 08c, starting with Cloud and retaining a self-hosted path | diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.CausalRestart.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.CausalRestart.cs index a2f61a37..49b2bae9 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.CausalRestart.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.CausalRestart.cs @@ -5,6 +5,9 @@ using LearnStack.Infrastructure.Persistence; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Jobs; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using MediatR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Npgsql; @@ -18,11 +21,16 @@ public sealed partial class BackgroundJobPositiveSdkTests [InlineData(false, false)] [InlineData(true, false)] [InlineData(false, true)] - public Task Native_completed_cancellation_survives_restart_and_independent_failure_is_not_reclassified( + public async Task Native_completed_cancellation_survives_restart_and_independent_failure_is_not_reclassified( bool independentFailure, bool lieOnlyOnReconcile) - => QualifyCausalRestartAsync(independentFailure, lieOnlyOnReconcile); + { + await QualifyCausalRestartAsync(independentFailure, lieOnlyOnReconcile); + if (!independentFailure && !lieOnlyOnReconcile) + await QualifyCausalRestartAsync(false, false, forceFinalizerRetry: true); + } - private async Task QualifyCausalRestartAsync(bool independentFailure, bool lieOnlyOnReconcile) + private async Task QualifyCausalRestartAsync(bool independentFailure, bool lieOnlyOnReconcile, + bool forceFinalizerRetry = false) { await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); await BackgroundJobSchemaSeed.SeedTenantsAsync(database.AppConnectionString); @@ -48,33 +56,47 @@ private async Task QualifyCausalRestartAsync(bool independentFailure, bool lieOn BackgroundJobExternalContext originalContext; BackgroundJobProviderCausalFacts committedProvider; DateTimeOffset oldLease; + var boundary = "original_start"; + CausalRestartRowObservation? observedRow = null; + var originalBounds = bounds with { RenewalInterval = NativeSettlementTimeProvider.RenewalPacing }; // Original host, source, process/run gates, cancellation source and // provider scope finish and dispose before the replacement host exists. await using (var originalHost = Fixture.Host(database, provider.Settings, configure: services => { services.RemoveAll(); - services.AddSingleton(bounds); + services.AddSingleton(originalBounds); services.AddSingleton(originalProbe); }, applicationPoolSize: 32)) await using (var originalSource = new PrivilegedDataSource(() => OutboxDispatcherDataSource.Build(database.OutboxConnectionString))) { - var originalRuntime = NativeSdkRuntime(originalHost.Services, originalSource, bounds); + var originalTime = new NativeSettlementTimeProvider(retainedRecovery: false); + var originalRuntime = NativeSdkRuntime(originalHost.Services, originalSource, originalBounds, originalTime); var submitted = await Fixture.SubmitAsync(originalHost.Services, Fixture.Parameters(resource, prepared, finalized)); submitted.IsSuccess.Should().BeTrue(); var job = submitted.Value; + var originalFailed = false; try { using var startup = new CancellationTokenSource(TimeSpan.FromSeconds(40)); await originalRuntime.StartAsync(startup.Token); // Publication follows the actual provider acceptance COMMIT and // actual connector/transaction cleanup, not a fabricated report. + boundary = "provider_accept"; + // This proof owns cancellation after provider acceptance. Keep + // real lease/deadline clocks armed while excluding the separate + // legal pre-ACK Prepare/Arm renewal-refusal interleaving. + await Task.WhenAll(originalProbe.Accepted.WaitAsync(startup.Token), + originalTime.ProveNormalDeadlineArmedBeforePacingAsync(startup.Token)); originalContext = await originalProbe.Accepted.WaitAsync(startup.Token); + await originalTime.WaitForFirstCallbackAsync(startup.Token); + boundary = "original_facts"; originalContext.JobId.Should().Be(job); originalProbe.RuntimeTokenCanBeCanceled.Should().BeTrue(); var invocation = NativeSdkInvocation(originalRuntime); var execution = (BackgroundJobExecutionControl)NativeSdkField(invocation, "_execution"); + originalTime.Original.Should().BeSameAs(execution); var providerTask = (Task)NativeSdkField(execution, "_externalProvider"); providerTask.IsCompleted.Should().BeFalse(); execution.Token.IsCancellationRequested.Should().BeFalse(); @@ -90,6 +112,7 @@ private async Task QualifyCausalRestartAsync(bool independentFailure, bool lieOn if (independentFailure) { + boundary = "independent_completion"; // Independent work positively fails and closes its provider // fence BEFORE stop; only its response remains held. originalProbe.ReleaseIndependentFailure(); @@ -102,12 +125,17 @@ private async Task QualifyCausalRestartAsync(bool independentFailure, bool lieOn providerTask.IsCompleted.Should().BeFalse(); execution.RunGate.IsClosed.Should().BeFalse(); } + boundary = "original_stop"; originalRuntime.RequestStop(); execution.RunGate.IsClosed.Should().BeTrue(); + originalTime.ReleaseAll(); + boundary = "provider_token"; await originalProbe.RuntimeTokenObserved.WaitAsync(TimeSpan.FromSeconds(10)); execution.Token.IsCancellationRequested.Should().BeTrue(); + boundary = "provider_completion"; await originalProbe.CompletionStored.WaitAsync(TimeSpan.FromSeconds(15)); originalProbe.ReleaseReport(); + boundary = "provider_report"; var completed = await providerTask.WaitAsync(TimeSpan.FromSeconds(15)); completed.Status.Should().Be(JobExternalExecutionStatus.Reported); completed.CleanupFailed.Should().BeFalse(); @@ -135,6 +163,7 @@ private async Task QualifyCausalRestartAsync(bool independentFailure, bool lieOn typeof(BackgroundJobExecutionControl).GetField("_externalOutcome", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! .GetValue(execution).Should().BeNull(); + boundary = "original_retention"; var held = await NativeUnresolvedFactsAsync(database, job); held.Normal.Should().Be(1); held.Finalize.Should().Be(0); @@ -146,14 +175,21 @@ private async Task QualifyCausalRestartAsync(bool independentFailure, bool lieOn (await BackgroundJobRuntimeRows.CountOrganizationAsync(database.AppConnectionString, finalized.Value)).Should().Be(0); oldLease = (await BackgroundJobRuntimeRows.ReadAsync(database.AppConnectionString, job))!.OwnershipUntil!.Value; } + catch (Exception error) when (NativeWaitCatchable(error)) + { + originalFailed = true; + WriteCausalRestartObservation("failure", boundary, observedRow, originalRuntime, null); + throw; + } finally { - originalRuntime.RequestStop(); - originalProbe.ReleaseIndependentFailure(); - originalProbe.ReleaseReport(); - using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(40)); - await originalRuntime.StopAsync(cleanup.Token); - await originalRuntime.Completion.WaitAsync(cleanup.Token); + await StopCausalRestartRuntimeAsync(originalRuntime, originalFailed, "original", () => + { + originalTime.ReleaseAll(); + originalProbe.ReleaseIndependentFailure(); + originalProbe.ReleaseReport(); + }, (kind, stage) => WriteCausalRestartObservation(kind, stage, observedRow, originalRuntime, null), + originalTime); } originalRuntime.Snapshot.CleanupConfirmed.Should().BeTrue(); originalRuntime.Snapshot.RetainedInvocations.Should().Be(0); @@ -170,11 +206,22 @@ private async Task QualifyCausalRestartAsync(bool independentFailure, bool lieOn await WaitForDatabaseLeaseAsync(database, oldLease); var freshReconcile = new BackgroundJobPositiveReconcileProbe(); var mutant = new BackgroundJobPositiveCausalReportMutation(lieOnlyOnReconcile); + var retry = new CausalFinalizerRetryProbe(); await using var restartedHost = Fixture.Host(database, provider.Settings, configure: services => { services.RemoveAll(); services.AddSingleton(bounds); services.AddSingleton(mutant); + if (forceFinalizerRetry) + { + var request = Fixture.RequestType("Finalize"); + var contract = typeof(IRequestHandler<,>).MakeGenericType(request, typeof(Result)); + var original = services.Last(service => service.ServiceType == contract).ImplementationType!; + services.RemoveAll(contract); + services.AddTransient(contract, provider => Activator.CreateInstance( + typeof(CausalFinalizerRetryHandler<>).MakeGenericType(request), + ActivatorUtilities.CreateInstance(provider, original), retry)!); + } }, applicationPoolSize: 32, reconcileProbe: freshReconcile); restartedHost.Services.GetService().Should().BeNull( "the replacement provider has no original token, stop flag or causal completion probe"); @@ -182,57 +229,111 @@ private async Task QualifyCausalRestartAsync(bool independentFailure, bool lieOn OutboxDispatcherDataSource.Build(database.OutboxConnectionString)); var restarted = NativeSdkRuntime(restartedHost.Services, restartedSource, bounds); CausalRestartFacts actual; + long reconcileCalls = 0; + var restartedFailed = false; + boundary = "restart_start"; try { using var startup = new CancellationTokenSource(TimeSpan.FromSeconds(40)); await restarted.StartAsync(startup.Token); using var completion = new CancellationTokenSource(TimeSpan.FromSeconds(70)); + boundary = "terminal_wait"; while ((await BackgroundJobRuntimeRows.ReadAsync(database.AppConnectionString, originalContext.JobId))!.Phase != "terminal_pending" || restarted.Snapshot.Control!.Executions != 0) await Task.Delay(20, completion.Token); + boundary = "reconcile_facts"; freshReconcile.Context.Should().Be(originalContext, "a new recovery token must still query the exact old provider operation/attempt/deadline"); freshReconcile.TokenWasCancelled.Should().BeFalse(); - (await provider.CountsAsync(originalContext.OperationId)).Should().Be((1L, 1L, 0L)); + var calls = await provider.CountsAsync(originalContext.OperationId); + calls.Executes.Should().Be(1); + calls.Effects.Should().Be(0); + calls.Reconciles.Should().BeGreaterThanOrEqualTo(1, + "a completed report whose Outcome was refused may be reread after real recovery"); + reconcileCalls = calls.Reconciles; (await provider.CausalFactsAsync(originalContext)).Should().Be(committedProvider, "Reconcile and the deliberate adapter lie cannot rewrite the independent provider's immutable completed facts"); - actual = await ReadCausalRestartFactsAsync(database, originalContext, committedProvider.Evidence!.Value); + boundary = "read_facts"; + actual = await ReadCausalRestartFactsAsync(database, originalContext, committedProvider.Evidence!.Value, + value => observedRow = value, () => boundary = "transaction_witness"); + boundary = "assert_facts"; + if (forceFinalizerRetry) output.WriteLine("Causal retry counter;calls={0};forced={1}.", retry.Calls, retry.ForcedConflicts); actual.NormalAttempts.Should().Be(1); - actual.FinalizeAttempts.Should().Be(1); + actual.FinalizeAttempts.Should().BeInRange(1, BackgroundJobRuntimeOptions.MaxFinalizeAttempts); + if (forceFinalizerRetry) actual.FinalizeAttempts.Should().BeGreaterThanOrEqualTo(2); actual.SelectedNormal.Should().Be(originalContext.AttemptId.Value); actual.FinalizeAttempt.Should().NotBe(originalContext.AttemptId.Value); - actual.LedgerRows.Should().Be(2); + actual.LedgerRows.Should().Be(1L + actual.FinalizeAttempts, + "one immutable normal attempt and every separately admitted finalizer remain in the ledger"); actual.ReleaseCount.Should().Be(1); actual.ReleaseReason.Should().Be("quiescent"); actual.BusinessIncomplete.Should().BeFalse(); actual.TerminalAudits.Should().Be(1); (await BackgroundJobRuntimeRows.CountOrganizationAsync(database.AppConnectionString, finalized.Value)).Should().Be(1); } + catch (Exception error) when (NativeWaitCatchable(error)) + { + restartedFailed = true; + WriteCausalRestartObservation("failure", boundary, observedRow, restarted, retry); + throw; + } finally { - restarted.RequestStop(); - using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(40)); - await restarted.StopAsync(cleanup.Token); - await restarted.Completion.WaitAsync(cleanup.Token); + await StopCausalRestartRuntimeAsync(restarted, restartedFailed, "restart", () => { }, + (kind, stage) => WriteCausalRestartObservation(kind, stage, observedRow, restarted, retry)); } + WriteCausalRestartCounts(reconcileCalls, actual.FinalizeAttempts, actual.LedgerRows, + forceFinalizerRetry, restarted); restarted.Snapshot.CleanupConfirmed.Should().BeTrue(); restarted.Snapshot.RetainedInvocations.Should().Be(0); restarted.Snapshot.Control!.Executions.Should().Be(0); + if (forceFinalizerRetry) + { + retry.Calls.Should().BeInRange(2, BackgroundJobRuntimeOptions.MaxFinalizeAttempts); + retry.ForcedConflicts.Should().Be(1); + retry.Injected.Should().NotBeNull().And.NotBeSameAs(retry.Last); + retry.Injected!.CleanupConfirmed.Should().BeTrue(); + retry.Last!.CleanupConfirmed.Should().BeTrue(); + } Action qualify = () => AssertCausalRestartMeaning(actual, committedProvider); - if (lieOnlyOnReconcile) + try { - mutant.Applied.Should().Be(1, "the dirty adapter must actually lie on the one real Reconcile call"); - actual.OutcomeKind.Should().Be("failed"); - actual.FailurePolicy.Should().Be("permanent"); - // This is adapter qualification, not runtime causal certification. - // The SAME positive assertion must reject the actual dirty result. - qualify.Should().Throw().WithMessage("*stored cancellation*"); + var observedLeaseLoss = restarted.Snapshot.Control.LeaseLossElapsed > 0 + || restarted.Snapshot.Control.LeaseLossRenewalRefused > 0; + if (!forceFinalizerRetry && !observedLeaseLoss) + { + reconcileCalls.Should().Be(1, "the unforced path has no observed lease loss requiring a reread"); + actual.FinalizeAttempts.Should().Be(1, "the unforced path has no observed lease loss or injected retry"); + actual.LedgerRows.Should().Be(2); + mutant.Applied.Should().Be(lieOnlyOnReconcile ? 1 : 0); + } + if (forceFinalizerRetry && !observedLeaseLoss) + { + retry.Calls.Should().Be(2, "one deliberately refused finalizer and its successful successor need no further admission without observed lease loss"); + actual.FinalizeAttempts.Should().Be(2); + actual.LedgerRows.Should().Be(3, "one normal attempt and exactly the two witnessed finalizers remain immutable"); + } + if (lieOnlyOnReconcile) + { + ((long)mutant.Applied).Should().BeInRange(1, reconcileCalls, + "the dirty adapter must actually lie, at most once per real Reconcile call"); + actual.OutcomeKind.Should().Be("failed"); + actual.FailurePolicy.Should().Be("permanent"); + // This is adapter qualification, not runtime causal certification. + // The SAME positive assertion must reject the actual dirty result. + qualify.Should().Throw().WithMessage("*stored cancellation*"); + } + else + { + mutant.Applied.Should().Be(0); + qualify(); + } } - else + catch (Exception error) when (NativeWaitCatchable(error)) { - mutant.Applied.Should().Be(0); - qualify(); + WriteCausalRestartObservation("failure", "meaning", observedRow, restarted, retry); + throw; } } @@ -249,7 +350,8 @@ private static void AssertCausalRestartMeaning(CausalRestartFacts actual, Backgr } private static async Task ReadCausalRestartFactsAsync(DisposableSchemaDatabase database, - BackgroundJobExternalContext context, Guid providerEvidence) + BackgroundJobExternalContext context, Guid providerEvidence, Action observe, + Action observingTransactions) { await using var app = await PostgresFixture.OpenAsync(database.AppConnectionString); await using var transaction = await app.BeginTransactionAsync(); @@ -281,10 +383,12 @@ private static async Task ReadCausalRestartFactsAsync(Dispos await using (var row = await command.ExecuteReaderAsync()) { (await row.ReadAsync()).Should().BeTrue("app outcome/release must bind the real provider completion and original attempt"); + observe(ReadCausalRestartObservation(row)); facts = new(row.GetString(0), row.GetString(1), row.GetString(2), row.GetString(3), row.GetInt32(4), row.GetInt32(5), row.GetGuid(6), row.GetGuid(7), row.GetBoolean(8), row.GetInt64(9), row.GetInt64(10), row.GetString(11), row.GetInt64(12), row.GetString(13), row.GetString(14), row.GetString(15), row.GetString(16)); } + observingTransactions(); await AssertObservedTransactionsAsync(app, transaction, failure: true); return facts; } @@ -293,4 +397,51 @@ private sealed record CausalRestartFacts(string OutcomeKind, string FailurePolic string TerminalReason, int NormalAttempts, int FinalizeAttempts, Guid SelectedNormal, Guid FinalizeAttempt, bool BusinessIncomplete, long LedgerRows, long ReleaseCount, string ReleaseReason, long TerminalAudits, string AuditState, string AuditPolicy, string AuditModuleReason, string AuditTerminalReason); + + // The genuine registered Finalize handler still owns all business work. + // One actual post-write concurrency failure exercises its normal rollback + // and fresh-admission path; no stored outcome or authority is fabricated. + private sealed class CausalFinalizerRetryHandler(IRequestHandler> inner, + CausalFinalizerRetryProbe probe) : IRequestHandler> + where TRequest : notnull, IRequest> + { + public async Task> Handle(TRequest request, CancellationToken token) + { + var source = JobApplicationSourceOwner.Current + ?? throw new InvalidOperationException("The actual causal finalizer has no application source."); + probe.Enter(source); + var result = await inner.Handle(request, token); + if (result.IsSuccess && probe.TryInject(source)) + throw new AggregateConcurrencyException("Actual causal finalizer retry test conflict."); + return result; + } + } + + private sealed class CausalFinalizerRetryProbe + { + private readonly object _sync = new(); + private int _calls, _forcedConflicts; + internal int Calls { get { lock (_sync) return _calls; } } + internal int ForcedConflicts { get { lock (_sync) return _forcedConflicts; } } + internal JobApplicationSourceOwner? Injected { get; private set; } + internal JobApplicationSourceOwner? Last { get; private set; } + internal void Enter(JobApplicationSourceOwner source) + { + lock (_sync) + { + Last = source; + _calls++; + } + } + internal bool TryInject(JobApplicationSourceOwner source) + { + lock (_sync) + { + if (_forcedConflicts != 0) return false; + _forcedConflicts++; + Injected = source; + return true; + } + } + } } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.CausalRestartObservations.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.CausalRestartObservations.cs new file mode 100644 index 00000000..7b8a8a11 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.CausalRestartObservations.cs @@ -0,0 +1,137 @@ +using System.Data.Common; +using System.Globalization; +using System.Runtime.ExceptionServices; +using LearnStack.Infrastructure.Jobs; + +namespace LearnStack.Tests.Integration.Database; + +public sealed partial class BackgroundJobPositiveSdkTests +{ + private void WriteCausalRestartCounts(long reconciles, int finalizers, long ledger, bool forced, + BackgroundJobRuntime runtime) + { + static string Count(long? value) => value is >= 0 and <= 10000 + ? value.Value.ToString(CultureInfo.InvariantCulture) : "out_of_range"; + try + { + var control = runtime.Snapshot.Control; + output.WriteLine("Causal restart counts;reconciles=" + Count(reconciles) + + ";finalize=" + Count(finalizers) + ";ledger=" + Count(ledger) + + ";forced=" + (forced ? "yes" : "no") + + ";leaseElapsed=" + Count(control?.LeaseLossElapsed) + + ";renewalRefused=" + Count(control?.LeaseLossRenewalRefused) + "."); + } + catch (Exception error) when (NativeWaitCatchable(error)) { } + } + + private sealed record CausalRestartRowObservation(int Normal, int Finalize, long Ledger, long Release, + long Audits, bool Incomplete, string Outcome, string Policy, string Reason, int Nulls); + + private static CausalRestartRowObservation ReadCausalRestartObservation(DbDataReader row) + { + static string Closed(DbDataReader row, int index, params string[] allowed) + { + if (row.IsDBNull(index)) return "missing"; + var value = row.GetString(index); + return allowed.Contains(value, StringComparer.Ordinal) ? value : "other"; + } + int[] nullable = [0, 1, 2, 3, 11, 13, 14, 15, 16]; + var missing = 0; + for (var index = 0; index < nullable.Length; index++) + if (row.IsDBNull(nullable[index])) missing |= 1 << index; + return new(row.GetInt32(4), row.GetInt32(5), row.GetInt64(9), row.GetInt64(10), row.GetInt64(12), + row.GetBoolean(8), Closed(row, 0, "succeeded", "failed", "cancelled"), + Closed(row, 1, "permanent", "transient_repeatable", "transient_not_repeatable"), + Closed(row, 3, "permanent_failure", "external_retry_forbidden", "retry_exhausted", "finalize_refused", "finalize_exhausted"), + missing); + } + + // First body failure survives every later explicit cleanup failure. Timeouts + // remain observation bounds and never become actual completion evidence. + private static async Task StopCausalRestartRuntimeAsync(BackgroundJobRuntime runtime, bool bodyFailed, + string prefix, Action release, Action observe, NativeSettlementTimeProvider? pacing = null) + { + ExceptionDispatchInfo? failure = null; + var boundary = prefix + "_cleanup_request"; + void Preserve(Exception error) + { + if (failure is not null) return; + failure = ExceptionDispatchInfo.Capture(error); + if (!bodyFailed) observe("failure", boundary); + } + try { runtime.RequestStop(); } + catch (Exception error) when (NativeWaitCatchable(error)) { Preserve(error); } + boundary = prefix + "_cleanup_release"; + try { release(); } + catch (Exception error) when (NativeWaitCatchable(error)) { Preserve(error); } + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(40)); + boundary = prefix + "_cleanup_stop"; + try { await runtime.StopAsync(cleanup.Token); } + catch (Exception error) when (NativeWaitCatchable(error)) { Preserve(error); } + boundary = prefix + "_cleanup_completion"; + try { await runtime.Completion.WaitAsync(cleanup.Token); } + catch (Exception error) when (NativeWaitCatchable(error)) { Preserve(error); } + var timersJoined = pacing is null; + if (pacing is not null) + { + boundary = prefix + "_cleanup_timers"; + try + { + var runtimeJoined = runtime.Completion.IsCompletedSuccessfully; + await pacing.JoinAsync(runtimeJoined).WaitAsync(cleanup.Token); + pacing.ThrowIfFaulted(); + timersJoined = runtimeJoined; + } + catch (Exception error) when (NativeWaitCatchable(error)) { Preserve(error); } + } + if (runtime.Completion.IsCompletedSuccessfully && timersJoined) boundary = prefix + "_cleanup_complete"; + if (bodyFailed || failure is not null) observe("cleanup", boundary); + if (!bodyFailed) failure?.Throw(); + } + + // Diagnostic-only projection of already read app-role rows and memory; no + // additional SQL, provider call, identity or exception text is exported. + private void WriteCausalRestartObservation(string kind, string boundary, CausalRestartRowObservation? row, + BackgroundJobRuntime runtime, CausalFinalizerRetryProbe? retry) + { + static string Count(long? value, int maximum) => value switch + { + null => "unavailable", + >= 0 when value <= maximum => value.Value.ToString(CultureInfo.InvariantCulture), + _ => "out_of_range", + }; + try + { + var snapshot = runtime.Snapshot; + var state = snapshot.State switch + { + BackgroundJobRuntimeState.NotStarted => "not_started", + BackgroundJobRuntimeState.Bootstrapping => "bootstrapping", + BackgroundJobRuntimeState.Starting => "starting", + BackgroundJobRuntimeState.Running => "running", + BackgroundJobRuntimeState.Stopping => "stopping", + BackgroundJobRuntimeState.Retained => "retained", + BackgroundJobRuntimeState.Stopped => "stopped", + _ => "unknown", + }; + // calls belongs to the optional finalizer retry probe, not the provider. + output.WriteLine("Causal restart " + kind + ";boundary=" + boundary + ";runtime=" + state + + ";executions=" + Count(snapshot.Control?.Executions, 64) + + ";renewalRefused=" + Count(snapshot.Control?.LeaseLossRenewalRefused, 64) + + ";leaseElapsed=" + Count(snapshot.Control?.LeaseLossElapsed, 64) + + ";row=" + (row is null ? "unobserved" : "observed") + + ";normal=" + Count(row?.Normal, 6) + ";finalize=" + Count(row?.Finalize, 6) + + ";ledger=" + Count(row?.Ledger, 12) + ";release=" + Count(row?.Release, 64) + + ";audits=" + Count(row?.Audits, 64) + + ";incomplete=" + (row is null ? "unavailable" : row.Incomplete ? "yes" : "no") + + ";outcome=" + (row?.Outcome ?? "unavailable") + ";policy=" + (row?.Policy ?? "unavailable") + + ";reason=" + (row?.Reason ?? "unavailable") + ";nulls=" + Count(row?.Nulls, 511) + + ";calls=" + Count(retry?.Calls, 6) + ";forced=" + Count(retry?.ForcedConflicts ?? 0, 1) + "."); + } + catch (Exception error) when (NativeWaitCatchable(error)) + { + try { output.WriteLine("Causal restart " + kind + ";boundary=" + boundary + ";snapshot=unavailable."); } + catch (Exception outputError) when (NativeWaitCatchable(outputError)) { } + } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeHandoff.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeHandoff.cs index ebbca8ce..a93ef6bf 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeHandoff.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeHandoff.cs @@ -21,7 +21,8 @@ public async Task Native_fixture_start_waits_for_previous_actual_cleanup_before_ await BackgroundJobSchemaSeed.SeedTenantsAsync(database.AppConnectionString); await using var provider = await BackgroundJobProviderJournalDatabase.CreateAsync(schema.Postgres); var construction = new BackgroundJobPositiveProviderConstructionProbe(hold: true); - var bounds = new BackgroundJobRuntimeOptions { RenewalInterval = TimeSpan.FromSeconds(1) }; + var renewal = new NativeSettlementTimeProvider(retainedRecovery: false); + var bounds = new BackgroundJobRuntimeOptions { RenewalInterval = NativeSettlementTimeProvider.RenewalPacing }; await using var host = Fixture.Host(database, provider.Settings, construction, services => { services.RemoveAll(); @@ -29,7 +30,7 @@ public async Task Native_fixture_start_waits_for_previous_actual_cleanup_before_ }); await using var source = new PrivilegedDataSource(() => OutboxDispatcherDataSource.Build(database.OutboxConnectionString)); using var globals = new NativeSdkGlobalFilterSnapshot(); - var time = new NativeHandoffCleanupTime(bounds.TransactionTimeout); + var time = new NativeHandoffCleanupTime(bounds.TransactionTimeout, renewal); var runtime = NativeSdkRuntime(host.Services, source, bounds, time); BackgroundJobBridge? bridge = null; using var startup = new CancellationTokenSource(TimeSpan.FromSeconds(30)); @@ -38,14 +39,22 @@ public async Task Native_fixture_start_waits_for_previous_actual_cleanup_before_ var releaseHelper = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); NpgsqlConnection? blocker = null; NpgsqlTransaction? transaction = null; - Task? pulse = null, helper = null; + Task? pulse = null, helper = null, pacing = null; ExceptionDispatchInfo? failure = null; + var boundary = "submit"; + void Preserve(Exception error, string at) + { + if (failure is not null) return; + failure = ExceptionDispatchInfo.Capture(error); + WriteNativeHandoffFailure(at, error, pulse, helper, pacing, time, construction, runtime); + } try { var submitted = await Fixture.SubmitAsync(host.Services, Fixture.Parameters(Guid.CreateVersion7(), OrganizationId.From(Guid.CreateVersion7()), OrganizationId.From(Guid.CreateVersion7()))); submitted.IsSuccess.Should().BeTrue(); var job = submitted.Value; + boundary = "bridge"; var storage = HangfireStorageBootstrap.Create(source, Fixture.Registry.Infrastructure, new HangfireBoundedLogProvider()); bridge = new BackgroundJobBridge(new PostgresBackgroundJobBridgeStore(source, Fixture.Registry, bounds, new SystemGuidFactory()), new HangfireWakeupClient(storage, HangfireRuntimeFilters.Create()), bounds, TimeProvider.System); @@ -53,6 +62,7 @@ public async Task Native_fixture_start_waits_for_previous_actual_cleanup_before_ while (bridge.Snapshot.Outstanding != 0) await Task.Delay(10, startup.Token); bridge.Snapshot.Acknowledged.Should().Be(1); await bridge.StopAsync(startup.Token); + boundary = "lock"; blocker = await source.OpenConnectionAsync(startup.Token); transaction = await blocker.BeginTransactionAsync(startup.Token); string original; @@ -62,10 +72,17 @@ public async Task Native_fixture_start_waits_for_previous_actual_cleanup_before_ original = (string)(await command.ExecuteScalarAsync(startup.Token))!; } pulse = KeepNativeHandoffLockAsync(blocker, transaction, pulseStop.Token); + boundary = "runtime_start"; await runtime.StartAsync(startup.Token); + // The initial empty recovery creates no typed lease. Keep real + // renewal pacing held through the later normal Prepare/Arm, so a + // row-lock refusal cannot replace the constructor-entry subject. + pacing = renewal.ProveNormalDeadlineArmedBeforePacingAsync(startup.Token); + boundary = "cleanup_enter"; var entered = await Task.WhenAny(time.Entered, pulse).WaitAsync(startup.Token); entered.Should().BeSameAs(time.Entered, "a failed keepalive cannot silently release the controlled lock"); await time.Entered; + boundary = "lock_release"; // The first real empty acquisition now retains its own actual timer // cleanup and local recovery identity. Release the DB lock before // testing hint assistance, so it cannot explain the second refusal. @@ -77,12 +94,14 @@ public async Task Native_fixture_start_waits_for_previous_actual_cleanup_before_ ((BackgroundJobControl)NativeSdkField(runtime, "_jobs")).CanRoute(job).Should().BeFalse(); runtime.Snapshot.Control!.Executions.Should().Be(1); (await NativeSdkVendorStateAsync(source, original, startup.Token)).Should().Be("Processing"); + boundary = "hint_start"; helper = EnsureNativeSdkStartAsync(runtime, source, job, construction.Entered, startup.Token, async () => { observed.TrySetResult(); await releaseHelper.Task.WaitAsync(startup.Token); }); (await Task.WhenAny(observed.Task, helper).WaitAsync(startup.Token)).Should().BeSameAs(observed.Task); + boundary = "hint_count"; long nativeCount; string newest; await using (var read = await source.OpenConnectionAsync(startup.Token)) @@ -95,6 +114,7 @@ public async Task Native_fixture_start_waits_for_previous_actual_cleanup_before_ } if (nativeCount > 1) { + boundary = "premature_hint"; // Counterfactual: the premature real extra hint is consumed // while the prior local identity remains held; no fake work // result or fabricated cleanup signal makes it succeed. @@ -105,20 +125,26 @@ public async Task Native_fixture_start_waits_for_previous_actual_cleanup_before_ construction.Calls.Should().Be(0); (await BackgroundJobRuntimeRows.ReadAsync(database.AppConnectionString, job))!.NormalAttempts.Should().Be(0); nativeCount.Should().Be(1, "no extra native hint may be consumed before preceding private cleanup joins"); + boundary = "release_cleanup"; time.Release(); await time.Joined.WaitAsync(startup.Token); releaseHelper.TrySetResult(); - await helper; + await Task.WhenAll(helper, pacing); + boundary = "construction"; await construction.Entered.WaitAsync(startup.Token); + boundary = "renewal"; + await renewal.WaitForFirstCallbackAsync(startup.Token); + boundary = "assert"; construction.Calls.Should().Be(1); (await BackgroundJobRuntimeRows.ReadAsync(database.AppConnectionString, job))!.NormalAttempts.Should().Be(1); (await GenerationAsync(database, job)).Should().Be(1); runtime.Snapshot.Bridge!.PriorHintRepairsAcknowledged.Should().Be(0); } - catch (Exception error) when (NativeWaitCatchable(error)) { failure = ExceptionDispatchInfo.Capture(error); } + catch (Exception error) when (NativeWaitCatchable(error)) { Preserve(error, boundary); } finally { runtime.RequestStop(); + renewal.ReleaseAll(); startup.Cancel(); time.Release(); releaseHelper.TrySetResult(); @@ -127,22 +153,71 @@ public async Task Native_fixture_start_waits_for_previous_actual_cleanup_before_ async Task ObserveCleanupAsync(Func operation) { try { await operation(); } - catch (Exception error) when (NativeWaitCatchable(error)) { failure ??= ExceptionDispatchInfo.Capture(error); } + catch (Exception error) when (NativeWaitCatchable(error)) { Preserve(error, "cleanup"); } } try { if (helper is not null) await helper; } catch (OperationCanceledException) when (startup.IsCancellationRequested) { } - catch (Exception error) when (NativeWaitCatchable(error)) { failure ??= ExceptionDispatchInfo.Capture(error); } + catch (Exception error) when (NativeWaitCatchable(error)) { Preserve(error, "cleanup"); } + try { if (pacing is not null) await pacing; } + catch (OperationCanceledException) when (startup.IsCancellationRequested) { } + catch (Exception error) when (NativeWaitCatchable(error)) { Preserve(error, "cleanup"); } if (pulse is not null) await ObserveCleanupAsync(() => pulse); if (transaction is not null) await ObserveCleanupAsync(() => transaction.DisposeAsync().AsTask()); if (blocker is not null) await ObserveCleanupAsync(() => blocker.DisposeAsync().AsTask()); using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(40)); if (bridge is not null) await ObserveCleanupAsync(() => bridge.StopAsync(cleanup.Token)); await ObserveCleanupAsync(() => runtime.StopAsync(cleanup.Token)); - await ObserveCleanupAsync(() => runtime.Completion.WaitAsync(cleanup.Token)); + var runtimeJoined = false; + await ObserveCleanupAsync(async () => + { + await runtime.Completion.WaitAsync(cleanup.Token); + runtimeJoined = true; + }); + await ObserveCleanupAsync(async () => + { + await renewal.JoinAsync(runtimeJoined).WaitAsync(cleanup.Token); + renewal.ThrowIfFaulted(); + }); } failure?.Throw(); } + // First-failure memory only: no SQL, repair, cancellation or provider call. + private void WriteNativeHandoffFailure(string boundary, Exception error, Task? pulse, Task? helper, Task? pacing, + NativeHandoffCleanupTime time, BackgroundJobPositiveProviderConstructionProbe construction, BackgroundJobRuntime runtime) + { + static string State(Task? task) => task?.Status switch + { + null => "not_issued", + TaskStatus.RanToCompletion => "succeeded", + TaskStatus.Canceled => "canceled", + TaskStatus.Faulted => "faulted", + _ => "pending", + }; + static string Count(long? value) => value is >= 0 and <= 10000 + ? value.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "unavailable"; + var kind = error switch + { + TimeoutException => "timeout", + OperationCanceledException => "cancellation", + Xunit.Sdk.XunitException => "assertion", + InvalidOperationException => "invalid_state", + _ => "other", + }; + try + { + var control = runtime.Snapshot.Control; + output.WriteLine("Native handoff failure;boundary=" + boundary + ";kind=" + kind + + ";pulse=" + State(pulse) + ";helper=" + State(helper) + ";pacing=" + State(pacing) + + ";cleanup=" + State(time.Entered) + ";joined=" + State(time.Joined) + + ";constructor=" + State(construction.Entered) + + ";executions=" + Count(control?.Executions) + + ";renewalRefused=" + Count(control?.LeaseLossRenewalRefused) + + ";leaseElapsed=" + Count(control?.LeaseLossElapsed) + "."); + } + catch (Exception observation) when (NativeWaitCatchable(observation)) { } + } + private static async Task KeepNativeHandoffLockAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, CancellationToken stopping) { @@ -156,7 +231,7 @@ private static async Task KeepNativeHandoffLockAsync(NpgsqlConnection connection } } - private sealed class NativeHandoffCleanupTime(TimeSpan frameworkWait) : TimeProvider + private sealed class NativeHandoffCleanupTime(TimeSpan frameworkWait, TimeProvider time) : TimeProvider { private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -170,7 +245,7 @@ private sealed class NativeHandoffCleanupTime(TimeSpan frameworkWait) : TimeProv internal void Release() => _release.TrySetResult(); public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) { - var actual = System.CreateTimer(callback, state, dueTime, period); + var actual = time.CreateTimer(callback, state, dueTime, period); return state is HangfireGenerationWork.Invocation && dueTime == frameworkWait && Interlocked.CompareExchange(ref _captured, 1, 0) == 0 ? new HeldTimer(actual, this) : actual; } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeHistory.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeHistory.cs index 7b2c81e4..98282bf5 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeHistory.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeHistory.cs @@ -170,7 +170,8 @@ private async Task NativeCheckpointHistoryAsync(string kind, int passedCommits, } private static async Task EnsureNativeSdkStartAsync(BackgroundJobRuntime runtime, PrivilegedDataSource source, - BackgroundJobId job, Task started, CancellationToken cancellationToken, Func? afterObservation = null) + BackgroundJobId job, Task started, CancellationToken cancellationToken, Func? afterObservation = null, + Action? observe = null) { // These isolated execution/cleanup fixtures do not measure the bridge's // repair grace. Handoff ACK and native Succeeded alone do not prove the @@ -184,13 +185,21 @@ private static async Task EnsureNativeSdkStartAsync(BackgroundJobRuntime runtime long current; bool eligible; string vendor; + string phase; + string owner; + string lease; + int attempts; await using (var connection = await source.OpenConnectionAsync(cancellationToken)) await using (var command = new NpgsqlCommand(""" SELECT wake_generation, vendor_job_id, ownership_token IS NULL AND processed_at IS NULL AND dead_lettered_at IS NULL AND terminal_reason IS NULL AND original_due_at <= clock_timestamp() AND ((phase IN ('pending','finalize_pending') AND available_after <= clock_timestamp()) - OR (phase='reconciling' AND probe_after IS NOT NULL AND probe_after <= clock_timestamp())) + OR (phase='reconciling' AND probe_after IS NOT NULL AND probe_after <= clock_timestamp())), + phase, COALESCE(ownership_kind,'none'), + CASE WHEN ownership_until IS NULL THEN 'absent' + WHEN ownership_until > clock_timestamp() THEN 'live' ELSE 'elapsed' END, + normal_attempts FROM background_jobs WHERE id=@id AND vendor_job_id IS NOT NULL """, connection) { CommandTimeout = 5 }) { @@ -204,6 +213,22 @@ ownership_token IS NULL AND processed_at IS NULL AND dead_lettered_at IS NULL current = row.GetInt64(0); vendor = row.GetString(1); eligible = row.GetBoolean(2); + phase = row.GetString(3) switch + { + var value when value is "pending" or "preparing" or "prepared" or "executing" or "reconciling" + or "finalize_pending" or "finalizing" or "terminal_pending" or "completed" or "quarantined" => value, + _ => "other" + }; + owner = row.GetString(4) switch + { + "none" => "none", + "execution" => "execution", + "recovery" => "recovery", + "routing" => "routing", + _ => "other" + }; + lease = row.GetString(5); + attempts = row.GetInt32(6); } generation ??= current; current.Should().Be(generation.Value, "fixture hints cannot reset the durable generation"); @@ -226,13 +251,29 @@ ownership_token IS NULL AND processed_at IS NULL AND dead_lettered_at IS NULL } capturedGeneration ??= currentGeneration; ReferenceEquals(capturedGeneration, currentGeneration).Should().BeTrue(); - if (state == "Succeeded" && snapshot.State == HangfirePoolOwnerState.Running - && snapshot.Work is { Running: 0, Retained: 0, Closed: false } - && ((BackgroundJobControl)NativeSdkField(runtime, "_jobs")).CanRoute(job) && eligible) + var idle = state == "Succeeded" && snapshot.Work is { Running: 0, Retained: 0, Closed: false }; + // Preserve the existing short-circuit: diagnostics never obtain a + // routing capability that this iteration would not already inspect. + bool? canRoute = idle ? ((BackgroundJobControl)NativeSdkField(runtime, "_jobs")).CanRoute(job) : null; + var observation = new NativeSdkHandoffObservation(state switch + { + "Succeeded" => "succeeded", + "Processing" => "processing", + "Enqueued" => "enqueued", + "Scheduled" => "scheduled", + null => "missing", + _ => "other" + }, eligible, snapshot.Work.Running, snapshot.Work.Retained, snapshot.Work.Closed, + canRoute is null ? "not_checked" : canRoute.Value ? "allowed" : "refused", + phase, owner, lease, attempts, HintState: "not_attempted"); + observe?.Invoke(observation); + if (idle && canRoute == true && eligible) { cancellationToken.ThrowIfCancellationRequested(); if (started.IsCompleted) return; + observe?.Invoke(observation with { HintState = "attempted" }); _ = NativeSdkWakeup(runtime, job, current); + observe?.Invoke(observation with { HintState = "acknowledged" }); if (afterObservation is not null) await afterObservation(); return; // One fixture hint, only after the preceding native operation actually released its ownership. } @@ -245,6 +286,9 @@ ownership_token IS NULL AND processed_at IS NULL AND dead_lettered_at IS NULL } } + private sealed record NativeSdkHandoffObservation(string NativeState, bool Eligible, int Running, + int Retained, bool Closed, string Route, string Phase, string Owner, string Lease, int Attempts, string HintState); + private static HangfirePoolOwner NativeSdkExternalPool(BackgroundJobRuntime runtime) { lock (NativeSdkField(runtime, "_sync")) diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeRecovery.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeRecovery.cs index fe85ac66..98f613f7 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeRecovery.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeRecovery.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.Runtime.ExceptionServices; using FluentAssertions; using LearnStack.Infrastructure.Jobs; using LearnStack.Infrastructure.Messaging; @@ -61,14 +63,79 @@ await SchemaQueries.ExecuteAsync(connection, null, """ var execution = control.TryBeginRecovery(flow.Receipt.Envelope.JobId, await GenerationAsync(database, flow.Receipt.Envelope.JobId), flow.Receipt.Envelope.Queue, work)!; work.HoldExecution(execution); + var executionToken = execution.Token; Task? actual = null; var clean = false; + var boundary = "rooted"; + int? baselineChanges = null; + ExceptionDispatchInfo? failure = null; + Exception? cleanupFailure = null; + string? cleanupBoundary = null; + void Observe(string record, string at, Exception error) + { + static string Count(long? value) => value switch + { + null => "unavailable", + >= 0 and <= 10000 => value.Value.ToString(CultureInfo.InvariantCulture), + _ => "out_of_range", + }; + try + { + int? changes = null; + try { changes = time.Timer(execution).Changes; } + catch (KeyNotFoundException) { } + var snapshot = control.Snapshot; + var kind = error switch + { + TimeoutException => "timeout", + OperationCanceledException => "cancellation", + Xunit.Sdk.XunitException => "assertion", + InvalidOperationException => "invalid_state", + _ => "other", + }; + var state = actual switch + { + null => "not_issued", + { IsCanceled: true } => "canceled", + { IsFaulted: true } => "faulted", + { IsCompletedSuccessfully: true } => "succeeded", + _ => "pending", + }; + output.WriteLine("Native recovery " + record + ";boundary=" + at + ";kind=" + kind + + ";actual=" + state + ";timerChanges=" + Count(changes) + + ";baselineChanges=" + Count(baselineChanges) + ";renewals=" + Count(snapshot.Renewals) + + ";renewalLosses=" + Count(snapshot.LeaseLossRenewalRefused) + + ";elapsedLosses=" + Count(snapshot.LeaseLossElapsed) + + ";runClosed=" + (execution.RunGate.IsClosed ? "true" : "false") + + ";tokenCancelled=" + (executionToken.IsCancellationRequested ? "true" : "false") + + ";dropped=" + Count(proxy.DroppedCommitResponses) + + ";clean=" + (clean ? "true" : "false") + "."); + } + catch (Exception errorWhileObserving) when (NativeWaitCatchable(errorWhileObserving)) { } + } + void Preserve(Exception error, string at) + { + if (failure is not null) return; + failure = ExceptionDispatchInfo.Capture(error); + Observe("failure", at, error); + } + async Task CleanupAsync(string at, Func cleanup) + { + try { await cleanup(); } + catch (Exception error) when (NativeWaitCatchable(error)) + { + if (cleanupFailure is null) { cleanupFailure = error; cleanupBoundary = at; } + Preserve(error, at); + } + } try { control.Snapshot.Executions.Should().Be(1, "the native owner is rooted before any recovery SQL"); execution.WaitForPublicationAsync().IsCompleted.Should().BeTrue("there is no unfinished admission publication"); actual = Task.Run(() => control.ExecuteExternalRecoveryAsync(execution)); + boundary = "construction"; await construction.Entered.WaitAsync(TimeSpan.FromSeconds(15)); + boundary = "identity"; execution.Identity.Should().NotBeNull(); var identity = execution.Identity!; identity.IsRecovery.Should().BeTrue(); @@ -79,33 +146,58 @@ await SchemaQueries.ExecuteAsync(connection, null, """ execution.RunGate.IsClosed.Should().BeFalse(); proxy.DroppedCommitResponses.Should().Be(loseAcquisitionAck ? 1 : 0); var oldLease = identity.OwnershipUntil; + baselineChanges = time.Timer(execution).Changes; + boundary = "renewal_wait"; control.Start(); using var until = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - while (!until.IsCancellationRequested && (time.Timer(execution).Changes == 0 || control.Snapshot.Renewals == 0)) + while (!until.IsCancellationRequested && (time.Timer(execution).Changes <= baselineChanges.Value || control.Snapshot.Renewals == 0)) await Task.Delay(10); - time.Timer(execution).Changes.Should().BeGreaterThan(0); + time.Timer(execution).Changes.Should().BeGreaterThan(baselineChanges.Value, + "the initial timer setup does not prove a later renewal rearmed it"); + boundary = "database_renewal"; (await NativeLeaseAsync(database, identity.JobId)).Should().BeAfter(oldLease); + boundary = "old_lease"; await WaitForDatabaseLeaseAsync(database, oldLease); execution.RunGate.IsClosed.Should().BeFalse("actual recovery renewal survives both expired original and acquisition windows"); execution.Token.IsCancellationRequested.Should().BeFalse(); + boundary = "provider_before"; (await provider.CountsAsync(flow.Operation)).Should().Be((1L, 0L, 1L)); construction.Release(); + boundary = "reconcile"; clean = await actual.WaitAsync(TimeSpan.FromSeconds(15)); clean.Should().BeTrue(); + boundary = "row_facts"; (await BackgroundJobRuntimeRows.ReadAsync(database.AppConnectionString, identity.JobId))!.Phase.Should().Be("finalize_pending"); (await BackgroundJobRuntimeRows.CountAttemptsAsync(database.AppConnectionString, identity.JobId)).Should().Be(1); (await provider.CountsAsync(flow.Operation)).Should().Be((1L, 1L, 1L)); } + catch (Exception error) when (NativeWaitCatchable(error)) { Preserve(error, boundary); } finally { - construction.Release(); - if (actual is not null) clean = await actual; - await work.CompleteAsync(await execution.JoinAsync(clean)); - generation.CloseStarts(); - control.RequestStop(); - await control.Completion.WaitAsync(TimeSpan.FromSeconds(20)); + await CleanupAsync("cleanup_actual", async () => + { + construction.Release(); + if (actual is not null) clean = await actual; + }); + await CleanupAsync("cleanup_work", async () => await work.CompleteAsync(await execution.JoinAsync(clean))); + await CleanupAsync("cleanup_control", async () => + { + generation.CloseStarts(); + control.RequestStop(); + await control.Completion.WaitAsync(TimeSpan.FromSeconds(20)); + }); + if (cleanupFailure is not null) Observe("cleanup", cleanupBoundary!, cleanupFailure); + } + failure?.Throw(); + try + { + control.Snapshot.Executions.Should().Be(0); + await generation.Completion.WaitAsync(TimeSpan.FromSeconds(10)); + } + catch (Exception error) when (NativeWaitCatchable(error)) + { + Observe("failure", "post_cleanup", error); + throw; } - control.Snapshot.Executions.Should().Be(0); - await generation.Completion.WaitAsync(TimeSpan.FromSeconds(10)); } } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeWait.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeWait.cs index 64dad489..b3732f38 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeWait.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeWait.cs @@ -26,7 +26,8 @@ public async Task Native_stop_returns_the_wrapper_before_a_held_external_constru var prepared = OrganizationId.From(Guid.CreateVersion7()); var finalized = OrganizationId.From(Guid.CreateVersion7()); var construction = new BackgroundJobPositiveProviderConstructionProbe(hold: true); - var bounds = new BackgroundJobRuntimeOptions { RenewalInterval = TimeSpan.FromSeconds(1) }; + var time = new NativeSettlementTimeProvider(retainedRecovery: false); + var bounds = new BackgroundJobRuntimeOptions { RenewalInterval = NativeSettlementTimeProvider.RenewalPacing }; await using var host = Fixture.Host(database, provider.Settings, construction, services => { services.RemoveAll(); @@ -34,7 +35,7 @@ public async Task Native_stop_returns_the_wrapper_before_a_held_external_constru }); await using var source = new PrivilegedDataSource(() => OutboxDispatcherDataSource.Build(database.OutboxConnectionString)); using var globals = new NativeSdkGlobalFilterSnapshot(); - var runtime = NativeSdkRuntime(host.Services, source, bounds); + var runtime = NativeSdkRuntime(host.Services, source, bounds, time); var submitted = await Fixture.SubmitAsync(host.Services, Fixture.Parameters(Guid.CreateVersion7(), prepared, finalized)); submitted.IsSuccess.Should().BeTrue(); var job = submitted.Value; @@ -42,15 +43,23 @@ public async Task Native_stop_returns_the_wrapper_before_a_held_external_constru Task? wrapper = null; Task? actual = null; ExceptionDispatchInfo? failure = null; + NativeSdkHandoffObservation? handoff = null; var boundary = "start"; try { using var startup = new CancellationTokenSource(TimeSpan.FromSeconds(30)); await runtime.StartAsync(startup.Token); boundary = "handoff"; - await EnsureNativeSdkStartAsync(runtime, source, job, construction.Entered, startup.Token); + // Prepare/Arm legitimately hold the row lock and a concurrent renewal + // can refuse that row before construction. This test owns cancellation + // only after constructor entry; the separate held-Prepare proof covers + // the earlier refusal. Keep real lease/deadline timers armed throughout. + await Task.WhenAll( + EnsureNativeSdkStartAsync(runtime, source, job, construction.Entered, startup.Token, observe: value => handoff = value), + time.ProveNormalDeadlineArmedBeforePacingAsync(startup.Token)); boundary = "constructor"; await construction.Entered.WaitAsync(startup.Token); + await time.WaitForFirstCallbackAsync(startup.Token); boundary = "capture"; owned = NativeSdkInvocation(runtime); var framework = (HangfireInvocationContract.BoundInvocation)NativeSdkField(owned, "_framework"); @@ -68,6 +77,7 @@ public async Task Native_stop_returns_the_wrapper_before_a_held_external_constru expire.Parameters.AddWithValue("id", job.Value); (await expire.ExecuteNonQueryAsync()).Should().Be(1); } + time.ReleaseAll(); boundary = "return"; await owned.ReturnRequested.WaitAsync(TimeSpan.FromSeconds(15)); boundary = "wrapper"; @@ -78,6 +88,13 @@ public async Task Native_stop_returns_the_wrapper_before_a_held_external_constru owned.Token.WaitHandle.Should().NotBeNull(); runtime.Snapshot.RetainedInvocations.Should().Be(1); runtime.Snapshot.Control!.Executions.Should().Be(1); + if (reason == "lease_loss") + { + var execution = (BackgroundJobExecutionControl)NativeSdkField(owned, "_execution"); + NativeSettlementCause(execution).Should().Be("renewal_refused"); + runtime.Snapshot.Control.LeaseLossRenewalRefused.Should().Be(1); + runtime.Snapshot.Control.LeaseLossElapsed.Should().Be(0); + } runtime.Completion.IsCompleted.Should().BeFalse(); construction.Calls.Should().Be(1); construction.ExternalDenial.Should().BeTrue(); @@ -87,11 +104,14 @@ public async Task Native_stop_returns_the_wrapper_before_a_held_external_constru catch (Exception error) when (NativeWaitCatchable(error)) { failure = ExceptionDispatchInfo.Capture(error); + WriteNativeHandoffObservation(handoff); WriteNativeWaitObservation("failure", boundary, construction, owned, wrapper, actual, runtime); } finally { + time.ReleaseAll(); construction.Release(); + var runtimeJoined = false; using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(40)); boundary = "cleanup_request"; try @@ -101,13 +121,30 @@ public async Task Native_stop_returns_the_wrapper_before_a_held_external_constru await runtime.StopAsync(cleanup.Token); boundary = "cleanup_completion"; await runtime.Completion.WaitAsync(cleanup.Token); - boundary = "cleanup_complete"; + runtimeJoined = true; } catch (Exception error) when (NativeWaitCatchable(error)) { if (failure is null) { failure = ExceptionDispatchInfo.Capture(error); + WriteNativeHandoffObservation(handoff); + WriteNativeWaitObservation("failure", boundary, construction, owned, wrapper, actual, runtime); + } + } + boundary = "cleanup_timers"; + try + { + await time.JoinAsync(runtimeJoined).WaitAsync(cleanup.Token); + time.ThrowIfFaulted(); + if (runtimeJoined) boundary = "cleanup_complete"; + } + catch (Exception error) when (NativeWaitCatchable(error)) + { + if (failure is null) + { + failure = ExceptionDispatchInfo.Capture(error); + WriteNativeHandoffObservation(handoff); WriteNativeWaitObservation("failure", boundary, construction, owned, wrapper, actual, runtime); } } @@ -191,6 +228,20 @@ private void WriteNativeWaitObservation(string kind, string boundary, } } + private void WriteNativeHandoffObservation(NativeSdkHandoffObservation? value) + { + if (value is null) return; + static string Flag(bool flag) => flag ? "yes" : "no"; + try + { + output.WriteLine("Native handoff;native=" + value.NativeState + ";eligible=" + Flag(value.Eligible) + + ";running=" + value.Running + ";retained=" + value.Retained + ";closed=" + Flag(value.Closed) + + ";route=" + value.Route + ";phase=" + value.Phase + ";owner=" + value.Owner + + ";lease=" + value.Lease + ";attempts=" + value.Attempts + ";hint=" + value.HintState + "."); + } + catch (Exception error) when (NativeWaitCatchable(error)) { } + } + private static bool NativeWaitCatchable(Exception error) => error.GetType() != typeof(OutOfMemoryException) && error.GetType() != typeof(StackOverflowException); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.SourceContention.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.SourceContention.cs index 63c62304..d21fd8ee 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.SourceContention.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.SourceContention.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Runtime.ExceptionServices; using FluentAssertions; using LearnStack.Infrastructure.Jobs; using LearnStack.Infrastructure.Messaging; @@ -112,17 +113,29 @@ public async Task Native_transactional_pool_completes_at_the_app_source_floor_wh var pressure = new List(); SourceContentionOwner? transactionalOwner = null; Task? heldProvider = null; + ExceptionDispatchInfo? failure = null; + var boundary = "start"; + void PreserveFailure(Exception error) + { + if (failure is not null) return; + failure = ExceptionDispatchInfo.Capture(error); + WriteSourceContentionObservation("failure", boundary, reconcile.Entered, entered.Task, + heldProvider, pressure.Count, runtime); + } try { using var startup = new CancellationTokenSource(TimeSpan.FromSeconds(40)); await runtime.StartAsync(startup.Token); + boundary = "capacity"; lifetime.PoolSettings.Should().Be(new BackgroundJobApplicationPoolSettings(true, 32)); runtime.Snapshot.ApplicationCapacity!.RequiredConnections.Should().Be(32); await using (var physical = await source.OpenConnectionAsync(startup.Token)) new NpgsqlConnectionStringBuilder(physical.ConnectionString).MaxPoolSize.Should().Be(52); // Execute and the provider journal commit are real. The native recovery // then enters the SDK's post-call barrier, retaining its actual task. + boundary = "reconcile_wait"; await reconcile.Entered.WaitAsync(startup.Token); + boundary = "reconcile_facts"; var external = SourceContentionExecution(runtime, "external", externalJob); external.Identity!.IsRecovery.Should().BeTrue(); heldProvider = (Task)NativeSdkField(external, "_externalProvider"); @@ -135,6 +148,7 @@ public async Task Native_transactional_pool_completes_at_the_app_source_floor_wh // These are 30 explicit physical pressure borrowers on the SAME app // source, not 30 claimed native invocations or maximum-topology proof. + boundary = "pressure"; using var pressureDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(20)); for (var index = 0; index < 30; index++) { @@ -142,13 +156,16 @@ public async Task Native_transactional_pool_completes_at_the_app_source_floor_wh pressure.Add(connection); await SchemaQueries.AssertApplicationRoleAsync(connection); } + boundary = "submit"; await using (var submission = host.Services.CreateAsyncScope()) (await transactional.SendAsync(submission.ServiceProvider)).IsSuccess.Should().BeTrue(); transactionalJob.Should().NotBeNull(); var acceptedTransactionalJob = transactionalJob!.Value; acceptedTransactionalJob.IsInitialized().Should().BeTrue(); using var completion = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + boundary = "transaction_wait"; transactionalOwner = await entered.Task.WaitAsync(completion.Token); + boundary = "transaction_facts"; var actualTransactional = SourceContentionExecution(runtime, "transactional", acceptedTransactionalJob); actualTransactional.Identity!.Profile.Should().Be("transactional"); var admission = (Task)NativeSdkField(actualTransactional, "_admission"); @@ -158,6 +175,7 @@ public async Task Native_transactional_pool_completes_at_the_app_source_floor_wh .Append(transactionalOwner.MainBackend).Append(transactionalOwner.AuxiliaryBackend).ToArray(); allBackends.Should().HaveCount(32).And.OnlyHaveUniqueItems( "the native root owns the final real main/auxiliary pair in the configured app pool"); + boundary = "overflow"; using (var overflowDeadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(150))) { Func overflow = async () => @@ -168,9 +186,12 @@ public async Task Native_transactional_pool_completes_at_the_app_source_floor_wh } heldProvider.IsCompleted.Should().BeFalse("the external operation stays held through actual source saturation"); releaseAuxiliary.TrySetResult(); + boundary = "transaction_complete"; while ((await BackgroundJobRuntimeRows.ReadAsync(database.AppConnectionString, acceptedTransactionalJob))!.Phase != "completed") await Task.Delay(20, completion.Token); + boundary = "committed"; await AssertSourceContentionCommittedAsync(database, acceptedTransactionalJob, unrelatedOrganization); + boundary = "external_facts"; heldProvider.IsCompleted.Should().BeFalse( "the unrelated transactional native worker commits before the held external task is released"); (await provider.CountsAsync(context.OperationId)).Should().Be((1L, 1L, 0L)); @@ -183,16 +204,33 @@ public async Task Native_transactional_pool_completes_at_the_app_source_floor_wh (await BackgroundJobRuntimeRows.CountOrganizationAsync(database.AppConnectionString, prepared.Value)).Should().Be(1); (await BackgroundJobRuntimeRows.CountOrganizationAsync(database.AppConnectionString, finalized.Value)).Should().Be(0); } + catch (Exception error) when (NativeWaitCatchable(error)) { PreserveFailure(error); } finally { releaseAuxiliary.TrySetResult(); - foreach (var connection in pressure) await connection.DisposeAsync(); - runtime.RequestStop(); + boundary = "cleanup_pressure"; + foreach (var connection in pressure) + { + try { await connection.DisposeAsync(); } + catch (Exception error) when (NativeWaitCatchable(error)) { PreserveFailure(error); } + } + boundary = "cleanup_request"; + try { runtime.RequestStop(); } + catch (Exception error) when (NativeWaitCatchable(error)) { PreserveFailure(error); } reconcile.Release(); using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(40)); - await runtime.StopAsync(cleanup.Token); - await runtime.Completion.WaitAsync(cleanup.Token); + boundary = "cleanup_stop"; + try { await runtime.StopAsync(cleanup.Token); } + catch (Exception error) when (NativeWaitCatchable(error)) { PreserveFailure(error); } + boundary = "cleanup_completion"; + try { await runtime.Completion.WaitAsync(cleanup.Token); } + catch (Exception error) when (NativeWaitCatchable(error)) { PreserveFailure(error); } + if (runtime.Completion.IsCompletedSuccessfully) boundary = "cleanup_complete"; + if (failure is not null) + WriteSourceContentionObservation("cleanup", boundary, reconcile.Entered, entered.Task, + heldProvider, pressure.Count, runtime); } + failure?.Throw(); heldProvider!.IsCompleted.Should().BeTrue(); transactionalOwner!.ActualAuxiliary.IsCompletedSuccessfully.Should().BeTrue(); transactionalOwner.Owner.CleanupConfirmed.Should().BeTrue(); @@ -201,6 +239,56 @@ public async Task Native_transactional_pool_completes_at_the_app_source_floor_wh runtime.Snapshot.Control!.Executions.Should().Be(0); } + // Failure-only memory projection. No SQL, identity, provider text, retry or + // completion capability is introduced, and projection faults preserve the cause. + private void WriteSourceContentionObservation(string kind, string boundary, Task reconcile, + Task entered, Task? provider, int pressure, BackgroundJobRuntime runtime) + { + static string State(Task? task) => task?.Status switch + { + null => "not_issued", + TaskStatus.RanToCompletion => "succeeded", + TaskStatus.Canceled => "cancelled", + TaskStatus.Faulted => "faulted", + _ => "pending", + }; + static string Count(long? value) => value switch + { + null => "unavailable", + >= 0 and <= 64 => value.Value.ToString(System.Globalization.CultureInfo.InvariantCulture), + _ => "out_of_range", + }; + try + { + var snapshot = runtime.Snapshot; + var state = snapshot.State switch + { + BackgroundJobRuntimeState.NotStarted => "not_started", + BackgroundJobRuntimeState.Bootstrapping => "bootstrapping", + BackgroundJobRuntimeState.Starting => "starting", + BackgroundJobRuntimeState.Running => "running", + BackgroundJobRuntimeState.Stopping => "stopping", + BackgroundJobRuntimeState.Retained => "retained", + BackgroundJobRuntimeState.Stopped => "stopped", + _ => "unknown", + }; + var auxiliary = entered.IsCompletedSuccessfully ? entered.GetAwaiter().GetResult().ActualAuxiliary : null; + output.WriteLine("Source contention " + kind + ";boundary=" + boundary + + ";reconcile=" + State(reconcile) + ";transactional=" + State(entered) + + ";provider=" + State(provider) + ";auxiliary=" + State(auxiliary) + + ";pressure=" + Count(pressure) + ";runtime=" + state + + ";completion=" + State(runtime.Completion) + ";executions=" + Count(snapshot.Control?.Executions) + + ";retained=" + Count(snapshot.RetainedInvocations) + + ";renewalRefused=" + Count(snapshot.Control?.LeaseLossRenewalRefused) + + ";leaseElapsed=" + Count(snapshot.Control?.LeaseLossElapsed) + "."); + } + catch (Exception error) when (NativeWaitCatchable(error)) + { + try { output.WriteLine("Source contention " + kind + ";boundary=" + boundary + ";snapshot=unavailable."); } + catch (Exception outputError) when (NativeWaitCatchable(outputError)) { } + } + } + private static BackgroundJobDefinitionRegistry SourceContentionRegistry(BackgroundJobRuntimeFixture transactional) { var external = Fixture.Registry.Resolve(BackgroundJobPositiveSdkFixture.Key).Definition!; diff --git a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.Runtime.cs b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.Runtime.cs index ccd5cc69..fa3cce05 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.Runtime.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobRecurrenceStoreTests.Runtime.cs @@ -15,6 +15,7 @@ using LearnStack.SharedKernel.Tenancy; using MediatR; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Npgsql; using Xunit; @@ -49,20 +50,37 @@ public async Task Actual_runtime_fans_out_to_both_scopes_and_duplicate_native_ti return Task.FromResult(Result.Ok(None.Value)); }, }; - await using var host = RecipientHost(database, probe, - configure: services => services.AddSingleton(new RecurrenceFixtureAuditSource())); + // Keep a lost native hint's legal repair observable inside this case's + // unchanged 30-second wait. The actual control and DI executor share + // this validated case-owned configuration; production defaults stay intact. + var bounds = new BackgroundJobRuntimeOptions + { + ControlPoll = TimeSpan.FromMilliseconds(50), + FanoutPageSize = 2, + HandoffRepairGrace = TimeSpan.FromSeconds(15), + }; + bounds.Validate(); + await using var host = RecipientHost(database, probe, configure: services => + { + services.RemoveAll(); + services.AddSingleton(bounds); + services.AddSingleton(new RecurrenceFixtureAuditSource()); + }); await using var source = Source(database); using var globals = new RecurrenceGlobalFilters(); - var bounds = new BackgroundJobRuntimeOptions { ControlPoll = TimeSpan.FromMilliseconds(50), FanoutPageSize = 2 }; var runtime = RecurrenceRuntime(host.Services, source, Fixture.Recurrences(manifest), bounds); using var metrics = new BackgroundJobMetricProbe(host.Services.GetRequiredService()); + long? completed = null; + var boundary = "start"; try { await runtime.StartAsync(); + boundary = "children"; await RecurrenceEventuallyAsync(async () => { await using var connection = await PostgresFixture.OpenAsync(database.OutboxConnectionString); - return await ReadAsync(connection, "SELECT count(*) FROM background_jobs WHERE phase='completed'") == 2; + completed = await ReadAsync(connection, "SELECT count(*) FROM background_jobs WHERE phase='completed'"); + return completed == 2; }); var expected = new[] { @@ -70,6 +88,7 @@ await RecurrenceEventuallyAsync(async () => (SchemaFixture.TenantB, organization ? (Guid?)SchemaFixture.OrgB1 : null), }; executed.ToArray().Should().BeEquivalentTo(expected); + boundary = "fanout"; await RecurrenceEventuallyAsync(() => Task.FromResult(runtime.Snapshot.Control!.Recurrence!.FanoutsCompletedAcknowledged == 1)); AssertProgress(); AssertProgress(); @@ -95,13 +114,16 @@ await RecurrenceEventuallyAsync(async () => CultureInfo.CurrentCulture = culture; CultureInfo.CurrentUICulture = uiCulture; } + boundary = "ticks"; await RecurrenceEventuallyAsync(async () => { await using var connection = await PostgresFixture.OpenAsync(database.OutboxConnectionString); return await ReadAsync(connection, "SELECT count(*) FROM public.job WHERE id::text=ANY(@ticks) AND statename='Succeeded'", ("ticks", ticks)) == ticks.Length; }); + boundary = "health"; await RecurrenceEventuallyAsync(() => Task.FromResult(runtime.Snapshot.IsHealthy)); + boundary = "assertions"; await using var dispatcher = await PostgresFixture.OpenAsync(database.OutboxConnectionString); (await ReadAsync(dispatcher, "SELECT count(*) FROM background_job_occurrences")).Should().Be(1); (await ReadAsync(dispatcher, "SELECT skipped_slots FROM background_job_occurrences")).Should().Be(2); @@ -119,6 +141,32 @@ AND j.organization_id IS NOT DISTINCT FROM r.organization_id runtime.Snapshot.Control!.Recurrence!.RetainedApplications.Should().Be(0); AssertProgress(); } + catch (Exception) + { + // Observe before stop changes the state. No diagnostic I/O, new + // polling or deadline extension may hide the original test failure. + try + { + var snapshot = runtime.Snapshot; + var recurrence = snapshot.Control?.Recurrence; + output.WriteLine("Recurrence runtime failure;boundary=" + boundary + + ";runtime=" + snapshot.State + + ";completed=" + Count(completed) + ";executed=" + Count(executed.Count) + + ";occurrences=" + Count(recurrence?.OccurrencesAcknowledged) + + ";recipients=" + Count(recurrence?.RecipientsInsertedAcknowledged) + + ";fanouts=" + Count(recurrence?.FanoutsCompletedAcknowledged) + + ";applications=" + Count(recurrence?.ApplicationOwners) + + ";retainedApplications=" + Count(recurrence?.RetainedApplications) + + ";bridgeOutstanding=" + Count(snapshot.Bridge?.Outstanding) + + ";bridgeRetained=" + Count(snapshot.Bridge?.Retained) + + ";bridgeFailed=" + Count(snapshot.Bridge?.Failed) + + ";executions=" + Count(snapshot.Control?.Executions) + + ";renewalRefused=" + Count(snapshot.Control?.LeaseLossRenewalRefused) + + ";leaseElapsed=" + Count(snapshot.Control?.LeaseLossElapsed) + "."); + } + catch (Exception) { } // Observation must not replace the original fault. + throw; + } finally { using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(30)); @@ -128,6 +176,9 @@ AND j.organization_id IS NOT DISTINCT FROM r.organization_id runtime.Snapshot.CleanupConfirmed.Should().BeTrue(); runtime.Snapshot.Control!.Recurrence!.CleanupConfirmed.Should().BeTrue(); + static string Count(long? value) => value is >= 0 and <= 10000 + ? value.Value.ToString(CultureInfo.InvariantCulture) : "unavailable"; + void AssertProgress() { var totals = runtime.Snapshot.Control!.Recurrence!; diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicAdmissionRenderingTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicAdmissionRenderingTests.cs index 17f2691f..82e8979f 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicAdmissionRenderingTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicAdmissionRenderingTests.cs @@ -122,8 +122,8 @@ await fixture.ExecuteAsync(SeedData.English, "UPDATE tenants SET display_name=@n } else if (checkpoint == "hmr-after") { - AssertCalls(host, ref position, 2, 2); - fixture.Observation.Reads.Should().Be(hmrReads + 2); + AssertCalls(host, ref position, 4, 2); + fixture.Observation.Reads.Should().Be(hmrReads + 4); } else if (checkpoint == "hmr-restore") { @@ -210,9 +210,9 @@ await fixture.ExecuteAsync(SeedData.English, "UPDATE tenants SET display_name=@n 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") + else if (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") + else if (name is "healthy-wire" or "healthy-lifetime" or "head" or "late-head" or "browser-initial" or "hmr-before" or "hmr-after") AssertCalls(host, ref position, 2, 1); else throw new InvalidOperationException("Unexpected admission proof checkpoint."); } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs index 636f9a84..430b0bb0 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs @@ -43,7 +43,7 @@ private async Task AssertLauncherAsync(bool useDemoRuntime, bool isolateProfile try { Directory.CreateDirectory(Path.Combine(temporary, "scripts")); - Directory.CreateDirectory(Path.Combine(app, "src/server")); + Directory.CreateDirectory(Path.Combine(app, "src/server/auth")); Directory.CreateDirectory(Path.Combine(temporary, "user-secrets")); Directory.CreateDirectory(Path.Combine(temporary, "owned-profile")); File.WriteAllText(Path.Combine(temporary, "package.json"), "{\"type\":\"module\"}"); @@ -62,7 +62,7 @@ private async Task AssertLauncherAsync(bool useDemoRuntime, bool isolateProfile } File.WriteAllText(runtime, source); } - foreach (var file in new[] { "tsconfig.json", "tsconfig.server.json", "src/server/ingress.ts" }) + foreach (var file in new[] { "tsconfig.json", "tsconfig.server.json", "src/server/ingress.ts", "src/server/auth/http-paths.ts" }) File.Copy(Path.Combine(repository, "frontend/apps/web", file), Path.Combine(app, file)); Directory.CreateSymbolicLink(Path.Combine(app, "node_modules"), Path.Combine(repository, "frontend/apps/web/node_modules")); Directory.CreateSymbolicLink(backend, Path.Combine(repository, "backend")); diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/BffNativeDriver.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/BffNativeDriver.cs new file mode 100644 index 00000000..0409c919 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/BffNativeDriver.cs @@ -0,0 +1,332 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +/// Private bounded pipe to one owned native Next process; no credentials in argv or results. +internal sealed class BffNativeDriver : IAsyncDisposable +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + private static readonly string[] ErrorStages = (new[] { "input", "input-issuer", "input-api", "input-secrets", "input-sites", "setup", "compile", "build", "start", "command", "request", "request-deadline", "request-connect", "request-tls", "request-socket", "response-json", "cleanup", "privacy", "native-stop", + "browser-launch", "browser-anonymous", "browser-signin-document", "browser-submit", "browser-origin-null", "browser-origin-absent", "browser-origin-other", "browser-storage", "browser-extension", "browser-logout-submit", "browser-logout-origin", "browser-logout-origin-null", "browser-logout-origin-other", "browser-logout-response", "browser-logout-location", "browser-logout-navigation", "browser-logout-cookie-header", "browser-logout-cookie", "browser-back-revalidation", "browser-history-setup", "browser-history-revalidation", "browser-headers", + "browser-executable", "browser-spawn", "browser-devtools", "browser-devtools-inactive", + "browser-devtools-unreadable", "browser-devtools-missing", "browser-devtools-empty", "browser-devtools-malformed", + "browser-not-live", "browser-connect", "browser-context" } + .Concat(new[] { "browser-callback-", "browser-authorize-", "browser-logout-status-" } + .SelectMany(prefix => new[] { "0", "200", "303", "400", "403", "404", "429", "503", "other" }.Select(code => prefix + code)))).ToArray(); + private readonly Process _child; + private readonly CancellationTokenSource _deadline = new(TimeSpan.FromMinutes(4)); + private readonly Task _errors; + private readonly string _temporaryRoot = Directory.CreateTempSubdirectory("learnstack-bff-driver-").FullName; + private Task? _disposal; + private bool _complete; + private int _expectedExit; + + private BffNativeDriver(string root, bool browser = false, bool counter = false, bool heldBuild = false) + { + var start = new ProcessStartInfo("node") + { + WorkingDirectory = root, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardInputEncoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + }; + start.ArgumentList.Add(Path.Combine(root, "frontend/apps/web/scripts/" + (browser ? "verify-bff-browser.mjs" : "verify-bff-session.mjs"))); + if (counter) start.ArgumentList.Add(browser ? "--without-history" : "--without-successful-native-stop"); + if (heldBuild) start.ArgumentList.Add("--hold-build"); + start.Environment.Clear(); + start.Environment["PATH"] = Environment.GetEnvironmentVariable("PATH"); + start.Environment["NODE_TLS_REJECT_UNAUTHORIZED"] = "1"; + foreach (var key in new[] { "TMPDIR", "TMP", "TEMP" }) start.Environment[key] = _temporaryRoot; + _child = new() { StartInfo = start }; + try + { + if (!_child.Start()) throw new InvalidOperationException(); + } + catch + { + _deadline.Dispose(); + _child.Dispose(); + RemoveEmptyTemporaryRoot(); + throw new InvalidOperationException("BFF native fixture could not start."); + } + _errors = ReadErrorsAsync(_child.StandardError, CancellationToken.None); + } + + internal string TemporaryRoot => _temporaryRoot; + + internal static async Task StartAsync(string root, object configuration, bool shutdownCounter = false, bool heldBuild = false, + Action? secondaryCleanupFailure = null) + { + var driver = new BffNativeDriver(root, counter: shutdownCounter, heldBuild: heldBuild); + try + { + await driver.WriteAsync(configuration); + await driver.ReadAsync(heldBuild ? "held-build" : "ready"); + return driver; + } + catch (Exception error) when (Catchable(error)) + { + try { await driver.DisposeAsync(); } + catch (Exception cleanup) when (Catchable(cleanup)) + { + try { secondaryCleanupFailure?.Invoke(); } + catch (Exception observation) when (Catchable(observation)) { } + } + throw; + } + } + + internal static async Task BrowserAsync(string root, object configuration, bool counter, + Action? secondaryCleanupFailure = null) + { + var driver = new BffNativeDriver(root, browser: true, counter: counter); + var bodyFailed = false; + try { return await ReadBrowserAsync(driver, configuration, counter); } + catch (Exception error) when (Catchable(error)) { bodyFailed = true; throw; } + finally + { + // Always attempt actual disposal. A second cleanup error must not + // replace the first failure; cleanup-only failure still propagates. + try { await driver.DisposeAsync(); } + catch (Exception error) when (bodyFailed && Catchable(error)) + { + try { secondaryCleanupFailure?.Invoke(); } + catch (Exception observation) when (Catchable(observation)) { } + } + } + } + + private static bool Catchable(Exception error) => error.GetType() != typeof(OutOfMemoryException) + && error.GetType() != typeof(StackOverflowException); + + private static async Task ReadBrowserAsync(BffNativeDriver driver, object configuration, bool counter) + { + await driver.WriteAsync(configuration); + var reply = await driver.ReadAsync("browser_result"); + driver._child.StandardInput.Close(); + await driver._child.WaitForExitAsync(driver._deadline.Token); + await driver.AssertOutputEndedAsync(); + var expected = counter ? "rejected_history" : "passed"; + var version = reply.GetProperty("version").GetString(); + driver._expectedExit = counter ? 1 : 0; + if (reply.GetProperty("outcome").GetString() != expected || reply.GetProperty("hosts").GetInt32() != 2 + || version is null || version.Length > 32 || !System.Text.RegularExpressions.Regex.IsMatch(version, @"^\d+(?:\.\d+){1,3}$") + || driver._child.ExitCode != driver._expectedExit || await driver._errors is not null) + throw new InvalidOperationException("BFF browser fixture lacks its complete positive/counter witness."); + var restores = counter ? 0 : reply.GetProperty("naturalRestores").GetInt32(); + if (restores is < 0 or > 32) throw new InvalidOperationException("BFF browser restore count is outside its bound."); + driver._complete = true; + var witness = counter ? reply.GetProperty("witness").GetString() : null; + if (counter && witness is not ("natural_back" or "persisted_event")) + throw new InvalidOperationException("BFF browser counter has no closed restoration witness."); + return new(version, restores, witness); + } + + internal async Task RequestAsync(string host, string path, string cookie = "", + string method = "GET", string body = "") + { + await WriteAsync(new { protocol = 1, action = "request", host, path, cookie, method, body }); + var reply = await ReadAsync("response"); + try + { + var status = reply.GetProperty("status").GetInt32(); + var cookies = reply.GetProperty("cookies").EnumerateArray().Select(value => value.GetString()!).ToArray(); + var location = reply.GetProperty("location").GetString(); + var cache = reply.GetProperty("cache").GetString(); + if (status is < 100 or > 599 || cookies.Length > 32 || cookies.Sum(value => value.Length) > 32768 + || (location?.Length ?? 0) > 32768) throw new InvalidOperationException(); + return new(status, cookies, location, cache, reply.GetProperty("data").Clone(), + reply.GetProperty("rendered").GetBoolean(), reply.GetProperty("privateFree").GetBoolean()); + } + catch { throw new InvalidOperationException("BFF native fixture response has an invalid closed shape."); } + } + + internal async Task CompleteAsync() + { + await WriteAsync(new { protocol = 1, action = "finish" }); + await ReadAsync("complete"); + _child.StandardInput.Close(); + await _child.WaitForExitAsync(_deadline.Token); + await AssertOutputEndedAsync(); + if (_child.ExitCode != 0 || await _errors is not null) + throw new InvalidOperationException("BFF native fixture failed after owned cleanup."); + _complete = true; + } + + private async Task WriteAsync(object value) + { + try + { + await _child.StandardInput.WriteLineAsync(JsonSerializer.Serialize(value, Json).AsMemory(), _deadline.Token); + await _child.StandardInput.FlushAsync(_deadline.Token); + } + catch { throw new InvalidOperationException("BFF native fixture private input failed."); } + } + + private async Task ReadAsync(string phase) + { + var lastStage = "input"; + using var responseBudget = CancellationTokenSource.CreateLinkedTokenSource(_deadline.Token); + responseBudget.CancelAfter(TimeSpan.FromSeconds(phase is "ready" or "held-build" ? 90 : phase == "browser_result" ? 150 : 40)); + try + { + for (var count = 0; count < 8; ++count) + { + var line = await ReadBoundedLineAsync(responseBudget.Token); + if (line is null) + { + await _child.WaitForExitAsync(responseBudget.Token); + throw new InvalidOperationException(); + } + using var parsed = JsonDocument.Parse(line); + if (parsed.RootElement.GetProperty("protocol").GetInt32() != 1) throw new InvalidOperationException(); + var actual = parsed.RootElement.GetProperty("phase").GetString(); + if (phase is "ready" or "held-build" && actual == "progress") + { + var value = parsed.RootElement.GetProperty("stage").GetString(); + if (value is not ("compile" or "build" or "start")) throw new InvalidOperationException(); + lastStage = value; + continue; + } + if (actual != phase) throw new InvalidOperationException(); + return parsed.RootElement.Clone(); + } + throw new InvalidOperationException(); + } + catch + { + var reason = "response"; + if (responseBudget.IsCancellationRequested) reason = "deadline_" + lastStage; + else if (_child.HasExited) try { reason = await _errors ?? "exit"; } catch { reason = "output"; } + throw new InvalidOperationException("BFF native fixture did not reach the closed " + phase + "/" + reason + " phase."); + } + } + + private async Task AssertOutputEndedAsync() + { + if (await ReadBoundedLineAsync(_deadline.Token) is not null) + throw new InvalidOperationException("BFF native fixture emitted an unexpected extra receipt."); + } + + private async Task ReadBoundedLineAsync(CancellationToken token) + { + var line = new System.Text.StringBuilder(); + var character = new char[1]; + while (await _child.StandardOutput.ReadAsync(character.AsMemory(), token) == 1) + { + if (character[0] == '\n') return line.ToString(); + if (line.Length == 65536) throw new InvalidOperationException("BFF native fixture receipt exceeds its bound."); + line.Append(character[0]); + } + if (line.Length != 0) throw new InvalidOperationException("BFF native fixture receipt is truncated."); + return null; + } + + internal static async Task ReadErrorsAsync(StreamReader reader, CancellationToken token) + { + var text = new System.Text.StringBuilder(); + var overflow = false; + var buffer = new char[1024]; + int read; + while ((read = await reader.ReadAsync(buffer.AsMemory(), token)) > 0) + if (text.Length + read <= 256) text.Append(buffer, 0, read); else overflow = true; + if (text.Length == 0 && !overflow) return null; + foreach (var stage in ErrorStages) + foreach (var prefix in new[] { "BFF session fixture failed: ", "BFF browser fixture failed: " }) + { + var closed = prefix + stage + "\n"; + if (!overflow && text.ToString() == closed) return stage; + var cleanup = prefix == "BFF browser fixture failed: " + ? "BFF browser cleanup failed.\n" : "BFF session cleanup failed.\n"; + if (!overflow && text.ToString() == closed + cleanup) return stage + "_cleanup_failed"; + if (text.ToString().Contains(closed, StringComparison.Ordinal)) return stage + "_unexpected_output"; + } + foreach (var (pattern, reason) in new[] { ("ERR_MODULE_NOT_FOUND", "node_module_missing"), + ("SyntaxError", "node_syntax"), ("AbortError", "node_abort"), ("Permission denied", "node_permission") }) + if (text.ToString().Contains(pattern, StringComparison.Ordinal)) return reason; + return "unexpected_output"; + } + + public ValueTask DisposeAsync() => new(_disposal ??= DisposeCoreAsync()); + + private async Task DisposeCoreAsync() + { + using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var forced = false; + try + { + // EOF is a private lifetime signal, not a request to kill Node before + // it can join its own groups and remove the nested TLS/build tree. + try { _child.StandardInput.Close(); } + catch (ObjectDisposedException) { } + catch (IOException) { } + var drained = DrainOutputAsync(_child.StandardOutput, budget.Token); + if (!_child.HasExited) + { + using var grace = CancellationTokenSource.CreateLinkedTokenSource(budget.Token); + grace.CancelAfter(TimeSpan.FromSeconds(15)); + try { await _child.WaitForExitAsync(grace.Token); } + catch (OperationCanceledException) + { + forced = true; + try { _child.Kill(entireProcessTree: true); } + catch (InvalidOperationException) when (_child.HasExited) { } + } + } + await _child.WaitForExitAsync(budget.Token); + await Task.WhenAll(drained, _errors.WaitAsync(budget.Token)); + if (forced) throw new InvalidOperationException("BFF native fixture required forced cleanup."); + if (_complete && _child.ExitCode != _expectedExit) + throw new InvalidOperationException("BFF native fixture completed without a successful exit."); + if (!RemoveEmptyTemporaryRoot()) + throw new InvalidOperationException("BFF native fixture retained its private TLS/build tree."); + } + catch (OperationCanceledException) { throw new InvalidOperationException("BFF native fixture cleanup could not be confirmed."); } + finally { _deadline.Dispose(); _child.Dispose(); } + } + + private bool RemoveEmptyTemporaryRoot() + { + try + { + if (!Directory.Exists(_temporaryRoot)) return true; + if (Directory.EnumerateFileSystemEntries(_temporaryRoot).Any()) return false; + Directory.Delete(_temporaryRoot); // Never recursively hide incomplete child cleanup. + return true; + } + catch { return false; } + } + + private static async Task DrainOutputAsync(StreamReader reader, CancellationToken token) + { + var buffer = new char[1024]; + while (await reader.ReadAsync(buffer.AsMemory(), token) != 0) { } + } +} + +internal sealed record BffWireResponse(int Status, string[] Cookies, string? Location, string? Cache, + JsonElement Data, bool Rendered, bool PrivateFree); +internal sealed record BffBrowserReceipt(string Version, int NaturalRestores, string? CounterWitness); + +/// Separate HTTP-chain jars; browser storage rules are proved by the actual browser case. +internal sealed class BffWireCookies +{ + private readonly Dictionary _values = new(StringComparer.Ordinal); + internal string Header => string.Join("; ", _values.Select(value => value.Key + "=" + value.Value)); + internal void Apply(BffWireResponse response) + { + foreach (var cookie in response.Cookies) + { + var pair = cookie.Split(';', 2)[0]; + var equal = pair.IndexOf('='); + if (equal < 1) throw new InvalidOperationException("BFF native fixture cookie is malformed."); + var name = pair[..equal]; + var value = pair[(equal + 1)..]; + if (value.Length == 0) _values.Remove(name); + else _values[name] = value; + } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/Fixtures/keycloak-26.6.2-default-events.json b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/Fixtures/keycloak-26.6.2-default-events.json new file mode 100644 index 00000000..a3ddb7e0 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/Fixtures/keycloak-26.6.2-default-events.json @@ -0,0 +1,109 @@ +{ + "source": "https://raw.githubusercontent.com/keycloak/keycloak/26.6.2/server-spi-private/src/main/java/org/keycloak/events/EventType.java", + "sourceSha256": "e96d8c99c67ff697d6798d2919bfcbeda238ff58dc923daaf59250672ab9b0b6", + "defaultSaved": [ + "AUTHREQID_TO_TOKEN", + "AUTHREQID_TO_TOKEN_ERROR", + "CLIENT_DELETE", + "CLIENT_DELETE_ERROR", + "CLIENT_INITIATED_ACCOUNT_LINKING", + "CLIENT_INITIATED_ACCOUNT_LINKING_ERROR", + "CLIENT_LOGIN", + "CLIENT_LOGIN_ERROR", + "CLIENT_REGISTER", + "CLIENT_REGISTER_ERROR", + "CLIENT_UPDATE", + "CLIENT_UPDATE_ERROR", + "CODE_TO_TOKEN", + "CODE_TO_TOKEN_ERROR", + "CUSTOM_REQUIRED_ACTION", + "CUSTOM_REQUIRED_ACTION_ERROR", + "DELETE_ACCOUNT", + "DELETE_ACCOUNT_ERROR", + "EXECUTE_ACTIONS", + "EXECUTE_ACTIONS_ERROR", + "EXECUTE_ACTION_TOKEN", + "EXECUTE_ACTION_TOKEN_ERROR", + "FEDERATED_IDENTITY_LINK", + "FEDERATED_IDENTITY_LINK_ERROR", + "FEDERATED_IDENTITY_OVERRIDE_LINK", + "FEDERATED_IDENTITY_OVERRIDE_LINK_ERROR", + "GRANT_CONSENT", + "GRANT_CONSENT_ERROR", + "IDENTITY_PROVIDER_FIRST_LOGIN", + "IDENTITY_PROVIDER_FIRST_LOGIN_ERROR", + "IDENTITY_PROVIDER_LINK_ACCOUNT", + "IDENTITY_PROVIDER_LINK_ACCOUNT_ERROR", + "IDENTITY_PROVIDER_LOGIN", + "IDENTITY_PROVIDER_LOGIN_ERROR", + "IDENTITY_PROVIDER_POST_LOGIN", + "IDENTITY_PROVIDER_POST_LOGIN_ERROR", + "IMPERSONATE", + "IMPERSONATE_ERROR", + "INVITE_ORG", + "INVITE_ORG_ERROR", + "JWT_AUTHORIZATION_GRANT", + "JWT_AUTHORIZATION_GRANT_ERROR", + "LOGIN", + "LOGIN_ERROR", + "LOGOUT", + "LOGOUT_ERROR", + "OAUTH2_DEVICE_AUTH", + "OAUTH2_DEVICE_AUTH_ERROR", + "OAUTH2_DEVICE_CODE_TO_TOKEN", + "OAUTH2_DEVICE_CODE_TO_TOKEN_ERROR", + "OAUTH2_DEVICE_VERIFY_USER_CODE", + "OAUTH2_DEVICE_VERIFY_USER_CODE_ERROR", + "OAUTH2_EXTENSION_GRANT", + "OAUTH2_EXTENSION_GRANT_ERROR", + "PERMISSION_TOKEN", + "REGISTER", + "REGISTER_ERROR", + "REMOVE_CREDENTIAL", + "REMOVE_CREDENTIAL_ERROR", + "REMOVE_FEDERATED_IDENTITY", + "REMOVE_FEDERATED_IDENTITY_ERROR", + "REMOVE_TOTP", + "REMOVE_TOTP_ERROR", + "RESET_PASSWORD", + "RESET_PASSWORD_ERROR", + "RESTART_AUTHENTICATION", + "RESTART_AUTHENTICATION_ERROR", + "REVOKE_GRANT", + "REVOKE_GRANT_ERROR", + "SEND_IDENTITY_PROVIDER_LINK", + "SEND_IDENTITY_PROVIDER_LINK_ERROR", + "SEND_RESET_PASSWORD", + "SEND_RESET_PASSWORD_ERROR", + "SEND_VERIFY_EMAIL", + "SEND_VERIFY_EMAIL_ERROR", + "TOKEN_EXCHANGE", + "TOKEN_EXCHANGE_ERROR", + "UPDATE_CONSENT", + "UPDATE_CONSENT_ERROR", + "UPDATE_CREDENTIAL", + "UPDATE_CREDENTIAL_ERROR", + "UPDATE_EMAIL", + "UPDATE_EMAIL_ERROR", + "UPDATE_PASSWORD", + "UPDATE_PASSWORD_ERROR", + "UPDATE_PROFILE", + "UPDATE_PROFILE_ERROR", + "UPDATE_TOTP", + "UPDATE_TOTP_ERROR", + "USER_DISABLED_BY_PERMANENT_LOCKOUT", + "USER_DISABLED_BY_TEMPORARY_LOCKOUT", + "VERIFIABLE_CREDENTIAL_CREATE_OFFER", + "VERIFIABLE_CREDENTIAL_CREATE_OFFER_ERROR", + "VERIFIABLE_CREDENTIAL_OFFER_REQUEST", + "VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR", + "VERIFIABLE_CREDENTIAL_PRE_AUTHORIZED_GRANT", + "VERIFIABLE_CREDENTIAL_PRE_AUTHORIZED_GRANT_ERROR", + "VERIFIABLE_CREDENTIAL_REQUEST", + "VERIFIABLE_CREDENTIAL_REQUEST_ERROR", + "VERIFY_EMAIL", + "VERIFY_EMAIL_ERROR", + "VERIFY_PROFILE", + "VERIFY_PROFILE_ERROR" + ] +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakBrowserFlow.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakBrowserFlow.cs index 17ba35a3..501a3e9f 100644 --- a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakBrowserFlow.cs +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakBrowserFlow.cs @@ -26,6 +26,72 @@ internal KeycloakBrowserFlow(Uri baseAddress, X509Certificate2 rootCertificate, _client = KeycloakHttp.Create(baseAddress, rootCertificate, new CookieContainer()); } + // Completes the RP-owned authorization URL without replacing its state, + // nonce, challenge or verifier, and never redeems its code in this helper. + internal async Task CompleteBffLoginAsync(Uri authorization, string username, string password) + { + if (!SameOrigin(authorization, _baseAddress) + || authorization.AbsolutePath != "/realms/learnstack/protocol/openid-connect/auth") + throw new InvalidOperationException("BFF fixture authorization is outside the owned provider."); + var query = QueryHelpers.ParseQuery(authorization.Query); + var callbackText = query.GetValueOrDefault("redirect_uri").ToString(); + if (!Uri.TryCreate(callbackText, UriKind.Absolute, out var callback) + || callback.Scheme != "https" || callback.Port != 3000 + || !LearnStack.Tools.Seeder.SeedData.All.Any(tenant => tenant.Host == callback.Host) + || callback.AbsolutePath != "/api/auth/callback/keycloak" || callback.Query.Length != 0 + || callback.Fragment.Length != 0 || callback.UserInfo.Length != 0 + || query.GetValueOrDefault("client_id") != "learnstack-web" + || query.GetValueOrDefault("code_challenge_method") != "S256" + || query.Any(pair => pair.Value.Count != 1)) + throw new InvalidOperationException("BFF fixture authorization does not use the delivered callback/S256 contract."); + using var page = await KeycloakHttp.SendAsync(_client, HttpMethod.Get, authorization.AbsoluteUri); + if (page.StatusCode != HttpStatusCode.OK) + throw new InvalidOperationException("BFF fixture expected a fresh provider login form."); + var action = LoginAction(await KeycloakHttp.ReadBodyAsync(page)); + using var submitted = await KeycloakHttp.SendAsync(_client, HttpMethod.Post, action, + new FormUrlEncodedContent(new Dictionary + { + ["username"] = username, + ["password"] = password, + ["credentialId"] = "", + })); + var location = submitted.Headers.Location; + if (submitted.StatusCode is not (HttpStatusCode.Found or HttpStatusCode.SeeOther) + || location is null || !location.IsAbsoluteUri + || location.GetLeftPart(UriPartial.Path) != callback.GetLeftPart(UriPartial.Path)) + throw new InvalidOperationException("BFF fixture provider login did not reach its owned callback."); + var returned = QueryHelpers.ParseQuery(location.Query); + if (returned.GetValueOrDefault("state") != query.GetValueOrDefault("state") + || returned.GetValueOrDefault("code").Count != 1 || returned.ContainsKey("error") + || location.Fragment.Length != 0 || location.UserInfo.Length != 0 + || returned.Any(pair => pair.Value.Count != 1)) + throw new InvalidOperationException("BFF fixture provider callback lacks its original state or code."); + return location; + } + + internal async Task CompleteBffLogoutAsync(Uri logout) + { + if (!SameOrigin(logout, _baseAddress) + || logout.AbsolutePath != "/realms/learnstack/protocol/openid-connect/logout") + throw new InvalidOperationException("BFF fixture logout is outside the owned provider."); + var query = QueryHelpers.ParseQuery(logout.Query); + if (!Uri.TryCreate(query.GetValueOrDefault("post_logout_redirect_uri"), UriKind.Absolute, out var target) + || target.Scheme != "https" || target.Port != 3000 || target.AbsolutePath != "/" + || target.Query.Length != 0 || target.Fragment.Length != 0 || target.UserInfo.Length != 0 + || query.Any(pair => pair.Value.Count != 1) + || !LearnStack.Tools.Seeder.SeedData.All.Any(tenant => tenant.Host == target.Host)) + throw new InvalidOperationException("BFF fixture logout return is outside the delivered hosts."); + using var response = await KeycloakHttp.SendAsync(_client, HttpMethod.Get, logout.AbsoluteUri); + var location = response.Headers.Location; + if (response.StatusCode is not (HttpStatusCode.Found or HttpStatusCode.SeeOther) + || location is null || !location.IsAbsoluteUri + || location.GetLeftPart(UriPartial.Path) != target.GetLeftPart(UriPartial.Path) + || location.Fragment.Length != 0 || location.UserInfo.Length != 0 + || QueryHelpers.ParseQuery(location.Query).GetValueOrDefault("state") != query.GetValueOrDefault("state")) + throw new InvalidOperationException("BFF fixture provider logout did not return its owned state."); + return location; + } + internal async Task AuthorizeAsync(string username, string password, string redirectUri, KeycloakPkce pkce = KeycloakPkce.S256, string clientId = "learnstack-web", string scope = "openid profile email", Func>? otpCode = null) diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakFixture.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakFixture.cs index 8f649ba4..acb2e804 100644 --- a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakFixture.cs +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakFixture.cs @@ -147,6 +147,29 @@ internal async Task CreateAdminClientAsync(string realm = " catch { client.Dispose(); throw; } } + // A fresh isolated master issuance observes the provider's integer-second + // clock. This scalar is test timing only, never a token-authority witness. + // Extra master sessions belong to this fixture's actual container cleanup. + internal async Task ObserveProviderUtcAsync(CancellationToken cancellationToken) + { + var credential = await AuthenticateAdministratorAsync(cancellationToken); + try + { + var parts = credential.AccessToken.Split('.'); + if (parts.Length != 3) throw new InvalidOperationException(); + using var payload = JsonDocument.Parse(Microsoft.IdentityModel.Tokens.Base64UrlEncoder.DecodeBytes(parts[1])); + if (payload.RootElement.ValueKind != JsonValueKind.Object + || payload.RootElement.EnumerateObject().Count(property => property.NameEquals("iat")) != 1 + || !payload.RootElement.TryGetProperty("iat", out var issued) + || issued.ValueKind != JsonValueKind.Number || !issued.TryGetInt64(out var seconds) + || seconds <= 0) throw new InvalidOperationException(); + return DateTimeOffset.FromUnixTimeSeconds(seconds).ToUnixTimeMilliseconds(); + } + catch (Exception error) when (error.GetType() != typeof(OutOfMemoryException) + && error.GetType() != typeof(StackOverflowException)) + { throw new InvalidOperationException("Keycloak fixture clock observation is invalid."); } + } + private async Task AuthenticateAdministratorAsync(CancellationToken cancellationToken) { using var client = KeycloakHttp.Create(BaseAddress, RootCertificate); diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakReconciliationDriver.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakReconciliationDriver.cs index 79090683..3532fe3f 100644 --- a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakReconciliationDriver.cs +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakReconciliationDriver.cs @@ -99,7 +99,7 @@ internal static async Task RunAsync(KeycloakFixture fixtu ["preflight", "discovery", "authentication", "version", "manifest", "lock", "plan", "prepare", "apply", "readback", "cleanup"]; private static readonly string[] Reasons = ["configuration", "identity_conflict", "mapper_conflict", "secret_conflict", "unsupported", "response", "status", - "redirect", "deadline", "cancelled", "transport", "lock_busy", "body_limit", "readback_conflict", "cleanup", "missing_web_secret"]; + "redirect", "deadline", "cancelled", "transport", "lock_busy", "body_limit", "readback_conflict", "cleanup", "missing_web_secret", "event_policy_conflict"]; private static readonly string[] RequestPhases = ["discovery", "authentication", "version", "initialPlan", "prepare", "freshPlan", "apply", "readback"]; diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantBffSessionTests.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantBffSessionTests.cs new file mode 100644 index 00000000..7a453940 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantBffSessionTests.cs @@ -0,0 +1,536 @@ +using System.Diagnostics; +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentAssertions; +using LearnStack.Api.Authentication; +using LearnStack.Modules.Tenancy.Application.Contracts.PublicReads; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.Tests.Integration.Database; +using LearnStack.Tools.Seeder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Xunit; +using Xunit.Abstractions; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +/// Real warm provider → native BFF → real application-role API; browser proof is separate. +[Collection(PublicReadTestGroup.Name)] +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed class TenantBffSessionTests(PublicReadFixture database, ITestOutputHelper output) +{ + [Fact] + public async Task Parent_EOF_during_held_build_joins_acquisition_and_removes_its_private_tree() + { + await AssertStartupFailureSurvivesCleanupAsync(observerThrows: false); + await AssertStartupFailureSurvivesCleanupAsync(observerThrows: true); + await using var unavailable = new UnavailableTenantIssuer(); + using var authority = new KeycloakTls(); + await using var native = await BffNativeDriver.StartAsync(RepositoryRoot(), NoNetworkConfiguration(unavailable, authority), heldBuild: true); + var root = native.TemporaryRoot; + Directory.EnumerateFileSystemEntries(root).Should().NotBeEmpty("the owned build is held before native readiness"); + await native.DisposeAsync(); + Directory.Exists(root).Should().BeFalse("EOF must cancel acquisition without waiting for the suspended command iterator"); + unavailable.Connections.Should().Be(0); + } + + private static async Task AssertStartupFailureSurvivesCleanupAsync(bool observerThrows) + { + var root = Directory.CreateTempSubdirectory("learnstack-bff-start-counter-"); + var receiptPath = Path.Combine(root.FullName, "owned.json"); + string? retained = null; + var childAbsenceConfirmed = false; + try + { + var scripts = Path.Combine(root.FullName, "frontend/apps/web/scripts"); + Directory.CreateDirectory(scripts); + await File.WriteAllTextAsync(Path.Combine(scripts, "verify-bff-session.mjs"), $$""" + import { mkdirSync, writeFileSync } from 'node:fs'; + import { join } from 'node:path'; + process.stdin.once('data', () => { + mkdirSync(join(process.env.TMPDIR, 'retained')); + writeFileSync({{JsonSerializer.Serialize(receiptPath)}}, JSON.stringify({ pid: process.pid, temporary: process.env.TMPDIR })); + process.stderr.write('BFF session fixture failed: compile\nBFF session cleanup failed.\n'); + process.stdin.destroy(); + process.exitCode = 1; + }); + """); + var observed = 0; + Exception? failure = null; + try + { + await BffNativeDriver.StartAsync(root.FullName, new { protocol = 1 }, + secondaryCleanupFailure: () => + { + observed++; + if (observerThrows) throw new InvalidOperationException("Deliberate closed observer failure."); + }); + } + catch (Exception error) when (error.GetType() != typeof(OutOfMemoryException) + && error.GetType() != typeof(StackOverflowException)) + { failure = error; } + using var receipt = JsonDocument.Parse(await File.ReadAllTextAsync(receiptPath)); + var candidate = receipt.RootElement.GetProperty("temporary").GetString()!; + Path.GetDirectoryName(candidate).Should().Be(Path.TrimEndingDirectorySeparator(Path.GetTempPath())); + Path.GetFileName(candidate).Should().StartWith("learnstack-bff-driver-"); + retained = candidate; + Directory.Exists(Path.Combine(retained, "retained")).Should().BeTrue(); + var absent = false; + try + { + using var child = Process.GetProcessById(receipt.RootElement.GetProperty("pid").GetInt32()); + absent = child.HasExited; + } + catch (ArgumentException) { absent = true; } + childAbsenceConfirmed = absent; + absent.Should().BeTrue("the failed driver still joins its actual owned child before returning the original error"); + failure.Should().BeOfType().Which.Message.Should() + .Be("BFF native fixture did not reach the closed ready/compile_cleanup_failed phase."); + observed.Should().Be(1, "the actual retained-tree disposal error must be separately observed"); + } + finally + { + // Only this counter's independently witnessed retained tree is removed. + // Its presence must first make actual driver disposal fail. + if (childAbsenceConfirmed) + { + if (retained is not null && Directory.Exists(retained)) Directory.Delete(retained, recursive: true); + root.Delete(recursive: true); + } + } + } + + [Fact] + public async Task Early_parent_disposal_joins_and_removes_its_private_native_tree() + { + await using var unavailable = new UnavailableTenantIssuer(); + using var authority = new KeycloakTls(); + await using var native = await BffNativeDriver.StartAsync(RepositoryRoot(), NoNetworkConfiguration(unavailable, authority)); + var root = native.TemporaryRoot; + Directory.Exists(root).Should().BeTrue(); + await native.DisposeAsync(); + Directory.Exists(root).Should().BeFalse("closed parent input must let Node join and remove its actual private tree"); + unavailable.Connections.Should().Be(0); + } + + [Fact] + public async Task Nonzero_native_shutdown_is_rejected_instead_of_publishing_success() + { + await using var unavailable = new UnavailableTenantIssuer(); + using var authority = new KeycloakTls(); + await using var native = await BffNativeDriver.StartAsync(RepositoryRoot(), NoNetworkConfiguration(unavailable, authority), shutdownCounter: true); + var root = native.TemporaryRoot; + await FluentActions.Awaiting(native.CompleteAsync).Should().ThrowAsync() + .WithMessage("BFF native fixture did not reach the closed complete/native-stop phase."); + await native.DisposeAsync(); + Directory.Exists(root).Should().BeFalse(); + unavailable.Connections.Should().Be(0); + } + + [Fact] + public async Task Enabled_native_startup_and_shutdown_make_no_provider_or_API_connection() + { + await using var unavailable = new UnavailableTenantIssuer(); + using var authority = new KeycloakTls(); + await using var native = await BffNativeDriver.StartAsync(RepositoryRoot(), new + { + protocol = 1, + mode = "real", + issuer = unavailable.Issuer, + clientSecret = KeycloakFixture.WebClientSecret, + authority = authority.RootCertificate.ExportCertificatePem(), + apiOrigin = new UriBuilder("http", "127.0.0.1", new Uri(unavailable.Issuer).Port).Uri.GetLeftPart(UriPartial.Authority), + secret = Convert.ToHexString(RandomNumberGenerator.GetBytes(24)).ToLowerInvariant(), + sites = SeedData.All.Select(site => new + { + host = site.Host + ":3000", + displayName = site.DisplayName, + locale = site.Curriculum!.Locales.Single(locale => locale.IsDefault).Locale + }), + }); + await native.CompleteAsync(); + unavailable.Connections.Should().Be(0, "enabled native construction and successful joined stop must not discover the issuer or bootstrap an unsolicited request"); + } + + /// Catalogue: Bff_Host_Cookies_And_History_Are_Isolated (ADR-0070). + [Fact] + public async Task Native_https_browser_stores_host_cookies_and_revalidates_logout_history_with_a_live_counter_control() + { + async Task ClosedErrorAsync(string text) + { + using var bytes = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(text)); + using var reader = new StreamReader(bytes); + return await BffNativeDriver.ReadErrorsAsync(reader, CancellationToken.None); + } + (await ClosedErrorAsync("BFF browser fixture failed: browser-launch\n")).Should().Be("browser-launch"); + (await ClosedErrorAsync("BFF browser fixture failed: browser-launch\nBFF browser cleanup failed.\n")) + .Should().Be("browser-launch_cleanup_failed"); + foreach (var stage in new[] { "browser-executable", "browser-spawn", "browser-devtools", "browser-devtools-inactive", + "browser-devtools-unreadable", "browser-devtools-missing", "browser-devtools-empty", "browser-devtools-malformed", + "browser-not-live", "browser-connect", "browser-context" }) + { + (await ClosedErrorAsync("BFF browser fixture failed: " + stage + "\n")).Should().Be(stage); + (await ClosedErrorAsync("BFF browser fixture failed: " + stage + "\nBFF browser cleanup failed.\n")) + .Should().Be(stage + "_cleanup_failed"); + } + (await ClosedErrorAsync("BFF browser fixture failed: browser-launch\nPRIVATE-CANARY\n")) + .Should().Be("browser-launch_unexpected_output"); + (await ClosedErrorAsync("BFF session fixture failed: native-stop\nBFF session cleanup failed.\n")) + .Should().Be("native-stop_cleanup_failed"); + (await ClosedErrorAsync("BFF session fixture failed: native-stop\nBFF browser cleanup failed.\n")) + .Should().Be("native-stop_unexpected_output"); + var input = new + { + protocol = 1, + sites = SeedData.All.Select(site => new + { + host = site.Host + ":3000", + displayName = site.DisplayName, + locale = site.Curriculum!.Locales.Single(locale => locale.IsDefault).Locale, + }) + }; + var timer = Stopwatch.StartNew(); + async Task JourneyAsync(bool counter) + { + var journey = counter ? "counter" : "positive"; + try + { + return await BffNativeDriver.BrowserAsync(RepositoryRoot(), input, counter, + () => output.WriteLine("BFF browser cleanup;journey=" + journey + ";state=failed.")); + } + catch (Exception error) when (error.GetType() != typeof(OutOfMemoryException) + && error.GetType() != typeof(StackOverflowException)) + { + try { output.WriteLine("BFF browser failure;journey=" + journey + "."); } + catch (Exception observation) when (observation.GetType() != typeof(OutOfMemoryException) + && observation.GetType() != typeof(StackOverflowException)) + { } + throw; + } + } + var positive = await JourneyAsync(counter: false); + var counter = await JourneyAsync(counter: true); + counter.Version.Should().Be(positive.Version); + output.WriteLine("Actual native HTTPS browser: stock Chromium={0}; hosts=2; cookie/send/isolation/keyboard/extension/back=true; natural persisted restores={1}; restoration counter killed at {2}; total including joined cleanup={3:F3}s.", + positive.Version, positive.NaturalRestores, counter.CounterWitness, timer.Elapsed.TotalSeconds); + output.WriteLine("This is fake-grant/API browser proof, separate from the actual warm-provider/application-role HTTP chain; the explicit event is not labelled a natural BFCache restore."); + } + + [Fact] + public async Task Reconciled_warm_realm_serves_both_native_hosts_with_real_refresh_logout_and_persisted_events() + { + var root = RepositoryRoot(); + var historical = JsonNode.Parse(await TenantRealmStockBaselineTests.HistoricalImportAsync(root))!.AsObject(); + var passwords = historical["users"]!.AsArray().ToDictionary(user => user!["username"]!.GetValue(), + user => user!["credentials"]![0]!["value"]!.GetValue(), StringComparer.Ordinal); + var journey = Stopwatch.StartNew(); + await using var provider = await KeycloakFixture.StartAsync(historical.ToJsonString()); + var resources = await provider.StockProviderResourcesAsync(); + try + { + var reconciled = await KeycloakReconciliationDriver.RunAsync(provider); + reconciled.Success.Should().BeTrue("closed warm qualification: {0}/{1}", reconciled.Stage, reconciled.Reason); + reconciled.PreparationDispatched.Should().BeTrue(); + reconciled.Mutations.Should().BeGreaterThan(0); + var repeated = await KeycloakReconciliationDriver.RunAsync(provider); + repeated.Success.Should().BeTrue(); + repeated.Mutations.Should().Be(0); + using var admin = await provider.CreateAdminClientAsync(); + var events = await admin.GetAsync("events/config"); + events.GetProperty("eventsEnabled").GetBoolean().Should().BeTrue(); + events.GetProperty("eventsExpiration").GetInt64().Should().Be(604800); + var auditBefore = await AuditCountsAsync(); + foreach (var tenant in SeedData.All) + { + var identity = SeedData.IdentityUsers.First(user => user.Tenant == tenant); + var password = passwords.GetValueOrDefault(identity.Username) ?? KeycloakFixture.DemoPassword; + var secret = Convert.ToHexString(RandomNumberGenerator.GetBytes(24)).ToLowerInvariant(); + await using var api = StartApi(secret, provider); + var readsBefore = database.Observation.Reads; + var started = Stopwatch.StartNew(); + await using var native = await BffNativeDriver.StartAsync(root, new + { + protocol = 1, + mode = "real", + issuer = provider.Issuer, + clientSecret = KeycloakFixture.WebClientSecret, + authority = provider.RootCertificate.ExportCertificatePem(), + apiOrigin = api.Origin, + secret, + sites = SeedData.All.Select(site => new + { + host = site.Host + ":3000", + displayName = site.DisplayName, + locale = site.Curriculum!.Locales.Single(locale => locale.IsDefault).Locale + }), + }); + using var browser = provider.Browser(); + var user = await admin.UserAsync(identity.Username); + var userPath = "users/" + user.GetProperty("id").GetString(); + var cookies = new BffWireCookies(); + var session = await LoginAsync(native, browser, identity, password, cookies, api.Observed); + session.GetProperty("expiresAt").GetDouble().Should().BeGreaterThan(DateTimeOffset.UtcNow.AddMinutes(20).ToUnixTimeMilliseconds(), + "the ordinary configured provider idle is used before the separate clamp variant"); + var admitted = await native.RequestAsync(Host(tenant), "/studio", cookies.Header); + admitted.Status.Should().Be(200); + admitted.Rendered.Should().BeTrue(); + admitted.PrivateFree.Should().BeTrue(); + admitted.Cache.Should().Contain("no-store"); + var priorReads = api.Observed.Reads.Count(row => row.Actor is not null); + var other = SeedData.All.Single(site => site != tenant); + var cross = await native.RequestAsync(Host(other), "/studio", cookies.Header); + cross.Status.Should().Be(303); + cross.Location.Should().Be("https://" + Host(other) + "/api/auth/signin?returnTo=%2Fstudio"); + cross.Cookies.Should().BeEmpty("a copied host cookie cannot authorize edits on the other host"); + cross.PrivateFree.Should().BeTrue(); + cross.Cache.Should().Contain("no-store"); + api.Observed.Reads.Count(row => row.Actor is not null).Should().Be(priorReads, + "copied HTTP cookies on another host cannot reach an authenticated AccountSite reader"); + var initialProviderSession = await admin.GetAsync(userPath + "/sessions"); + initialProviderSession.GetArrayLength().Should().Be(1); + await WaitForProviderClockAsync(provider, initialProviderSession[0].GetProperty("lastAccess").GetInt64() + 1000); + var extended = await ExtendAsync(native, tenant, cookies, session, api.Observed); + extended.GetProperty("expiresAt").GetDouble().Should().BeGreaterThan(session.GetProperty("expiresAt").GetDouble(), + "an explicit fresh-access extension must actually renew provider idle"); + var beforeLogoutCookies = cookies.Header; + var logout = await native.RequestAsync(Host(tenant), "/api/auth/signout", beforeLogoutCookies, "POST", + Form("csrfToken", extended.GetProperty("csrfToken").GetString()!)); + logout.Status.Should().Be(303); + await AnonymousAsync(native, tenant, beforeLogoutCookies); + cookies.Apply(logout); + var returned = await browser.CompleteBffLogoutAsync(PrivateLocation(logout)); + var completion = await native.RequestAsync(Host(tenant), returned.PathAndQuery, cookies.Header); + completion.Status.Should().Be(303); + cookies.Apply(completion); + await AnonymousAsync(native, tenant, cookies); + (await admin.GetAsync(userPath + "/sessions")).GetArrayLength().Should().Be(0, + "actual provider logout ends the corresponding provider SSO session"); + + // Kill only this fixture user's provider session; BFF alone retains + // its refresh credential and must encounter the actual provider error. + using var failedBrowser = provider.Browser(); + var failedCookies = new BffWireCookies(); + var failedSession = await LoginAsync(native, failedBrowser, identity, password, failedCookies, api.Observed); + var beforeFailedRefreshCookies = failedCookies.Header; + (await admin.PostAsync(userPath + "/logout", new { })).Should().Be(HttpStatusCode.NoContent); + var failed = await native.RequestAsync(Host(tenant), "/api/auth/extend", failedCookies.Header, "POST", + Form("csrfToken", failedSession.GetProperty("csrfToken").GetString()!)); + failed.Status.Should().Be(400); + failed.PrivateFree.Should().BeTrue(); + await AnonymousAsync(native, tenant, beforeFailedRefreshCookies); + failedCookies.Apply(failed); + await AnonymousAsync(native, tenant, failedCookies); + + // Case-owned real provider policy compresses time, not a fake BFF + // clock or transport. A 22-second idle leaves room around each + // 10-second step; the second refresh exceeds the 35-second maximum + // without Keycloak's clamp. Committed 1800/36000 settings stay intact. + const int clampIdleSeconds = 22; + const int clampMaximumSeconds = 35; + (await admin.PutAsync("", new { ssoSessionIdleTimeout = clampIdleSeconds, ssoSessionMaxLifespan = clampMaximumSeconds })) + .Should().Be(HttpStatusCode.NoContent); + using var maximumBrowser = provider.Browser(); + var maximumCookies = new BffWireCookies(); + var maximumSession = await LoginAsync(native, maximumBrowser, identity, password, maximumCookies, api.Observed); + var providerSessions = await admin.GetAsync(userPath + "/sessions"); + providerSessions.GetArrayLength().Should().Be(1); + var providerStart = providerSessions[0].GetProperty("start").GetInt64(); + var maximum = providerStart + clampMaximumSeconds * 1000L; + for (var round = 0; round < 2; ++round) + { + await WaitForProviderClockAsync(provider, providerStart + (round + 1) * 10_000); + var next = await ExtendAsync(native, tenant, maximumCookies, maximumSession, api.Observed); + next.GetProperty("expiresAt").GetDouble().Should().BeLessThanOrEqualTo(maximum + 1000, + "real refresh never widens the actual provider SSO maximum (integer-second rounding only)"); + next.GetProperty("expiresAt").GetDouble().Should().BeGreaterThan(maximumSession.GetProperty("expiresAt").GetDouble()); + maximumSession = next; + } + (await admin.PutAsync("", new { ssoSessionIdleTimeout = 1800, ssoSessionMaxLifespan = 36000 })) + .Should().Be(HttpStatusCode.NoContent); + (await admin.PostAsync(userPath + "/logout", new { })).Should().Be(HttpStatusCode.NoContent); + var opposite = SeedData.IdentityUsers.First(user => user.Tenant == other); + using var oppositeBrowser = provider.Browser(); + var oppositeCookies = new BffWireCookies(); + var beforeOpposite = await AuditCountsAsync(); + var beforeOppositeReads = api.Observed.Reads.Count(row => row.Actor is not null); + var oppositeCsrf = await native.RequestAsync(Host(tenant), "/api/auth/csrf", oppositeCookies.Header); + oppositeCsrf.Status.Should().Be(200); + oppositeCookies.Apply(oppositeCsrf); + var oppositeBegin = await native.RequestAsync(Host(tenant), "/api/auth/signin/keycloak", oppositeCookies.Header, "POST", + Form("csrfToken", oppositeCsrf.Data.GetProperty("csrfToken").GetString()!)); + oppositeBegin.Status.Should().Be(303); + oppositeCookies.Apply(oppositeBegin); + var oppositeCallback = await oppositeBrowser.CompleteBffLoginAsync(PrivateLocation(oppositeBegin), opposite.Username, + passwords.GetValueOrDefault(opposite.Username) ?? KeycloakFixture.DemoPassword); + var oppositeCompleted = await native.RequestAsync(Host(tenant), oppositeCallback.PathAndQuery, oppositeCookies.Header); + oppositeCompleted.Status.Should().Be(400); + oppositeCompleted.Data.ValueKind.Should().Be(JsonValueKind.Null, + "the real browser request asks for HTML; closed JSON reason mapping is covered separately"); + oppositeCompleted.Rendered.Should().BeFalse("refusal cannot render the admitted institution view"); + oppositeCompleted.Cache.Should().Contain("no-store"); + oppositeCompleted.Cookies.Should().NotContain(value => value.StartsWith("__Host-ls-session=", StringComparison.Ordinal), + "the mismatched grant cannot publish a new session handle"); + oppositeCompleted.PrivateFree.Should().BeTrue(); + await AnonymousAsync(native, tenant, oppositeCookies.Header); + api.Observed.Reads.Count(row => row.Actor is not null).Should().Be(beforeOppositeReads, + "a real opposite-tenant persona is refused before the authenticated AccountSite reader and publication"); + (await AuditCountsAsync()).Should().Equal(SeedData.All.Select((site, index) => beforeOpposite[index] + (site == tenant ? 1 : 0)), + "the authenticated JWT mismatch writes exactly one security audit under the defended host tenant"); + var oppositeUser = await admin.UserAsync(opposite.Username); + (await admin.PostAsync("users/" + oppositeUser.GetProperty("id").GetString() + "/logout", new { })) + .Should().Be(HttpStatusCode.NoContent); + var authenticatedReads = api.Observed.Reads.Where(row => row.Actor is not null).ToArray(); + authenticatedReads.Length.Should().Be(7, "three actual grants, three successful refreshes and the protected document read each require their own AccountSite witness"); + authenticatedReads.All(row => row.Actor == identity.UserId.Value && row.Tenant == tenant.TenantId.Value + && row.HostTenant == tenant.TenantId.Value + && row.Organization == (tenant.MapHostToDefaultOrganization ? tenant.DefaultOrganization.OrganizationId.Value : null)) + .Should().BeTrue("every BFF witness uses actual JWT actor and independently resolved host/organization"); + database.Observation.Reads.Should().BeGreaterThan(readsBefore, + "the retained physical ReadOnly application-role observer actually ran"); + await native.CompleteAsync(); + output.WriteLine("Real warm BFF host completed: locale={0}; authenticated witnesses={1}; native lifecycle={2:F3}s.", + tenant.Curriculum!.Locales.Single(locale => locale.IsDefault).Locale, authenticatedReads.Length, started.Elapsed.TotalSeconds); + } + foreach (var type in new[] { "LOGIN", "CODE_TO_TOKEN", "REFRESH_TOKEN", "REFRESH_TOKEN_ERROR", "LOGOUT" }) + { + var stored = await admin.GetAsync("events?max=100&type=" + type); + stored.EnumerateArray().Count(row => row.GetProperty("type").GetString() == type).Should().BeGreaterThan(0, + "the actual BFF journey persists provider event type {0}", type); + } + (await AuditCountsAsync()).Should().Equal(auditBefore.Select(count => count + 1), + "only the one deliberately mismatched persona per host adds a security audit; query Off and provider-event ownership add none"); + output.WriteLine("Real warm BFF proof: hosts=2; real refresh/maximum/logout/error/events=true; zero-write rerun=true; fixture={0:F3}s; journey={1:F3}s.", + provider.StartupElapsed.TotalSeconds, journey.Elapsed.TotalSeconds); + } + finally + { + var cleanup = Stopwatch.StartNew(); + await provider.DisposeAsync(); + await KeycloakFixture.AssertStockResourcesAbsentAsync(resources); + output.WriteLine("BFF provider joined cleanup={0:F3}s; two owned containers and network independently absent=true.", cleanup.Elapsed.TotalSeconds); + } + } + + private static async Task LoginAsync(BffNativeDriver native, KeycloakBrowserFlow browser, + SeedIdentity identity, string password, BffWireCookies cookies, TenantRealmApiObservation observation) + { + var before = observation.Reads.Count(row => row.Actor is not null); + var host = Host(identity.Tenant); + var csrf = await native.RequestAsync(host, "/api/auth/csrf", cookies.Header); + csrf.Status.Should().Be(200); + cookies.Apply(csrf); + var begin = await native.RequestAsync(host, "/api/auth/signin/keycloak", cookies.Header, "POST", + Form("csrfToken", csrf.Data.GetProperty("csrfToken").GetString()!) + "&returnTo=%2Fstudio"); + begin.Status.Should().Be(303); + cookies.Apply(begin); + var callback = await browser.CompleteBffLoginAsync(PrivateLocation(begin), identity.Username, password); + var completed = await native.RequestAsync(host, callback.PathAndQuery, cookies.Header); + completed.Status.Should().Be(303); + completed.PrivateFree.Should().BeTrue(); + cookies.Apply(completed); + var session = await native.RequestAsync(host, "/api/auth/session", cookies.Header); + session.Status.Should().Be(200); + session.PrivateFree.Should().BeTrue(); + (session.Data.GetProperty("state").GetString() == "authenticated").Should().BeTrue(); + observation.Reads.Count(row => row.Actor is not null).Should().Be(before + 1, "each actual grant obtains a new API authority witness before publication"); + return session.Data; + } + + private static async Task ExtendAsync(BffNativeDriver native, SeedTenant tenant, BffWireCookies cookies, JsonElement session, TenantRealmApiObservation observation) + { + var before = observation.Reads.Count(row => row.Actor is not null); + var response = await native.RequestAsync(Host(tenant), "/api/auth/extend", cookies.Header, "POST", + Form("csrfToken", session.GetProperty("csrfToken").GetString()!)); + response.Status.Should().Be(200); + response.PrivateFree.Should().BeTrue(); + cookies.Apply(response); + (response.Data.GetProperty("state").GetString() == "authenticated").Should().BeTrue(); + observation.Reads.Count(row => row.Actor is not null).Should().Be(before + 1, "each actual refresh revalidates API authority before publishing its revision"); + return response.Data; + } + + private static async Task AnonymousAsync(BffNativeDriver native, SeedTenant tenant, BffWireCookies cookies) + => await AnonymousAsync(native, tenant, cookies.Header); + + private static async Task AnonymousAsync(BffNativeDriver native, SeedTenant tenant, string originalCookies) + { + var response = await native.RequestAsync(Host(tenant), "/api/auth/session", originalCookies); + response.Status.Should().Be(200); + response.PrivateFree.Should().BeTrue(); + (response.Data.GetProperty("state").GetString() == "anonymous").Should().BeTrue(); + } + + private static async Task WaitForProviderClockAsync(KeycloakFixture provider, long targetMilliseconds) + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + while (true) + { + if (await provider.ObserveProviderUtcAsync(deadline.Token) >= targetMilliseconds) return; + await Task.Delay(TimeSpan.FromSeconds(1), deadline.Token); + } + } + + private ApiHost StartApi(string secret, KeycloakFixture provider) + { + var observation = new TenantRealmApiObservation(); + var factory = database.WithWebHostBuilder(builder => + { + builder.UseSetting("Tenancy:TrustedHop:Networks:0", "127.0.0.1/32"); + builder.UseSetting("Tenancy:TrustedHop:Secrets:0", secret); + builder.UseSetting(TenantAuthenticationOptions.IssuerConfigurationKey, provider.Issuer); + builder.ConfigureTestServices(services => + { + services.Replace(ServiceDescriptor.Singleton(_ => new TenantAuthenticationBackchannel(KeycloakHttp.CreateHandler(provider.RootCertificate)))); + services.AddScoped(); + services.AddScoped(scope => new TenantRealmContextReader( + scope.GetRequiredService(), scope.GetRequiredService(), + scope.GetRequiredService(), 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); + } + + private async Task AuditCountsAsync() => [await database.AuditCountAsync(SeedData.English), await database.AuditCountAsync(SeedData.Yoga)]; + private static object NoNetworkConfiguration(UnavailableTenantIssuer unavailable, KeycloakTls authority) => new + { + protocol = 1, + mode = "real", + issuer = unavailable.Issuer, + clientSecret = KeycloakFixture.WebClientSecret, + authority = authority.RootCertificate.ExportCertificatePem(), + apiOrigin = new UriBuilder("http", "127.0.0.1", new Uri(unavailable.Issuer).Port).Uri.GetLeftPart(UriPartial.Authority), + secret = Convert.ToHexString(RandomNumberGenerator.GetBytes(24)).ToLowerInvariant(), + sites = SeedData.All.Select(site => new + { + host = site.Host + ":3000", + displayName = site.DisplayName, + locale = site.Curriculum!.Locales.Single(locale => locale.IsDefault).Locale + }), + }; + private static string Host(SeedTenant tenant) => tenant.Host + ":3000"; + private static string Form(string key, string value) => key + "=" + Uri.EscapeDataString(value); + private static Uri PrivateLocation(BffWireResponse response) => Uri.TryCreate(response.Location, UriKind.Absolute, out var uri) + ? uri : throw new InvalidOperationException("BFF fixture lacks its expected private redirect."); + internal 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 BFF session fixture source."); + } + private sealed record ApiHost(WebApplicationFactory Factory, HttpClient Client, string Origin, + TenantRealmApiObservation Observed) : IAsyncDisposable + { + public async ValueTask DisposeAsync() { Client.Dispose(); await Factory.DisposeAsync(); } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTests.WarmQualification.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTests.WarmQualification.cs index b47cbb2c..09d984f5 100644 --- a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTests.WarmQualification.cs +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmApiTests.WarmQualification.cs @@ -93,7 +93,7 @@ public async Task Historical_automatic_upgrade_preserves_live_password_and_MFA_t var first = await KeycloakReconciliationDriver.RunAsync(warm); first.Success.Should().BeTrue("closed historical reconciliation outcome: {0}/{1}", first.Stage, first.Reason); first.PreparationDispatched.Should().BeTrue(); - first.Mutations.Should().Be(17, "one preparation and sixteen ordinary writes; the eligible unrelated account needs no grant"); + first.Mutations.Should().Be(18, "one preparation, sixteen prior ordinary writes and one ADR-0070 event-policy PUT; the unrelated account needs no grant"); output.WriteLine("Historical journey stage: automatic reconciliation completed."); var accountsAfter = await WarmAccountsAsync(admin, usernames); WarmSame(expectedProfile, WarmNode(await admin.GetAsync("users/profile"))).Should().BeTrue( diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmEventTests.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmEventTests.cs new file mode 100644 index 00000000..28020226 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmEventTests.cs @@ -0,0 +1,114 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentAssertions; +using LearnStack.Tests.Integration.Database; +using LearnStack.Tools.Seeder; +using Xunit; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +/// Actual pinned provider storage, separate from a durable LearnStack session ledger. +[Collection(SharedKeycloakRealm.Name)] +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed class TenantRealmEventTests(KeycloakFixture fresh) +{ + private static readonly string[] Required = + ["LOGIN", "LOGIN_ERROR", "LOGOUT", "LOGOUT_ERROR", "CODE_TO_TOKEN", "CODE_TO_TOKEN_ERROR", "REFRESH_TOKEN", "REFRESH_TOKEN_ERROR"]; + + [Fact] + public async Task Fresh_provider_stores_real_login_code_refresh_and_refresh_error_events() + { + using var admin = await fresh.CreateAdminClientAsync(); + var configuration = await admin.GetAsync("events/config"); + configuration.GetProperty("eventsEnabled").GetBoolean().Should().BeTrue(); + configuration.GetProperty("eventsExpiration").GetInt64().Should().Be(604800); + Types(configuration).Should().Contain(Required); + Types(configuration).Should().Contain(TenantRealmExportTests.DefaultSavedEvents, + "every pinned default-saved security type remains selected"); + + using var browser = fresh.Browser(); + var declaration = SeedData.IdentityUsers[0]; + var authorization = await browser.AuthorizeAsync(declaration.Username, KeycloakFixture.DemoPassword, Callback(declaration)); + authorization.Authorized.Should().BeTrue(); + var exchange = await browser.RedeemAsync(authorization); + exchange.StatusCode.Should().Be(HttpStatusCode.OK); + var refreshed = await browser.RequestTokenAsync(new Dictionary + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = exchange.RefreshToken, + ["client_id"] = "learnstack-web", + ["client_secret"] = KeycloakFixture.WebClientSecret, + }); + refreshed.StatusCode.Should().Be(HttpStatusCode.OK); + var refused = await browser.RequestTokenAsync(new Dictionary + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = "owned-invalid-fixture-refresh", + ["client_id"] = "learnstack-web", + ["client_secret"] = KeycloakFixture.WebClientSecret, + }); + refused.Error.Should().Be("invalid_grant"); + foreach (var type in new[] { "LOGIN", "CODE_TO_TOKEN", "REFRESH_TOKEN", "REFRESH_TOKEN_ERROR" }) + { + // Assert only the bounded closed type/count; event identity/IP details never become assertion output. + var events = await admin.GetAsync("events?max=100&type=" + type); + events.EnumerateArray().Count(value => value.GetProperty("type").GetString() == type).Should().BeGreaterThan(0, + "the enabled event type {0} must have an actual stored provider row", type); + } + } + + [Fact] + public async Task Warm_empty_default_policy_converges_preserves_custom_selection_and_refuses_existing_indefinite_history() + { + var realm = JsonNode.Parse(await File.ReadAllTextAsync(KeycloakFixture.RealmPath("learnstack")))!.AsObject(); + realm["eventsEnabled"] = false; + realm["eventsExpiration"] = 0; + realm["enabledEventTypes"] = new JsonArray("LOGIN", "UPDATE_PROFILE"); + await using var fixture = await KeycloakFixture.StartAsync(realm.ToJsonString()); + using var admin = await fixture.CreateAdminClientAsync(); + var before = await admin.GetAsync("events/config"); + var profile = (await admin.GetAsync("users/profile")).GetRawText(); + (await admin.GetAsync("events?first=0&max=1")).GetArrayLength().Should().Be(0); + var outcome = await KeycloakReconciliationDriver.RunAsync(fixture); + outcome.Success.Should().BeTrue("qualified warm event policy converges: {0}/{1}", outcome.Stage, outcome.Reason); + outcome.Mutations.Should().Be(1); + var after = await admin.GetAsync("events/config"); + after.GetProperty("eventsEnabled").GetBoolean().Should().BeTrue(); + after.GetProperty("eventsExpiration").GetInt64().Should().Be(604800); + Types(after).Should().BeEquivalentTo(Types(before).Union(Required, StringComparer.Ordinal), + "reconciliation adds only the required types and preserves the exact prior selection"); + foreach (var field in new[] { "eventsListeners", "adminEventsEnabled", "adminEventsDetailsEnabled" }) + after.GetProperty(field).GetRawText().Should().Be(before.GetProperty(field).GetRawText()); + (await admin.GetAsync("users/profile")).GetRawText().Should().Be(profile); + var repeated = await KeycloakReconciliationDriver.RunAsync(fixture); + repeated.Success.Should().BeTrue(); + repeated.Mutations.Should().Be(0); + + using var browser = fixture.Browser(); + var user = SeedData.IdentityUsers[0]; + var authorization = await browser.AuthorizeAsync(user.Username, KeycloakFixture.DemoPassword, Callback(user)); + authorization.Authorized.Should().BeTrue(); + (await browser.RedeemAsync(authorization)).StatusCode.Should().Be(HttpStatusCode.OK); + (await admin.GetAsync("events?first=0&max=1")).GetArrayLength().Should().Be(1); + var indefinite = JsonNode.Parse(after.GetRawText())!.AsObject(); + indefinite["eventsEnabled"] = false; + indefinite["eventsExpiration"] = 0; + (await admin.PutAsync("events/config", indefinite)).Should().Be(HttpStatusCode.NoContent); + var unchanged = (await admin.GetAsync("events/config")).GetRawText(); + var refusal = await KeycloakReconciliationDriver.RunAsync(fixture); + refusal.Success.Should().BeFalse(); + refusal.Stage.Should().Be("plan"); + refusal.Reason.Should().Be("event_policy_conflict"); + refusal.Mutations.Should().Be(0); + (await admin.GetAsync("events/config")).GetRawText().Should().Be(unchanged); + (await admin.GetAsync("events?first=0&max=1")).GetArrayLength().Should().Be(1, + "refusal never deletes prior history or invents a safe retention window"); + } + + private static string Callback(SeedIdentity identity) => + "https://" + identity.Tenant.Host + ":3000/api/auth/callback/keycloak"; + + private static string[] Types(JsonElement configuration) => configuration.GetProperty("enabledEventTypes") + .EnumerateArray().Select(value => value.GetString()!).ToArray(); +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmExportTests.Events.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmExportTests.Events.cs new file mode 100644 index 00000000..b45d0014 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmExportTests.Events.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Nodes; +using FluentAssertions; +using Xunit; + +namespace LearnStack.Tests.Integration.TenancyIdentity; + +public sealed partial class TenantRealmExportTests +{ + // Independent observation of pinned EventType.saveByDefault, not derived from our export. + internal static readonly string[] DefaultSavedEvents = Strings(JsonNode.Parse(File.ReadAllText(RepositoryFile( + Path.Combine("backend", "tests", "LearnStack.Tests.Integration", "TenancyIdentity", "Fixtures", + "keycloak-26.6.2-default-events.json"))))!["defaultSaved"]); + + [Fact] + public void Every_pinned_default_saved_event_is_preserved_and_its_omission_is_detected() + { + DefaultSavedEvents.Should().HaveCount(103).And.OnlyHaveUniqueItems(); + var original = ReadRealm(); + Violations(original).Should().BeEmpty(); + foreach (var name in DefaultSavedEvents) + { + var variant = original.DeepClone().AsObject(); + var types = variant["enabledEventTypes"]!.AsArray(); + types.Remove(types.Single(value => value!.GetValue() == name)); + Violations(variant).Should().Contain("complete pinned default-saved event selection", + "removing default-saved type {0} must be independently detected", name); + } + Violations(original).Should().BeEmpty("planted omissions never alter the committed declaration"); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmExportTests.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmExportTests.cs index a31142ec..c6d47f6b 100644 --- a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmExportTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmExportTests.cs @@ -49,6 +49,8 @@ public void Identity_manifest_rejects_reassignment_legacy_expansion_and_nonstabl private static readonly string[] ReservedClaims = ["iss", "sub", "aud", "azp", "typ", "scope", "iat", "exp", "nbf"]; + private static readonly string[] RequiredDevelopmentEvents = + ["LOGIN", "LOGIN_ERROR", "LOGOUT", "LOGOUT_ERROR", "CODE_TO_TOKEN", "CODE_TO_TOKEN_ERROR", "REFRESH_TOKEN", "REFRESH_TOKEN_ERROR"]; private static readonly string[] StockAccountRoles = ["view-profile", "manage-account", "manage-account-links", "view-applications", "view-consent", "manage-consent", "delete-account", "view-groups"]; private static readonly string[] StockDefaultAccountRoles = ["view-profile", "manage-account"]; @@ -88,6 +90,10 @@ public void Export_controls_reject_unsafe_mapper_profile_client_and_identity_var var original = ReadRealm(); var mutations = new List<(string Name, Action Apply)> { + ("disabled event storage", realm => realm["eventsEnabled"] = false), + ("short event retention", realm => realm["eventsExpiration"] = 60), + ("indefinite event retention", realm => realm["eventsExpiration"] = 0), + ("missing refresh error storage", realm => realm["enabledEventTypes"] = new JsonArray("LOGIN", "LOGOUT")), ("wrong realm TTL", realm => realm["accessTokenLifespan"] = 1800), ("unmanaged profile enabled", realm => SetProfilePolicy(realm, "ENABLED")), ("malformed profile configuration", realm => ProfileValue(realm)[0] = "{"), @@ -188,6 +194,14 @@ internal static IReadOnlyList Violations(JsonObject realm) { Require(Text(realm["realm"]) == "learnstack", "tenant realm"); Require(realm["accessTokenLifespan"]?.GetValue() == 900, "realm lifespan"); + Require(realm["eventsEnabled"]?.GetValue() == true, "development user-event storage"); + Require(realm["eventsExpiration"]?.GetValue() == 604800, "seven-day development retention"); + var eventTypes = Strings(realm["enabledEventTypes"]); + Require(RequiredDevelopmentEvents.All(value => eventTypes.Contains(value, StringComparer.Ordinal)), + "explicit required authentication events including default-unsaved refresh"); + Require(eventTypes.Distinct(StringComparer.Ordinal).Count() == eventTypes.Length, "unique saved event types"); + Require(DefaultSavedEvents.All(value => eventTypes.Contains(value, StringComparer.Ordinal)), + "complete pinned default-saved event selection"); ValidateAccountDeclaration(realm, Require); var scopeNames = Scopes(realm).Select(scope => Text(scope?["name"])).ToArray(); Require(scopeNames.Distinct(StringComparer.Ordinal).Count() == scopeNames.Length, "unique scope names"); @@ -433,15 +447,18 @@ private static void ChangeProfile(JsonObject realm, string name, string permissi ProfileValue(realm)[0] = profile.ToJsonString(); } - private static JsonObject ReadRealm() + private static JsonObject ReadRealm() => JsonNode.Parse(File.ReadAllText(RepositoryFile( + Path.Combine("infra", "keycloak", "realms", "learnstack.json"))))!.AsObject(); + + private static string RepositoryFile(string relative) { var directory = new DirectoryInfo(AppContext.BaseDirectory); while (directory is not null) { - var path = Path.Combine(directory.FullName, "infra", "keycloak", "realms", "learnstack.json"); - if (File.Exists(path)) return JsonNode.Parse(File.ReadAllText(path))!.AsObject(); + var path = Path.Combine(directory.FullName, relative); + if (File.Exists(path)) return path; directory = directory.Parent; } - throw new InvalidOperationException("Repository realm export is missing."); + throw new InvalidOperationException("Repository proof input is missing."); } } diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmReconciliationTests.Preparation.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmReconciliationTests.Preparation.cs index df4389f4..6a871128 100644 --- a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmReconciliationTests.Preparation.cs +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmReconciliationTests.Preparation.cs @@ -13,7 +13,7 @@ public sealed partial class TenantRealmReconciliationTests private static readonly string[] ProtectedPreparationNames = ["user_id", "tenant_id", "organization_id"]; [Fact] - public Task Historical_stock_upgrade_automatically_prepares_once_then_converges_with_seventeen_writes() => + public Task Historical_stock_upgrade_prepares_once_and_counts_the_named_event_policy_write() => WithHistoricalPreparationAsync(async (fixture, admin) => { var profileBefore = await admin.GetAsync("users/profile"); @@ -38,7 +38,7 @@ public Task Historical_stock_upgrade_automatically_prepares_once_then_converges_ var first = await KeycloakReconciliationDriver.RunAsync(fixture); first.Success.Should().BeTrue("the automatic historical path reports {0}/{1}", first.Stage, first.Reason); first.PreparationDispatched.Should().BeTrue(); - first.Mutations.Should().Be(17, "one metadata preparation precedes sixteen ordinary stock-upgrade writes"); + first.Mutations.Should().Be(18, "one preparation, sixteen prior stock-upgrade writes and one ADR-0070 event-policy PUT"); AssertPreparationProfile(expectedProfile, await admin.GetAsync("users/profile")); foreach (var identity in SeedData.IdentityUsers) await AssertStoredIdentityAsync(admin, identity); foreach (var (username, original) in originalUsers) @@ -141,7 +141,7 @@ public Task Interrupted_historical_preparation_counts_the_dispatched_write_and_r var resumed = await KeycloakReconciliationDriver.RunAsync(fixture); resumed.Success.Should().BeTrue("an explicit fresh invocation reports {0}/{1}", resumed.Stage, resumed.Reason); - resumed.Mutations.Should().Be(16, "the durable preparation is reused without a second metadata preparation"); + resumed.Mutations.Should().Be(17, "the durable preparation is reused; sixteen prior stock writes and one event-policy PUT remain"); resumed.PreparationDispatched.Should().BeFalse(); var completed = await KeycloakReconciliationDriver.RunAsync(fixture); completed.Success.Should().BeTrue(); @@ -177,7 +177,7 @@ public Task Partial_admin_view_only_definition_is_preserved_by_preparation_then_ var resumed = await KeycloakReconciliationDriver.RunAsync(fixture); resumed.Success.Should().BeTrue("the separate ordinary plan reports {0}/{1}", resumed.Stage, resumed.Reason); resumed.PreparationDispatched.Should().BeFalse(); - resumed.Mutations.Should().Be(17, "sixteen stock-upgrade writes plus the separate approved profile-normalization PUT"); + resumed.Mutations.Should().Be(18, "sixteen prior stock writes, separate profile normalization and one event-policy PUT"); var expectedFinal = expectedPreparation.DeepClone(); var definitions = expectedFinal["attributes"]!.AsArray(); var index = definitions.IndexOf(definitions.Single(value => value!["name"]!.GetValue() == "user_id")); diff --git a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmStockBaselineTests.cs b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmStockBaselineTests.cs index 12b9ddd7..f3257dfd 100644 --- a/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmStockBaselineTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/TenancyIdentity/TenantRealmStockBaselineTests.cs @@ -169,7 +169,7 @@ public async Task Historical_stock_ordinary_reconciliation_detaches_only_three_c var first = await KeycloakReconciliationDriver.RunAsync(fixture); first.Success.Should().BeTrue("closed ordinary reconciliation outcome: {0}/{1} after {2} writes", first.Stage, first.Reason, first.Mutations); - first.Mutations.Should().Be(16, "thirteen ordinary approved mutations plus exactly three console assignment DELETEs"); + first.Mutations.Should().Be(17, "thirteen prior approved mutations, three console assignment DELETEs and one ADR-0070 event-policy PUT"); var after = await CaptureAsync(admin, historicalAssignments: false); Names(after[consolePath + "/default-client-scopes"]).Should().Equal(DefaultScopes.Where(value => value != "roles")); Names(after[consolePath + "/optional-client-scopes"]).Should().Equal(OptionalScopes.Where(value => value is not ("microprofile-jwt" or "offline_access"))); @@ -233,7 +233,7 @@ public async Task Historical_stock_ordinary_reconciliation_detaches_only_three_c var rerun = await KeycloakReconciliationDriver.RunAsync(fixture); rerun.Success.Should().BeTrue("closed rerun outcome: {0}/{1}", rerun.Stage, rerun.Reason); rerun.Mutations.Should().Be(0); - output.WriteLine("Historical ordinary stock path: 16 mutations, then zero; retained five default/three optional edges; elapsed={0:F3}s.", watch.Elapsed.TotalSeconds); + output.WriteLine("Historical ordinary stock path: 17 mutations including event policy, then zero; retained five default/three optional edges; elapsed={0:F3}s.", watch.Elapsed.TotalSeconds); } finally { diff --git a/backend/tests/LearnStack.Tests.Integration/TenantAuthenticationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/TenantAuthenticationHttpTests.cs index dc92199a..83493a42 100644 --- a/backend/tests/LearnStack.Tests.Integration/TenantAuthenticationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/TenantAuthenticationHttpTests.cs @@ -213,21 +213,24 @@ public async Task A_trusted_signature_with_an_invalid_profile_is_401_on_an_actua issuer.JwksRequests.Should().BeGreaterThan(0); } - [Fact] - public async Task Discovery_failure_on_an_actual_public_route_is_401_and_never_anonymous() + [Theory] + [InlineData(TenantAuthenticationHttpRequest.PublicPath)] + [InlineData(TenantAuthenticationHttpRequest.AccountPath)] + public async Task Discovery_failure_on_actual_public_and_account_routes_is_401_and_never_anonymous(string path) { await using var issuer = await TenantAuthenticationIssuer.StartAsync(); issuer.DiscoveryStatusCode = 503; await using var factory = new TenantAuthenticationApiFactory(issuer); using var client = factory.CreateClient(); using var response = await TenantAuthenticationHttpRequest.Send(client, - TenantAuthenticationReconciliationHost.TenantHost, TenantAuthenticationHttpRequest.PublicPath, + TenantAuthenticationReconciliationHost.TenantHost, path, token: issuer.Token()); await Problem(response, HttpStatusCode.Unauthorized, "unauthorized"); BareBearer(response); issuer.MetadataRequests.Should().BeGreaterThan(0); factory.Recorder!.Rejections.Should().BeEmpty(); + factory.Ports.Memberships.Should().BeEmpty("discovery refusal precedes membership and controller dispatch"); } [Theory] @@ -251,6 +254,27 @@ public async Task Infrastructure_and_internal_paths_ignore_bad_credentials_witho issuer.JwksRequests.Should().Be(0); factory.Ports.HostLookups.Should().BeEmpty(); factory.Recorder!.Rejections.Should().BeEmpty(); + + // A malformed token and unknown host independently avoid discovery. + // Remove both escape hatches before qualifying the route exclusion. + var token = issuer.Token(); + using var wellFormed = await TenantAuthenticationHttpRequest.Send(client, + TenantAuthenticationReconciliationHost.TenantHost, path, token: token, + tenantAssertion: "not-a-guid", organizationAssertion: "also-not-a-guid"); + wellFormed.StatusCode.Should().Be(status); + wellFormed.Headers.WwwAuthenticate.Should().BeEmpty(); + issuer.MetadataRequests.Should().Be(0); + issuer.JwksRequests.Should().Be(0); + factory.Ports.HostLookups.Should().BeEmpty(); + factory.Recorder!.Rejections.Should().BeEmpty(); + + // The same signed credential does reach the actual discovery transport + // on an admitted public route, rather than passing through an inert spy. + using var positive = await TenantAuthenticationHttpRequest.Send(client, + TenantAuthenticationReconciliationHost.TenantHost, + TenantAuthenticationHttpRequest.PublicPath, token: token); + await Problem(positive, HttpStatusCode.Unauthorized, "unauthorized"); + issuer.MetadataRequests.Should().BeGreaterThan(0); } internal static async Task Problem(HttpResponseMessage response, HttpStatusCode status, string code) diff --git a/docs/architecture/04-technical-architecture.md b/docs/architecture/04-technical-architecture.md index 819867df..05ffca57 100644 --- a/docs/architecture/04-technical-architecture.md +++ b/docs/architecture/04-technical-architecture.md @@ -148,7 +148,9 @@ Future options (deferred): schema-per-tenant for enterprise tenants, read replic - **Idempotency keys** for `POST` operations that have external side effects (payments, webhooks, send-notification). - **Optimistic concurrency** for any mutable entity, on an explicit `row_version bigint` column ([ADR-0039](../decisions/0039-optimistic-concurrency-token.md)). `xmin` is not used as a concurrency token. - **API versioning** via URL prefix: `/api/v1/...`. Breaking changes bump to `/api/v2/...`; non-breaking additions stay on the existing version. See [ADR-0024](../decisions/0024-api-versioning-policy.md), which fixed exactly this `/v1/` vs `/api/v1/` inconsistency. -- **Authentication** via OIDC bearer tokens issued by Keycloak. Frontends use Auth.js to bridge. +- **Authentication** via OIDC bearer tokens issued by Keycloak. The native web + BFF uses openid-client and owns server-side token custody under + [ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md). - **Authorization** layered: tenant scope → role/permission → resource ownership where applicable. GraphQL is not in scope for the MVP. Revisit only when a frontend surface (e.g. studio query workload) demonstrably needs it. diff --git a/docs/architecture/13-identity-and-auth.md b/docs/architecture/13-identity-and-auth.md index b6c6e757..7dd2088f 100644 --- a/docs/architecture/13-identity-and-auth.md +++ b/docs/architecture/13-identity-and-auth.md @@ -10,7 +10,7 @@ users/memberships had not shipped. Public sites used host-based anonymous admission without forwarding cookies or credentials. [Phase 02b's entry verification](../roadmap/phase-02b-delivery-log.md#p02b-0-entry-verification-2026-10-12) records that baseline. The P02b-5/P02b-6 delivery records below supersede its API -validation and realm-issuance status. BFF sessions remain P02b-7; Phase 03 owns +validation and realm-issuance status. P02b-7 delivers BFF sessions; Phase 03 owns the application identity domain. Undelivered flows below remain target architecture. **P02b-5 decision Accepted — 2026-10-19.** @@ -22,7 +22,8 @@ review rounds are complete and merged through PR #33 on 2026-10-20; the [merge closeout](../roadmap/phase-02b-delivery-log.md#p02b-5-merge-and-closeout-2026-10-20) owns delivery, correction reviews and final-source qualification. Authentication and authorization registration activate together with the closed surface in Step 4. -P02b-6 supplies realm reconciliation as recorded below; BFF sessions remain P02b-7. +P02b-6 supplies realm reconciliation as recorded below; P02b-7 supplies the native +BFF session boundary described here. **P02b-6 decision Accepted — 2026-10-21.** The [accepted package](../roadmap/phase-02b-delivery-log.md#p02b-6-decision-package-2026-10-20) @@ -36,8 +37,9 @@ proofs after both independent review rounds and verified corrections; its [record](../roadmap/phase-02b-delivery-log.md#p02b-6-step-4--real-identity-qualification-and-closeout-2026-10-21) owns local implementation, review closure and source-bound qualification receipts. The correction record below owns exact hosted assignments; merge is separate. -G13's BFF session part -stays Open for P02b-7. The +G13's BFF session part is Accepted under +[ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md); P02b-7 delivers its +native owner, opaque cookies and provider-event persistence. The [development realm guide](../../infra/keycloak/README.md) owns exact callbacks, private sources and current upgrade status. @@ -95,6 +97,7 @@ The cost is one more service to operate. That is acceptable: self-hosted infrast |---|---|---| | User credentials (password hash, MFA factors) | yes | no | | OIDC tokens (issuance, signing keys) | yes | no | +| BFF access/refresh/ID-token custody | no | native web session owner ([ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md)) | | Federation with social/SAML/OIDC providers | yes | no | | Email verification, password reset emails | yes (or LearnStack via SMTP delegation) | no | | Global `User` record (canonical identity, email) | mirrored | source for application data | @@ -137,29 +140,37 @@ decision before its first consumer. ```mermaid sequenceDiagram participant Browser - participant Web as Next.js - participant BFF as Next.js BFF + participant Web as Native Next.js BFF participant KC as Keycloak participant API as .NET API Browser->>Web: GET /studio - Web->>Browser: redirect to KC /auth (PKCE) + Web-->>Browser: 303 /api/auth/signin?returnTo=/studio + Browser->>Web: GET /api/auth/signin?returnTo=/studio + Web-->>Browser: Same-origin sign-in form and CSRF proof + Browser->>Web: POST /api/auth/signin/keycloak (Origin + CSRF) + Web-->>Browser: 303 Keycloak authorization URL (PKCE) Browser->>KC: login form - KC->>Browser: redirect with code - Browser->>BFF: GET /api/auth/callback?code=... - BFF->>KC: POST /token (exchange) - KC-->>BFF: access_token + refresh_token + id_token - BFF->>BFF: set HttpOnly cookie 'session' - BFF->>Browser: redirect to /studio/dashboard - Browser->>BFF: GET /api/courses - BFF->>API: GET /api/v1/account/site (Bearer, verified host) - API->>API: validate JWT signature against KC JWKS - API-->>BFF: tenant-scoped response - BFF-->>Browser: response + KC-->>Browser: Redirect with bound code/state/issuer + Browser->>Web: GET /api/auth/callback/keycloak + Web->>KC: POST /token (code + PKCE verifier) + KC-->>Web: access_token + refresh_token + id_token + Web->>API: GET /api/v1/account/site (Bearer, verified host) + API->>API: Validate JWT and independent host scope + API-->>Web: Read-only AccountSite witness + Web->>Web: Retain credentials in the session store + Web-->>Browser: 303 /studio + host-only opaque cookies + Browser->>Web: GET /studio (document navigation) + Web->>API: GET /api/v1/account/site (current access token) + API-->>Web: Independently validated AccountSite + Web-->>Browser: Protected shell and safe session projection ``` Key details: +- Anonymous Studio entry uses the local sign-in form before provider navigation. + The response owner performs the exact authenticated AccountSite read; there is + no general `/api/courses` BFF proxy or `/studio/dashboard` route in this packet. - **PKCE only.** No implicit flow, no client secrets in the browser. - **Cookies, not localStorage.** Tokens never enter JavaScript; the BFF holds them. - **Short access token TTL.** [Security Standards](../standards/11-security.md#authentication) @@ -169,6 +180,31 @@ Key details: ADR-0064 bounds API acceptance; P02b-7 owns BFF refresh and session lifetimes. - **JWKS rotation.** API validates tokens against Keycloak's JWKS endpoint, with key caching and rotation handling built into the .NET OIDC middleware. +### Current BFF support boundary + +The qualified BFF environment is one native web process with the private +loopback listener and pinned tenant development realm. Its session store is +volatile; restart requires sign-in. An HTTPS issuer accepted by a production +web build does not establish production identity-event retention, proxy +provenance or deployment readiness. [Phase 11](../roadmap/phase-11-production-hardening.md) +owns that qualification and the distributed adapter before multiple web +processes serve one session. + +Source admission counts canonical verified visitor IPs, not people or IPv6 +prefixes. Multiple addresses can consume the global pending allowance; clients +sharing a reverse proxy's socket peer can share one source allowance. No +prefix fairness or general proxy deployment is established by the local proof. + +[ADR-0071](../decisions/0071-uncertain-account-site-refusals.md#decision) +owns the ordinary AccountSite status distinction. API 401 deliberately includes +undecidable authentication, so an ordinary check retains its live local owner +without idle touch while withholding authenticated content. This does not make +the token usable at the API or establish live authority in a passive projection. +Initial callback and post-refresh publication still require a positive API +witness; failed refresh validation revokes. True 401 revocation is delayed, +whereas masked 403/404 still close the owner, including configuration refusals. +The benefit ends when the existing refresh threshold requires a new grant. + ## Tenant Resolution Across Identity When a user logs in, they may have multiple memberships. HTTP tenant authority @@ -251,9 +287,16 @@ LearnStack reads the `amr` claim to know whether MFA was used; sensitive operati ## Logout -- Browser logout clears the session cookie and calls Keycloak's end-session endpoint with the `id_token_hint`. -- Keycloak revokes the refresh token and clears its SSO session. -- Other LearnStack sessions for the same user are not affected unless the user explicitly signs out everywhere. +- [ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md) revokes locally and + fences the browser epoch before provider navigation. A separate short-lived + return proof survives clearing session/sign-in cookies; tokens are discarded + after constructing the registered end-session redirect. +- Real-provider proof must show the targeted SSO session ended. Shared SSO can + invalidate another host's later refresh; independent provider sessions are not + intentionally revoked. Already issued API JWTs keep their acceptance bound. +- Local logout succeeds even during provider outage. After navigating away, + automatic return is not guaranteed. Phase 03 owns authenticated back-channel + logout before immediate disablement/sign-out-everywhere is promised. ## Audit @@ -301,7 +344,8 @@ self-service proof, with source-bound hosted assignments and evidence limits. The realm guide owns the exact local procedure and refusal boundaries. G10 is Accepted: one token carries at most one active tenant. A user in both tenants also needs `Membership`, which [Phase 03](../roadmap/phase-03-identity-admin.md) -delivers. BFF custody and sessions remain P02b-7. +delivers. Native BFF custody/session rules derive from ADR-0070; the phase Status +owns current delivery and qualification. ## Risks diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index b861db3c..f73da798 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -63,42 +63,36 @@ it is not production security/lifecycle proof or runtime delivery. ## App Shape +The tree combines current routes with target package/block locations. The notes +below distinguish delivered route groups from Phase 06/07 targets; `packages/ui` +is extracted only when duplication warrants it. + ```text frontend/ apps/ web/ # the only tenant-facing Next.js app src/ app/ - (public)/ - [tenant-by-host]/ # virtual segment, resolved in middleware - page.tsx - courses/ - blog/ - (studio)/ - login/ - dashboard/ - content/ - pages/ + (public)/[locale]/ courses/ - media/ - (portal)/ - my-courses/ - lesson/[id]/ - sessions/ - api/ # only thin BFF proxies, see "Data Fetching" + [slug]/lessons/[lessonSlug]/ + status/not-found/ + (studio)/studio/ # guarded account scaffold + (portal)/portal/ # guarded account scaffold + api/healthz/ # infrastructure GET only components/ public/ # P6 public views and states blocks/ # built-in primitive page blocks ui/ # design-system primitives - lib/ - api/ - auth/ - tenant/ + server/auth/ # native BFF owner and fixed protocol + client/bff-session.ts # safe projection/extension only i18n/ # ADR-0027 foundation implemented in P6 Step 1 request.ts messages/ en/public.json + en/auth.json tr/public.json + tr/auth.json middleware.ts # ingress verification + locale entry extensions/ # client-side block resolver, see Page Builder packages/ @@ -114,7 +108,9 @@ frontend/ > 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. +> The tree shows the current route groups. Content/media authoring, dashboards +> and enrolled-learner screens are Phase 06/07 targets. BFF auth paths are served +> by the native response owner under ADR-0070, not general `app/api` proxies. The operator portal (`operator-portal`) is a **separate Next.js application in the separate `learnstack-hub` repository**; nothing about it lives under this `frontend/` @@ -303,28 +299,34 @@ merge of tenant + optional org) more cheaply. Two paths: - **Server-side** — React Server Components and route handlers call the .NET API directly using the typed SDK in `packages/sdk`. The SDK is generated from the backend's OpenAPI document; [Standards 07 § SDK](../standards/07-frontend-architecture.md) owns when and how. -- **Client-side target** — P02b-7 supplies the thin authenticated BFF/session - transport. It keeps tokens outside browser JavaScript; any tenant header can - only assert independently resolved scope under ADR-0036. It never selects it. - Public SDK requests remain credential-free. The BFF wraps no business logic. +- **Authenticated boundary** — P02b-7 supplies native credential custody and + exact AccountSite transport under ADR-0070. Browser code receives a safe + session projection and the closed extension action, never a generic API proxy, + bearer token or tenant/organization selector. The native caller attaches its + bearer and trusted host hop; the injected SDK owns neither. Public SDK + requests remain credential-free. Phase 06 owns wider authenticated consumers. The SDK is the only sanctioned way to call the API. Hand-rolled `fetch('/api/v1/...')` calls are blocked by lint — `no-restricted-globals`, because `fetch` is a global and an import rule could never have caught a single call. ## Authentication on the Frontend -P02b-5 delivers the API boundary and a separate typed AccountSite SDK contract, -without authenticated transport or a login UI. P02b-6 reconciles actual realm -issuance; P02b-7 owns the BFF/session flow below. Phase 06 expands the complete +P02b-5 delivers the API boundary and separate typed AccountSite SDK contract. +P02b-6 reconciles actual realm issuance; P02b-7 delivers the native BFF/session +flow below. Phase 06 expands the complete browser journey and Studio/Portal experience. The [API contract](../decisions/0064-tenant-api-authentication-boundary.md) does not turn a generated GET into a public wrapper or widen native public admission. Auth is delegated to the identity provider (Keycloak/Authentik, see [Identity and Authentication](13-identity-and-auth.md)). The frontend uses OIDC Authorization Code with PKCE. -- Session is held in HTTP-only cookies set by the BFF after callback. +- [ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md) selects native server + custody with host-only opaque cookies; P02b-7 delivers the Accepted contract. - Refresh is handled silently by the BFF; the frontend never sees the refresh token. -- Studio and Portal segments require an authenticated session at the edge; unauthenticated requests redirect to the identity provider. -- Public segment is anonymous-by-default; authenticated users get personalised hero blocks etc. +- Studio and Portal require native session admission before rendering. Enabled + mode redirects an anonymous visitor to the local sign-in form; only its + CSRF-protected POST starts provider navigation. Disabled mode returns the + bounded sign-in-unavailable state, without provider traffic. +- Public remains anonymous-by-default; P02b-7 adds no personalized content. ## Page-Block Resolver @@ -418,8 +420,8 @@ actions**. The tenant-facing surface in `apps/web` is read-only: operator action. Full flow: [27-custom-domain-tls.md](27-custom-domain-tls.md). Auth-cookie scoping for -custom domains uses SameSite-Lax with explicit `Domain=` per active host; no cookies -shared across tenants. +custom domains uses Secure/HttpOnly/SameSite-Lax `__Host-` cookies, Path=/ and +**no Domain**, under ADR-0070. No cookie or forwarded header selects a tenant. ## Live Classroom Integration @@ -500,7 +502,7 @@ mechanical. quota on each request. Measure that cost; only request-local reuse is allowed under [the public rendering policy](../standards/07-frontend-architecture.md#public-site-renderer). - **Cookie domain scoping** — tenants on custom domains complicate auth cookies. Use - SameSite-Lax + explicit `Domain=` per host; do not share auth cookies across tenants. + host-only `__Host-` cookies with no Domain; do not share auth cookies across tenants. Domain registration and TLS flow: [27-custom-domain-tls.md](27-custom-domain-tls.md). - **Brand-token contrast failures** — refuse saving a failing palette and explain diff --git a/docs/architecture/25-deployment-models.md b/docs/architecture/25-deployment-models.md index b08e990d..8d18e7fc 100644 --- a/docs/architecture/25-deployment-models.md +++ b/docs/architecture/25-deployment-models.md @@ -280,6 +280,13 @@ an abstraction. ### Other config settings switched by mode +- **BFF session store** — [ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md) + selects app-local `BffSessionStore` / `InMemoryBffSessionStore` for P02b-7's + one native web process. Restart signs sessions out in every mode. The SaaS + auto-scaled topology above is a target, not multi-process session support. + Phase 11 supplies a distributed adapter before multiple web processes serve + one session, with atomic refresh/revocation/interruption qualification. + - **Hub URL** — target: LearnStack-hosted for SaaS / Dedicated / SelfHostedOnline, customer-hosted only where separately arranged, or absent for SelfHostedAirGapped. The current default entitlement provider makes no Hub call. diff --git a/docs/architecture/30-api-gateway.md b/docs/architecture/30-api-gateway.md index 3555cd6a..6fed08a9 100644 --- a/docs/architecture/30-api-gateway.md +++ b/docs/architecture/30-api-gateway.md @@ -449,7 +449,9 @@ owns topology, cold/warm-cache and key-rotation drills, diagnostic/readiness dec and operational runbooks before production use. P02b-6 delivers actual realm issuance and safe development convergence; its [Step 4 record](../roadmap/phase-02b-delivery-log.md#p02b-6-step-4--real-identity-qualification-and-closeout-2026-10-21) -owns qualification. Browser-session integration remains P02b-7. +owns qualification. P02b-7 supplies native browser-session custody under +[ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md); production gateway and +proxy activation remain Phase 11's separate qualification. ## 6. Rate limit policy diff --git a/docs/architecture/31-audit-subsystem.md b/docs/architecture/31-audit-subsystem.md index 43e2a1b4..bf56f492 100644 --- a/docs/architecture/31-audit-subsystem.md +++ b/docs/architecture/31-audit-subsystem.md @@ -14,6 +14,13 @@ compliance-relevant operations across every LearnStack module. This document des the pipeline, the durability model, the data model, retention, redaction, and operational concerns. +[ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md) explicitly assigns +BFF provider events to development Keycloak storage. Local expiry, unreceived +refresh and outage-local logout have closed diagnostics only until Phase 03's +authenticated identity-event ingress/mirror. No standalone session audit API, +atomic provider/BFF durability or change to existing LearnStack MUST writes is +introduced; [Audit 18](../standards/18-audit-coverage.md#operation-types) owns the rule. + ## 1. Two durability classes The single most important thing to understand about this subsystem is that **audit is not diff --git a/docs/assets/bff-session/studio-en.png b/docs/assets/bff-session/studio-en.png new file mode 100644 index 00000000..ea7f1c91 Binary files /dev/null and b/docs/assets/bff-session/studio-en.png differ diff --git a/docs/assets/bff-session/studio-tr.png b/docs/assets/bff-session/studio-tr.png new file mode 100644 index 00000000..381a3c67 Binary files /dev/null and b/docs/assets/bff-session/studio-tr.png differ diff --git a/docs/decisions/0009-frontend-single-app-first.md b/docs/decisions/0009-frontend-single-app-first.md index eec19120..93f3f89e 100644 --- a/docs/decisions/0009-frontend-single-app-first.md +++ b/docs/decisions/0009-frontend-single-app-first.md @@ -58,3 +58,13 @@ Any one of these is sufficient cause to revisit: - [Frontend Architecture](../architecture/14-frontend-architecture.md) - [Frontend Architecture Standards](../standards/07-frontend-architecture.md) - Superseded ADR file: [_redirects/0006-frontend-single-app-first.md](_redirects/0006-frontend-single-app-first.md) (kept for old links). + + +### Amendment 1 — Native session ownership (2026-10-24) + +[ADR-0070](0070-owned-tenant-bff-sessions.md) supplies the subsequent session +owner within the retained single web application. Its closed auth protocol runs +in native ingress/middleware, with an app-local read-only `auth()` facade; +`app/api/` is not a general backend proxy. The original target layout remains +historical; [Architecture 14](../architecture/14-frontend-architecture.md) owns +the current/target distinction. This adds no multi-app split or endpoint. diff --git a/docs/decisions/0035-demand-gated-infrastructure.md b/docs/decisions/0035-demand-gated-infrastructure.md index e8ac65c3..2c7f625c 100644 --- a/docs/decisions/0035-demand-gated-infrastructure.md +++ b/docs/decisions/0035-demand-gated-infrastructure.md @@ -217,6 +217,23 @@ this ADR's sense because the durable implementation is a table rather than a ven adapter — a distinction this ADR does not draw, and the row costs less than the argument did. +### Amendment 2 — Native BFF session store joins the gated set (2026-10-23) + +The maintainer accepted [ADR-0070](0070-owned-tenant-bff-sessions.md). +This additive inventory extends the gated set without changing its four-part +rule or the original body/acceptance metadata: + +| Building block | Port | Selected working default | Owner | Trigger | +|---|---|---|---|---| +| Distributed BFF sessions | App-local `BffSessionStore` | `InMemoryBffSessionStore`, delivered by P02b-7 Step 2 | Phase 11 | Multiple native web processes must serve one browser session | + +Acceptance authorizes the port/default; it does not claim their implementation. +The volatile single-process default loses sessions on restart. The distributed +adapter must qualify atomic refresh, revocation and interrupted work before +multi-process session support. The +[P02b-7 package](../roadmap/phase-02b-delivery-log.md#p02b-7-revised-decision-package-2026-10-23) +owns delivery and proof; no cache adapter is silently reused for credential state. + ## References - [ADR-0014 Adopt Dapr](0014-adopt-dapr.md) diff --git a/docs/decisions/0053-trusted-public-server-rendering.md b/docs/decisions/0053-trusted-public-server-rendering.md index fdf7841c..de25eac7 100644 --- a/docs/decisions/0053-trusted-public-server-rendering.md +++ b/docs/decisions/0053-trusted-public-server-rendering.md @@ -382,6 +382,17 @@ 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. +### Amendment 11 — Separate authenticated BFF caller (2026-10-23) + +Accepted [ADR-0070](0070-owned-tenant-bff-sessions.md) adds one confined +AccountSite caller using the native session owner's bearer and independently +verified host/visitor hop. The quoted public-caller prohibition on forwarding +Authorization/cookies/assertions remains unchanged for every public wrapper. +This is an additive authenticated seam, not a general proxy or tenant selector. +The request-local public holder does not store credentials. Acceptance enables +no runtime route; P02b-7 delivers the caller with actual API/projection proofs. +Original body, metadata and earlier amendments remain historical. + ## 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 ca43c380..9c3e0217 100644 --- a/docs/decisions/0054-bounded-public-renderer-admission.md +++ b/docs/decisions/0054-bounded-public-renderer-admission.md @@ -364,6 +364,23 @@ metadata and numeric budgets remain unchanged. The owns execution and independent review status. This delivery note preserves the original acceptance metadata, body and all earlier amendments. +### Amendment 10 — Exact tenant BFF POST admission (2026-10-23) + +Accepted [ADR-0070](0070-owned-tenant-bff-sessions.md) narrowly extends +[Native method and upgrade admission](#native-method-and-upgrade-admission): + +> Apply GET/HEAD admission to every HTTP path reaching the native listener callback, +> before Next dispatch and independently of middleware matcher exemptions. + +Only POST to `/api/auth/signin/keycloak`, `/api/auth/signout` and +`/api/auth/extend` may additionally pass the native boundary, with the approved +host, bounded-input, CSRF and response-owner guards installed atomically. +Other methods/routes, generic auth actions, Server Actions and production +upgrades remain closed. Disabled BFF adds no exception. The public credential- +free flow and visitor/peer accounting are unchanged. P02b-7 owns implementation +and native proof; acceptance alone activates nothing. Original body/metadata +and earlier amendments stay historical. + ## References - [ADR-0053 — Trusted Public Server Rendering](0053-trusted-public-server-rendering.md) diff --git a/docs/decisions/0070-owned-tenant-bff-sessions.md b/docs/decisions/0070-owned-tenant-bff-sessions.md new file mode 100644 index 00000000..3521355a --- /dev/null +++ b/docs/decisions/0070-owned-tenant-bff-sessions.md @@ -0,0 +1,550 @@ +# ADR-0070: Owned Tenant BFF Sessions + +## Status + +Accepted — 2026-10-23, after separate maintainer approval of the revised record +and five-step package. No dependent runtime exists at acceptance. + +**Date:** 2026-10-23 +**Deciders:** @cemil + +This record answers G13's session part for +[Phase 02b](../roadmap/phase-02b-events-auth.md#the-decision-register). +The [revised package](../roadmap/phase-02b-delivery-log.md#p02b-7-revised-decision-package-2026-10-23) +owns its five implementation steps. Earlier draft reviews remain historical +source inspections; the dated acceptance record captures maintainer approval. + +## Decision Drivers + +- One owner must serialize refresh and prevent expired state, stale cookies or + late callbacks from restoring a locally revoked session. +- Server custody already requires transaction/session state; encrypting an opaque + handle adds key management without concealing additional credential data. +- The delivered web topology is one native Node process. Durable identity and + distributed session storage have not shipped. +- Sign-in capacity needs source/principal limits before the shared global cap; + otherwise one caller can deny every tenant new sign-ins. +- Provider-owned event attribution is insufficient while provider storage is + disabled. Local-only diagnostics still cannot be a durable session ledger. +- Anonymous rendering, API-owned host/claim authority and closed HTTP writes + remain independent of the new session and its provider. + +## Considered Options + +1. **openid-client, native credential custody and plain opaque cookies** + (chosen). A maintained RP client sequences OIDC checks and token grants; + LearnStack owns its bounded browser transactions and session lifecycle. +2. **Direct oauth4webapi with the same server store and cookies** (viable). + Supplies protocol validation, PKCE/state/nonce generation, client authentication + and signature verification. This is not custom cryptography. It leaves more + grant/response/JWKS sequencing to the application than openid-client. +3. **Auth.js with opaque JWT cookies and custom ownership** (rejected here). + Its provider/framework integration, CSRF and sealed cookies are real benefits. + This design nevertheless replaces custody, refresh, expiry, projection, + transport, logging, logout and response ownership. No installed integration + needs preserving. The v5 beta and maintenance transition add costs without a + necessary remaining responsibility. Encrypted credential-bearing cookies + alone would also need replacement/revocation state to uphold this contract. +4. **Better Auth** (viable framework, not selected). It supports stateless and + memory modes; a database is not mandatory. Stateless account-data cookies + conflict with exclusive server custody. Its stateful User/Account/session + machinery and development memory adapter require further adaptation to our + bounded refresh/revocation owner. An RP-only client avoids that model now. +5. **Durable PostgreSQL or Valkey sessions now** (viable alternative). Offers + restart/multi-instance continuity, but adds credential persistence, retention + and distributed ownership. No measured continuity requirement justifies that + scope for the delivered single-process topology. The store port ships now. +6. **Per-tenant reservations or oldest-session eviction** (not selected). + Reservations need a tenant inventory/fair-share policy. Source and subject + caps bound the obvious monopolies without displacing another live session. + Oldest-session eviction could sign another device out; only an explicitly + authenticated same-browser replacement retires its own prior local session. +7. **OIDC Back-Channel Logout now** (not selected). Useful for prompt remote + invalidation, but needs authenticated logout-token ingress, replay handling + and provider-to-session indexing. Phase 03 owns it with identity disablement/ + sign-out-everywhere, before those features promise immediate browser + revocation. Until then they are observed on refresh or local expiry. + +## Decision + +LearnStack uses openid-client for tenant OIDC and one authoritative session +owner in the native web process. Credentials stay server-side; host-only opaque +cookies bind the browser to that owner's state. Response-owning middleware and +routes complete session decisions before rendering, while app-local `auth()` +reads a safe immutable request projection. The default store is single-process, +volatile and bounded; the tenant development realm records provider user events. + +### Provider and library boundary + +Pin `openid-client` 6.8.8, with lock-resolved `oauth4webapi` 3.8.8 and `jose` +6.2.13, all MIT. Prove one resolved copy of each dependency. No Auth.js or Better +Auth runtime is installed by this choice. Provider protocol and cryptographic +algorithms remain library-owned; no password authentication or JWT crypto is +implemented by LearnStack. + +Use confidential `learnstack-web` in `learnstack`, S256, state and nonce, with +P02b-6's exact HTTPS callbacks and same-host root post-logout registrations. +Require ID tokens, explicit issuer/client/lifetime/nonce validation and +`enableNonRepudiationChecks` for library signature verification on code and +refresh ID tokens. Do not assume token-endpoint TLS processing verifies a JWS. +Use `authorizationCodeGrant`, `refreshTokenGrant`, `buildEndSessionUrl` and the +bounded `customFetch` seam. Configure no DPoP or automatic grant retry. + +The API remains the access-token authority. Before publishing a new session or +refresh revision, use that exact access token for the closed AccountSite read +and match the validated ID token's protected `user_id`/`tenant_id` to its actor/ +tenant. Any present `organization_id` must match the API-resolved organization; +absence does not have to equal a host-resolved organization. Accept resolved +scope only from that successful API read under the independently verified host; +the BFF supplies no missing membership/organization authority. Preserve original +protected claims, including absence, and the original resolved scope on refresh. +AccountSite has no issuer/subject fields; `sub` is not an actor UUID. Validate +ID-token issuer/subject independently and preserve that original pair on refresh. +The provider's token response associates the two tokens; AccountSite witnesses +access-token actor/scope, not its subject. An ID-token decode is not access-token +validation. Self-registration alone cannot create the required actor profile. + +All discovery, token and JWKS traffic uses private issuer configuration, fixed +endpoint purposes, no redirects and one operation-wide finite deadline through +body consumption. Reject unexpected origins, malformed/oversized responses and +profile conflicts. No cookie, claim URL, forwarded authority or error text +selects an endpoint. Loopback HTTP is development-only. Startup and ordinary +public requests without session cookies contact no provider; approved sign-in/ +callback transactions are explicit exceptions. Emit closed diagnostics only, +never library exceptions, token responses or raw request/redirect URLs. + +### Activation and custody + +`LEARNSTACK_BFF_ENABLED` absent/false selects public-only operation: no BFF secret +projection/provider call; auth routes are masked 404 and Studio/Portal return a +bounded 503 sign-in-unavailable state without protected content. Explicit true +requires an agreeing private issuer and existing web-client secret. The latter +already serves realm tooling and alone is not partial BFF configuration. +There is no new session-encryption key. Existing private files are not silently +repaired. Neither mode contacts Keycloak at startup. + +`BffSessionStore` is the app-local port; `InMemoryBffSessionStore` is installed +once by the native launcher, separately from ADR-0055's public request holder. +Middleware, routes and RSC facades share it across bundles/HMR. It owns credentials, +validated identity, transaction state, epochs and in-flight work. Browser-group +indexes exist only while their owner entries exist and cannot form an unbounded +second registry. Stop closes admission/publication, cancels owned provider I/O, +joins actual operations and stops maintenance before claiming clean shutdown. + +The session cookie is one random 256-bit opaque value. A separate random browser +binding groups that host's login transactions and local session. Neither is a +tenant selector or permission. Both use `__Host-`, `HttpOnly`, `Secure`, +`SameSite=Lax`, `Path=/`, and no `Domain`. Server lookup checks the independently +verified live host, browser binding, current epoch, active state and expiry. +No credentials, identity/profile or version metadata are stored in these cookies. +There is no chunked session format: reject duplicate/malformed names/values and +oversized cookie families instead of reconstructing arbitrary chunks. + +Session JSON/RSC/client data explicitly projects only the required state, +validated nonsensitive display fields, effective expiry and session-bound CSRF +value. It exposes no opaque handle, binding, provider token or raw profile. + +Support exactly one native web process. Restart, deployment or failover signs +its sessions out; sticky routing is not distributed continuity. A distributed +adapter behind `BffSessionStore` belongs to Phase 11 before multiple web +processes must serve one session, with atomic refresh/revocation/interruption +proofs. No new backend schema, database grant or Hub surface is introduced. + +### Admission and reclamation + +- Retain at most 1,024 owner entries, including closing/in-flight owners; at most + 128 may be pending sign-ins with a five-minute lifetime. Expiry never frees a + slot before its owned work actually settles. +- Allow at most 16 concurrent pending sign-ins per canonical verified visitor IP, + across all hosts. Source-cap refusal is 429 with a bounded retry hint; global + capacity refusal is 503. Neither creates a reservation or begins provider I/O. + Sixteen allows concurrent NAT users, but one source cannot fill all 128 slots. + Eight distributed sources can still do so; this is not DDoS or tenant fairness. +- Retain at most eight established/closing sessions **including logout-return + transactions** per validated `(issuer, sub)` across hosts. Additional + independent sessions are refused after identity validation, before publication, + with a closed 429. Do not evict another device or perform provider logout as + capacity cleanup. A ninth grant may already have + been issued; no remote rollback or cancellation of sibling SSO is promised. +- A successful login replaces only its bound browser group's current local + session. Failed validation leaves it intact. Close its publication gate and + settle owned old I/O before transferring its subject credit/publishing the new + session. Retain the pending entry/credit meanwhile. No other host/device is + retired by a subject lookup. Global/pending admission is still required before + authentication; replacement is not a capacity bypass. +- A logout converts its existing entry into a five-minute return transaction, + consuming no sign-in slot. Once its redirect is built, discard provider tokens; + retain only the host/browser-bound return proof, minimal subject-accounting key + and necessary in-flight owner. Keep its subject credit until return consumption + or expiry **and** actual operation settlement. Repeated login/logout without + following the provider return cannot bypass the eight-owner subject limit. +- Sweep all at most 1,024 entries every 30 seconds and run one complete expired- + state pass before refusing global capacity. This is a finite scalar-state scan, + with no provider/database I/O. Do not leave settled expiry waiting eight minutes + for rotation; do not claim microsecond latency or measured throughput. + +These are selected initial budgets, not a throughput entitlement. A maximum +32 KiB serialized credential bundle per owner bounds retained serialized +credentials to 32 MiB; JS/request/protocol overhead remains additional. Subject +caps permit, for example, four device/browser profiles on two hosts; someone +controlling an account can exhaust that account's allowance. Registration with +no valid protected actor does not spend an established-session slot. + +### Browser transactions and response races + +Use library-generated independent state, nonce and verifier in server transaction +records. A per-transaction `__Host-` cookie uses the state identifier in its name +and an independent random secret as its value; the record also binds the host's +separate browser-group cookie. Bound transaction count by source/global +admission and the whole incoming auth-cookie family. A callback requires exact +state and that transaction's browser/host binding; consume it atomically before +code exchange. +Missing/mismatched state leaves every existing cookie and unrelated transaction +untouched. A verified failed transaction may clear only its own cookie. + +A browser group has an owner epoch. The first successfully validated callback +publishes its new session and invalidates competing transactions from the prior +epoch; their later callbacks cannot publish or clear the session cookie. Logout +closes the current session and every earlier pending transaction in that browser +group before provider work. New explicit sign-in after logout uses a fresh epoch. +Check the epoch again after every await and before publication. Retain in-flight +transactions in capacity/index ownership until actual settlement. + +No server can retract Set-Cookie already sent. A delayed older response may +replace/clear the newest browser cookie and require reauthentication. Owner +epoch/revocation makes the old handle unusable; do not promise uninterrupted +newest-cookie availability or treat a browser response as server authority. +No failed callback deletes a primary session cookie. + +### Effective lifetime, activity and refresh + +The BFF idle limit is 30 minutes and absolute limit eight hours, using monotonic +elapsed time. Effective expiry is the minimum of last qualifying activity plus +idle limit, original absolute deadline and the current provider refresh deadline. +Derive the latter conservatively from the validated response's finite positive +`refresh_expires_in` and the request's measured start/elapsed time; refuse an +unknown/unbounded value in this development profile. Do not confuse it with the +900-second access/ID expiry or extend it on a local timestamp alone. + +Qualifying document GET navigation, non-prefetch RSC navigation (`RSC: 1`) and +an authenticated API read update idle state in their response owner after +successful admission. Assets, HEAD, prefetch and passive session/status polls do +not refresh or touch idle time. These are protocol classifications, not proof +of physical user activity. App-local `auth()` performs none of those writes. + +On a qualifying request, refresh once if access is expired **or** at most 60 +seconds from expiry, while the effective session deadline is still live. +An expired access token alone never forces re-login. Concurrent callers join one +owner for that revision; cancelling a waiter does not cancel it. Recheck epoch, +revocation and all deadlines after I/O and publish one revision atomically. +Preserve an existing refresh token only when a valid response omits replacement. + +Show a two-minute warning against effective expiry. A CSRF-protected extension +must join/start a refresh even if access is still fresh, so Keycloak idle and +credentials advance with BFF idle atomically. Return the actual resulting +expiry; the original absolute deadline cannot move, and an absolute/provider +maximum must not be presented as extendable. Prove this with the real provider. + +`invalid_grant`, malformed/invalid responses, timeout, network uncertainty and +other refresh failures revoke locally with distinct closed reasons; never replay +a possibly consumed grant automatically. Public rendering continues anonymously +with 200; protected pages require sign-in. Provider faults can therefore demand +reauthentication. Idle updates, refresh, invalidation and cookie changes belong +to response-owning middleware/routes before streaming. RSC consumes only the +immutable safe projection. A logical revision is server state, not cookie count. + +### Closed HTTP, CSRF and logout + +Retain GET/HEAD admission elsewhere and production upgrade closure. Admit POST +only to exact Keycloak sign-in, local sign-out and session-extension routes. +No generic proxy, arbitrary provider/action, Server Action or application write +is opened. The package owns the exact route/field matrix and bounded input. +Unknown hosts refuse before cookie allocation, discovery or token exchange. +Only the verified native host/HTTPS port builds return/callback URLs; trust no +`AUTH_URL`, forwarding header or submitted absolute `callbackUrl`. + +- Pre-session sign-in POST: exact Origin plus a random host-only double-submit + CSRF proof. Its cookie is HttpOnly; the same-origin form receives the matching + token. It grants no session authority. Apply pending-source admission before + reservation/provider work; a CSRF token is not a quota identity. +- Provider GET callback: cross-origin navigation is intentional; require the + state/nonce/S256 transaction and browser binding instead of an Origin match. +- Authenticated sign-out/extension POST: exact Origin and a constant-time checked + random token bound to the current server session. SameSite does not separate + sibling tenant origins. Cookie presence alone never proves CSRF. + +Logout revokes locally and closes the browser epoch first, then clears the +current session/sign-in cookies and performs RP-initiated end-session with the +stored ID-token hint and registered same-host root return. Issue a separate +per-transaction `__Host-` logout-proof cookie with the same security attributes, +bound to that validated browser group and single-use returned state. Its return +clears only that proof, never a newer session or browser binding. The +provider-directed ID-token-hint +Location is the narrow token-in-URL exception: use no-referrer and never retain +that raw URL. +Access/refresh credentials enter no Location, body, Flight, session JSON or +script-readable cookie. Return-state correlation is not an independent proof +that every provider session was terminated; real-provider tests check logout. + +Local revocation is independent of Keycloak availability. Before navigation, +failures can render local recovery. After leaving LearnStack the browser may +remain on a provider/error page; automatic return is not guaranteed. Revisiting +LearnStack remains locally signed out. Shared Keycloak SSO can cause another +host's later refresh to fail; independent provider sessions are not intentionally +revoked. Already issued API JWTs retain ADR-0064's expiry. No immediate remote +admin-disable/sign-out-everywhere guarantee exists before Phase 03's backchannel. + +Authenticated responses are no-store. Real browser history/page-restoration +proof must revalidate before protected display; headers alone establish no +browser behavior. + +### Reads and durable event ownership + +The authenticated server SDK remains exact AccountSite with the owner's bearer +and trusted host/visitor hop. No raw token/host/header option enters UI or pure +SDK consumers. Public wrappers remain credential-free. Studio/Portal initially +display only that nonsensitive projection, with no role-based administration, +new permission, membership or backend endpoint. + +Enable Keycloak user-event storage in the **tenant development realm**, fresh +and warm, for LOGIN, LOGIN_ERROR, LOGOUT, LOGOUT_ERROR, CODE_TO_TOKEN, +CODE_TO_TOKEN_ERROR, REFRESH_TOKEN and REFRESH_TOKEN_ERROR. Add them to the +existing effective selection, retaining default-saved and custom prior events, +listeners, admin-event policy and unrelated realm fields. Refresh events are +not saved by default; `eventsEnabled` alone is insufficient. + +Fresh/default-empty realms use 604800 seconds (seven days) retention. Adopt that +value on a warm disabled/default-zero store only after a bounded read proves +there are no stored events. Preserve an existing finite retention of at least +seven days; refuse shorter, indefinite or ambiguous customized retention before +any write rather than erase history or silently change its policy. Send explicit +enablement, retention and merged types through the fixed events-config endpoint; +verify separately and require a zero-write completed rerun. Do not replay realm +imports, alter credentials/roles/SSO timeouts or modify `learnstack-hub`. + +Seven days is a development review window, not Standards 18's application-audit +retention or a row/disk capacity bound. Provider expiry is asynchronous. Stored +provider events include identity/address details; they remain in the owned +provider database, never raw CI artifacts. Phase 11 owns production provisioning, +retention/security/capacity qualification. + +Provider events do not witness local expiry, an unreceived token request or +local logout during provider outage. Those BFF acts have only closed lifecycle +diagnostics until Phase 03's authenticated identity-event ingress/mirror. This +is an explicit narrow Standards 18 ownership exception, **not** AccountSite Off +or permission to skip existing LearnStack MUST events. There is no durable +application session ledger and no new audit-write API. Storage failure is a +provider availability issue; do not claim provider events commit atomically with +a BFF state change. + +### Relationship and acceptance carriers + +From Standards 07 § Auth: + +> Auth.js for session management. + +Replace that framework choice with the selected RP library/native session owner. +Reconcile the related Phase 02b Auth.js/chunking illustrations and skill examples; +retain an app-local read-only `auth()` facade. From Security 11 § Authentication: + +> Refresh tokens stored as `HttpOnly`, `Secure`, `SameSite=Lax` cookies; never +> accessible to JS. + +Replace cookie-held credentials with exclusive server custody and opaque cookies. +ADR-0064 API acceptance, ADR-0067 issuance and Keycloak authentication stay intact. +From ADR-0054 § Native method and upgrade admission: + +> Apply GET/HEAD admission to every HTTP path reaching the native listener callback, +> before Next dispatch and independently of middleware matcher exemptions. + +Add only the exact auth POST set. From ADR-0053 § Configured caller and rendering: + +> No Authorization, cookies, tenant/organization assertion or locale header is +> forwarded. + +Keep that public caller unchanged; add the separately confined AccountSite seam. +ADR-0055's request holder never becomes the session store. ADR-0004's prohibition +on .NET session storage is unchanged. The new development event fields extend +P02b-6's owned reconciliation allowance; all other preservation limits remain. + +Acceptance appends dated disclosures to ADR-0035/0053/0054 and the Accepted +P02b-6 package. ADR-0035's additive inventory amendment names `BffSessionStore`, +`InMemoryBffSessionStore`, Phase 11 and its multi-web-process trigger; it adds no +exception to the four-part rule. Do not rewrite Accepted bodies/metadata. +Reconcile Standards 06/07/10/11/18/20/21; Architecture 13/14/**25/31**; Phase 03/11; +G13 and the phase session plan; glossary BFF/session terms; realm/setup guidance +and both route/feature-gate skills. Architecture 25's auto-scaled SaaS topology +is a target requiring the distributed adapter, not current session support. +Acceptance establishes authority, not runtime qualification. + +## Context + +At `74740158`, no BFF/Auth.js runtime exists. Next is 15.5.27 with React 19; +native ingress rejects POST, middleware strips Cookie/Origin and the SDK has +AccountSite types without authenticated transport. The realm lacks event-store +configuration and the reconciler owns only realm access-token lifespan. +P02b-6's callbacks and real-token proofs do not establish browser sessions. + +The initial draft selected Auth.js; maintainer review prompted a fresh official +source comparison. The September 2025 Better Auth and Auth.js announcements +promise security/urgent maintenance and recommend Better Auth for new projects. +Auth.js's security policy describes current-latest support, while Next v5 remains +a beta channel. This is not a claim of abandonment or proof that beta.32 lacks +patches. Its framework conveniences are unnecessary for this selected custody +model. openid-client is an actively maintained RP layer over oauth4webapi; +its APIs still require explicit signature checks and our owned session protocol. + +Source inspection supports feasibility, not runtime qualification. Reopen for +required restart/multi-process continuity, measured admission/fairness/retention +or refresh costs, a failed library/native seam, or changed provider semantics. +Do not silently increase budgets, add persistence or waive a missing proof. + +## Consequences + +### Positive + +- One authority fences refresh, replay, expiry and local revocation without + credential-bearing/encrypted session cookies or a parallel framework model. +- Source/subject limits prevent the simplest single-caller capacity monopoly; + full finite expiry scans reclaim settled capacity before refusal. +- Warm and fresh development providers retain observable authentication events; + anonymous rendering and API host/claim authority remain independent. + +### Negative + +- Restart/failover signs sessions out; multiple web processes are unsupported. + Memory compromise exposes credentials; JS strings offer no secure erasure. +- NAT users share 16 pending slots, a subject shares eight session/logout-return + owners across hosts, and distributed sources/accounts can still fill global + capacity. No admission throughput, distributed fairness or live-session + eviction is promised. +- Late cookie responses and transient refresh failure can require sign-in again. + Logout may leave the browser at the provider; shared SSO couples later refresh. +- Provider event storage adds personal-data/volume cost and a seven-day retention + policy. Customized warm retention can require explicit operator resolution. + No durable local BFF ledger, atomic audit or prompt remote-disable guarantee + is provided; ID-token logout hints need browser/URL containment. +- LearnStack owns browser CSRF/binding, transaction consumption, expiry and + shutdown around the RP client. Correctness needs real negative/race controls. + +### Neutral + +- No business identity schema, grants, Hub contract, membership or permission. + Only named development event configuration changes; production provisioning + and the full real-browser OIDC journey retain their named phases. + +## Implementation Notes + +The revised package owns five steps, exact route grammar and proof placement. +It registers four new obligations at acceptance, with actual test owners and +`Registered` status until controls ship. No catalogue rule is claimed Implemented +by proposal acceptance. Existing issuance/reconciliation obligations gain the +provider-event preservation/retention controls with their first implementation. + +| Obligation | Level and owner | +|---|---| +| `Bff_Credentials_And_Projections_Are_Confined` | Frontend structural/production canary: `src/test/bff-boundaries.test.ts`, existing public fences | +| `Bff_Session_Refresh_And_Revocation_Have_One_Owner` | Unit/actual routes: `src/server/auth/session-store.test.ts`, `src/test/bff-session-routes.test.ts` | +| `Bff_Auth_Admission_And_Csrf_Are_Closed` | Native/route behavior: `src/test/bff-auth-admission.test.ts`, `src/test/bff-session-routes.test.ts` | +| `Bff_Host_Cookies_And_History_Are_Isolated` | Native HTTPS browser: `scripts/verify-bff-session.mjs`; real warm BFF/provider chain: backend Integration `TenancyIdentity/TenantBffSessionTests.cs` | + +Require reachable positives and planted negatives for signature/state/nonce/ +verifier/issuer/profile validation, raw diagnostics, singleton duplication, +input/CSRF bypass, pending-source/subject capacity, replacement, browser epochs, +late responses, expiry/on-demand refresh/extension, full reclaim and shutdown. +Include repeated sign-in/logout without provider return, subject credit held +through unsettled work, changed refresh subject and independent actor/scope +mismatch. A late logout return cannot clear a newer session's cookies. +Test independent per-transaction cookie deletion and fixed small session-cookie +rejection; no artificial chunking proof substitutes for this format. +Warm real BFF login/AccountSite/logout/event evidence and both-host real browser +storage/send/history proof remain mandatory, alongside public call counts and +stopped-Keycloak rendering. CI retains complete discovery, no skips and existing +whole-job ceilings; new fixture cost requires measured qualification. + +## Amendments + +### Amendment 1 — Delivered proof owners and read-failure clarification (2026-10-24) + +The delivered `verify-bff-session.mjs` owns the real-provider HTTP chain, while +`verify-bff-browser.mjs` owns the native HTTPS browser proof. The original table +records the planned assignment before runtime existed; this dated clarification +names the delivered owners without changing the required proof. The original +row and this record's decision, metadata and rationale remain intact. This is +not an ADR-0041 claim that nonexistent runtime contradicted the accepted plan. + +The existing refresh uncertainty rule applies after a grant may have been +consumed. An ordinary AccountSite read consumes no grant: temporary availability +failure denies that response without revoking a still-live session or advancing +its idle deadline. A proven identity refusal or actor/scope conflict still +revokes. Public responses remain anonymous; protected reads return unavailable +until authority can be checked. A failed AccountSite validation after refresh +still revokes, because its old grant cannot safely be replayed. This clarifies +the existing read/refresh distinction; it does not relax grant publication. + +Source admission counts canonical verified IP addresses, not people, networks +or IPv6 prefixes. One client with several addresses can consume several source +allowances; a reverse proxy's shared TCP peer can combine unrelated visitors. +The accepted budgets provide no subnet fairness or production proxy support. +Production provisioning and distributed/proxy qualification remain Phase 11's +named responsibilities. + +### Amendment 2 — Explicit AccountSite status decision (2026-10-24) + +[ADR-0071](0071-uncertain-account-site-refusals.md), Accepted 2026-10-24, +completes the ordinary AccountSite status mapping and ratifies the availability +choice recorded in Amendment 1 as an explicit policy decision. Amendment 1 +names no HTTP status table; it does not establish that a bare 401 proves +identity rejection. Its heading/body and this record's original decision, +metadata and rationale remain intact. This is not an ADR-0041 factual erratum. + +ADR-0064 deliberately returns the same 401 for invalid and undecidable +credentials. ADR-0071 changes that response's caller category to +`provider_refused`: an ordinary read with no preceding refresh retains a live +owner without touching idle time and withholds checked authenticated content. +Public documents render anonymously; protected checked reads return 503. +Masked 403/404 continue to revoke as access-boundary refusals, including the +accepted possibility of host/hop configuration failure. The status table and +delayed genuine-401 revocation cost live in ADR-0071. + +Initial callback publication still requires a positive API witness. Every +failed post-refresh authority check still revokes its current owner because +the new package cannot publish a revision without that witness. Callback and +extension AccountSite 401 consequently return BFF 503/`provider_refused`, rather +than 400/`identity`; the owner result remains refuse/revoke. API response codes, +grant replay rules, cookie/session lifetimes and capacity are unchanged. + +## References + +- [Phase 02b](../roadmap/phase-02b-events-auth.md), [Security 11](../standards/11-security.md#authentication), [Frontend 07](../standards/07-frontend-architecture.md#auth), [Audit 18](../standards/18-audit-coverage.md#operation-types), [Catalogue 21](../standards/21-architecture-tests-catalogue.md). +- [ADR-0004](0004-authentication-strategy.md), [ADR-0035](0035-demand-gated-infrastructure.md#the-gated-set), [ADR-0036](0036-tenant-resolution-trusted-inputs.md), [ADR-0037](0037-idempotency-key-contract.md), [ADR-0053](0053-trusted-public-server-rendering.md), [ADR-0054](0054-bounded-public-renderer-admission.md), [ADR-0055](0055-public-renderer-bootstrap-failures.md), [ADR-0064](0064-tenant-api-authentication-boundary.md), [ADR-0067](0067-development-tenant-access-token-issuance.md). +- [Architecture 25](../architecture/25-deployment-models.md#supported-today-versus-prepared-seam), [Architecture 31](../architecture/31-audit-subsystem.md), [Phase 03](../roadmap/phase-03-identity-admin.md), [Phase 11](../roadmap/phase-11-production-hardening.md). +- [openid-client 6.8.8](https://github.com/panva/openid-client/tree/v6.8.8), [code grant](https://github.com/panva/openid-client/blob/v6.8.8/docs/functions/authorizationCodeGrant.md), [signature checks](https://github.com/panva/openid-client/blob/v6.8.8/docs/functions/enableNonRepudiationChecks.md), [oauth4webapi 3.8.8](https://github.com/panva/oauth4webapi/tree/v3.8.8). +- [Better Auth announcement](https://better-auth.com/blog/authjs-joins-better-auth), [Auth.js team announcement](https://github.com/nextauthjs/next-auth/discussions/13252), [Auth.js security policy](https://authjs.dev/security), [Better Auth 1.7.12 memory adapter](https://github.com/better-auth/better-auth/blob/v1.7.12/packages/memory-adapter/src/memory-adapter.ts). +- [Keycloak 26.6.2 event types](https://github.com/keycloak/keycloak/blob/26.6.2/server-spi-private/src/main/java/org/keycloak/events/EventType.java), [event persistence](https://github.com/keycloak/keycloak/blob/26.6.2/server-spi-private/src/main/java/org/keycloak/events/EventBuilder.java), [realm event update](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/services/managers/RealmManager.java), [token/refresh timing](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java). +- [OIDC RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html), [OIDC Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html). + + +### Amendment 3 — Implementation controls and diagnostic scope (2026-10-24) + +The existing fixed native host/HTTPS-port clause is enforced before auth and +protected-shell admission; anonymous public Host normalization remains intact. +The native launcher suppresses raw development query/trace sinks under the +existing diagnostic-privacy rule. Its proof exercises both streams and the +unprotected counter; this creates no telemetry authority to publish credentials. + +The existing local lifecycle diagnostics include once-only `expired` when sweep +retires an expired owner and `revoked` after successful local signout. Reasons +remain closed and contain no host, actor, credential or request identifier. +They are local observations; [Phase 11](../roadmap/phase-11-production-hardening.md) +owns production capacity signals, alerting and the operational runbook. + +Malformed HTTP input remains 400. Invalid upstream response/configuration and +unexpected server failures return a bounded 503; source/subject admission stays +429, while existing transaction/identity/expired/revoked/invalid-grant refusals +retain 400. Input parsing and server failure are exercised independently in the +[route controls](../../frontend/apps/web/src/test/bff-session-routes.test.ts). +This HTTP presentation does not alter publication, grant replay or revocation. +The earlier Registered-until-shipped wording records acceptance-time status; +the [catalogue](../standards/21-architecture-tests-catalogue.md#p02b-7-tenant-bff-obligations) +owns current control status. diff --git a/docs/decisions/0071-uncertain-account-site-refusals.md b/docs/decisions/0071-uncertain-account-site-refusals.md new file mode 100644 index 00000000..390325b6 --- /dev/null +++ b/docs/decisions/0071-uncertain-account-site-refusals.md @@ -0,0 +1,248 @@ +# ADR-0071: Uncertain AccountSite Refusals + +## Status + +Accepted — 2026-10-24, under maintainer authority to close verified review +findings and then accept and implement without another approval request. + +**Date:** 2026-10-24 +**Deciders:** @cemil + +**Relationship:** Complete ADR-0070's unspecified ordinary AccountSite status +mapping and ratify the ordinary-read availability policy recorded in its +Amendment 1. That amendment names no HTTP status classification. ADR-0064's API +response contract and ADR-0070's grant-publication, refresh, capacity and lifetime +rules remain unchanged. Ordinary-read retention for availability failures is +already implemented; the 401 mapping awaits the dependent implementation. + +## Decision Drivers + +- ADR-0064 deliberately returns 401 for both invalid and undecidable + credentials. A valid token whose issuer discovery fails receives that + same response; 401 alone cannot prove that its identity was rejected. +- The BFF currently maps AccountSite 401, 403 and 404 to `identity`. + An ordinary read then closes its live session, defeating the intended + availability distinction when the API cannot reach its identity provider. +- Failed authority checks must withhold that response's authenticated content. + Retaining credentials must not grant API access, extend idle time or replay + an uncertain provider grant. +- A response-status table needs an explicit decision owner. The ordinary-read + policy selected in ADR-0070 Amendment 1 must be stated as a decision rather + than inferred solely from its clarification of the refresh boundary. + +## Considered Options + +1. **Retain on an ordinary AccountSite 401** (chosen). Classify it as + `provider_refused`, deny the checked response and preserve the live owner + without advancing idle time. This accepts delayed local revocation when + 401 really describes invalid credentials. +2. **Revoke on every 401** (rejected). Simple and conservative about custody, + but also signs users out when issuer discovery is unavailable. Subsequent + callback authority checks still fail, so immediate re-login cannot repair + the dependency outage. +3. **Change API indeterminate authentication to 503 or add a refusal witness** + (viable separate decision, not selected). This would distinguish the + outcomes but changes ADR-0064's intentionally uniform contract. No + response body, challenge text or library exception is a trusted witness + under the current contract. A future API contract revision belongs to + Phase 03's identity boundary work and requires its own explicit decision. +4. **Retry, refresh immediately or decode token claims to guess the cause** + (rejected). An immediate automatic retry supplies no reliable new cause + distinction; an extra refresh may consume a grant; local claims do not + establish current API authority. A later user request remains permitted. +5. **Retain a newly refreshed package without positive AccountSite validation** + (rejected). This would relax ADR-0070's publication rule for a new revision. + Keeping an unvalidated replacement is a different custody/authority policy; + this decision preserves the accepted fail-closed publication boundary. + +## Decision + +LearnStack treats AccountSite 401 as an uncertain authority refusal. The BFF +maps a valid API error response with this status to `provider_refused`. +During an ordinary read it retains the live session, withholds authenticated +content for that checked response and leaves its idle deadline unchanged. +Public documents render anonymously; protected checked reads return 503. +The request neither retries automatically nor starts an additional refresh. + +The following decision table owns the distinction: + +| AccountSite observation | Ordinary read | Initial callback | After refresh | +|---|---|---|---| +| Valid 401 API problem | Retain; unavailable; no idle touch | Refuse publication | Revoke | +| Valid 403/404 API problem | Revoke as `identity` | Refuse publication | Revoke | +| Any other valid API problem, including other 4xx and 5xx | Retain; unavailable; no idle touch | Refuse publication | Revoke | +| Transport, deadline or invalid response | Retain; unavailable; no idle touch | Refuse publication | Revoke | +| Successful response with actor/scope conflict | Revoke as `identity` | Refuse publication | Revoke | +| Successful matching response | Admit and apply the existing activity rule | Publish once | Publish one revision | + +Missing or malformed API problem bodies cannot manufacture an identity refusal. +The existing SDK/adapter response and transport categories remain closed. +403/404 remain deliberate access-boundary refusals: authorization, tenant/host +scope and masked not-found outcomes do not grant this session access to the +fixed AccountSite surface. Their `identity` reason is a legacy closed category, +not proof that the user's identity itself is invalid. A masked 404 can also +describe a hop/host configuration failure; retaining revocation on that status +accepts that availability cost. This decision changes only the 401 ordinary-read +cell, not that existing conservative choice. + +The usual expiry/near-expiry refresh rule still applies before a checked read; +this decision adds no refresh trigger after an uncertain ordinary read. + +An initial callback needs positive API authority before session publication. +Every failed post-refresh AccountSite check still revokes: the newly obtained +package has no positive API authority witness and cannot publish a revision. +The completed refresh has already used its preceding grant; replaying that old +grant is not recovery. `invalid_grant`, provider uncertainty, epochs, late-result +fences and actual-cleanup ownership retain ADR-0070's rules. + +The shared caller also classifies callback/extension AccountSite 401 as +`provider_refused`. Their BFF POST response consequently changes from +400/`identity` to 503/`provider_refused`, while publication still refuses and a +failed refresh still revokes. A 503 is not a promise that a session survived or +that a consumed grant can be retried. API response statuses are unchanged. + +Passive projections continue to describe local session state. They do not +establish live API/provider acceptance, refresh credentials or touch idle time. +This decision adds no business permission or new passive-request authority. + +## Context + +The source at `c12f4eed` shows the complete chain: + +- `createAccountSiteCaller` maps 401/403/404 to `identity`. +- `InMemoryBffSessionStore.admit(..., 'read')` closes only for `identity`. +- The actual API control + `Discovery_failure_on_an_actual_public_route_is_401_and_never_anonymous` + signs a normal token, returns discovery 503 and observes API 401. It passes + independently during this decision pass. + +The API deliberately makes these outcomes indistinguishable. No comparison of +the error message or bearer challenge can recover information it does not +provide. A retained BFF session does not make a rejected token usable at the API. +The discovery-failure control above uses the public pipeline; the same real +handler's protected AccountSite route is an additional required control. A warm +API can continue accepting tokens signed by a cached known key despite a +discovery outage. The uncertain 401 is relevant when validation cannot obtain +the needed configuration/key, rather than to every warm-instance outage. + +### Relationship and acceptance vehicle + +From ADR-0064's accepted API contract: + +> On classified known hosts, a present invalid or undecidable credential is 401, +> including on public endpoints. + +That contract is unchanged. + +From ADR-0070 Amendment 1: + +> A proven identity refusal or actor/scope conflict still +> revokes. + +A bare 401 is not that proof. Amendment 1 does not enumerate statuses; this +record fills that decision gap and replaces the implementation's overly broad +401 classification. Acceptance appends a dated ADR-0070 amendment +linking this explicit classification and clarifying the earlier amendment's +decision status. Its original heading, body, metadata and rationale remain +historical. The ordinary-read availability choice recorded on 2026-10-24 is +ratified here as a policy decision; this does not claim that ADR-0070's original +Decision already specified every ordinary AccountSite status mapping. The +earlier amendment's grant-publication explanation remains valid. + +This is an explicit decision completion and ratification, not an ADR-0041 +factual correction or a claim that Amendment 1 contained a contradictory 401 +rule. Acceptance reconciles Security 11 and Architecture 13 to reference this +table. They distinguish credentials held by the session owner from a successful +API access check. + +Reopen this choice if the API adopts a trusted distinct indeterminate outcome, +if passive shells gain sensitive/business data, or if the provider/revocation +contract changes. Phase 03 owns identity disablement/back-channel logout; +Phase 11 owns production and distributed-session qualification. + +## Consequences + +### Positive + +- An uncertain 401 does not destroy an otherwise live local session during an + ordinary read that does not first require refresh. +- Failed checked responses remain unavailable; dependency recovery can admit + a later matching read using the existing owner. +- Initial publication and uncertain-grant handling retain their stricter fences. + +### Negative + +- A genuine 401 also retains credentials until logout, a later definitive + refusal, refresh failure or an existing local/provider deadline closes them. + Immediate local revocation cannot be inferred from this API status. +- Passive projections can continue to show local session state during the + uncertainty window. They are not a live identity acceptance statement. +- No availability guarantee follows: callbacks and post-refresh checks still + fail closed, and repeated dependency failure can outlast the local session. + Retention helps only until the existing refresh threshold; with a fresh + 900-second development access token and the 60-second refresh-ahead window, + that window is at most approximately 14 minutes, not a new outage budget. +- Masked 403/404 still revoke, including host/hop configuration refusals. The + shared `provider_refused` reason also groups API refusals with provider-endpoint + refusals; it does not identify which dependency or cause failed. + +### Neutral + +- No API status, role, grant, endpoint, quota, cookie or session lifetime changes. +- No new retry, background poll, refresh trigger or audit-write path is added. + +## Implementation Notes + +- Commit acceptance before changing the dependent status mapping. +- Reconcile ADR-0070 through its own dated amendment, Security 11's BFF rules, + Architecture 13, the decision index and the P02b-7 delivery record. +- Extend the existing `Bff_Credentials_And_Projections_Are_Confined`, + `Bff_Session_Refresh_And_Revocation_Have_One_Owner` and + `Bff_Auth_Admission_And_Csrf_Are_Closed` catalogue obligations and actual + controls; do not invent a second rule spelling. +- Exercise real `createAccountSiteCaller` plus the real store with a held, + valid 401 problem response. Prove retained ownership, unchanged idle expiry, + unavailable checked projection and recovery on a later valid response. +- Independently exercise actual HTTP routes: public anonymous/protected 503, + preserved cookies and no second grant. Exercise discovery failure on both + actual public and protected API routes to prove the API half of the contract. +- Use valid 403/404/other-4xx/5xx problem bodies and transport/invalid-body controls + to isolate the reason mapping. Assert the initial-callback and post-refresh 401 + negatives independently. The attempted callback must publish no new session; + clear its owned credentials after actual settlement while preserving any + prior live replacement session. A failed post-refresh check revokes its + current owner. Assert callback/extension 503/`provider_refused` after a valid + API 401, independently of the store's ownership result. Pending work stays + rooted until actual settlement; no heap + zeroization claim follows from logical entry/index cleanup. +- Do not label the composed controls as a fully browser-driven provider outage + journey. Phase 06 owns the full real-browser OIDC/browser-matrix expansion. + +## References + +- [ADR-0064](0064-tenant-api-authentication-boundary.md). +- [ADR-0070 and Amendment 1](0070-owned-tenant-bff-sessions.md#amendment-1--delivered-proof-owners-and-read-failure-clarification-2026-10-24). +- [Security 11](../standards/11-security.md#authentication). +- [Architecture 13](../architecture/13-identity-and-auth.md). +- [Catalogue 21](../standards/21-architecture-tests-catalogue.md). +- [Phase 02b](../roadmap/phase-02b-events-auth.md). +- [Phase 03](../roadmap/phase-03-identity-admin.md), [Phase 06](../roadmap/phase-06-renderer-admin-studio.md), [Phase 11](../roadmap/phase-11-production-hardening.md). +- [AccountSite caller](../../frontend/apps/web/src/server/auth/account-client.ts). +- [Session owner](../../frontend/apps/web/src/server/auth/session-store.ts). +- [Session failure contract](../../frontend/apps/web/src/server/auth/session-contract.ts) + and [provider failure taxonomy](../../frontend/apps/web/src/server/auth/provider-error.ts). +- [Actual API authentication controls](../../backend/tests/LearnStack.Tests.Integration/TenantAuthenticationHttpTests.cs). + + +### Amendment 1 — Acceptance evidence and delivered mapping (2026-10-24) + +The maintainer's conditional acceptance and implementation instruction is +preserved verbatim in the [delivery receipt](../roadmap/phase-02b-delivery-log.md#p02b-7-review-correction-contracts-and-proof-controls-2026-10-24). +Acceptance is committed at `e782f971073213b1029221d2c2f4544af8c45ec7` +(2026-10-24 14:31:18 +03:00); the dependent mapping ships at +`7133f8ad9b90988c4400d62cc4a95d6f7c7b5b9e` (14:44:18 +03:00). +The Status block's pending-implementation wording describes acceptance time. +The current public/protected API discovery-failure control is +`Discovery_failure_on_actual_public_and_account_routes_is_401_and_never_anonymous` +in the [HTTP tests](../../backend/tests/LearnStack.Tests.Integration/TenantAuthenticationHttpTests.cs). +The original decision, metadata and source observations remain historical. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 26c30448..f5aa3c31 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -111,6 +111,8 @@ owns outstanding hosted CI/PR qualification. Phase 11 owns production qualificat | 0067 | [Development Tenant Access-Token Issuance](0067-development-tenant-access-token-issuance.md) | Accepted 2026-10-21 after separate maintainer approval: dedicated authority for the already approved 900s development issuance choice, inherited ID-token expiration and bounded provider timestamp/session semantics. Security 11 owns the ongoing rule; API acceptance and BFF session policy remain separate. The [acceptance record](../roadmap/phase-02b-delivery-log.md#adr-0067-acceptance-and-carrier-reconciliation-2026-10-21) preserves original phase approval and source-bound proof/review status. | | 0068 | [Development Warm-Realm Qualification](0068-development-warm-realm-qualification.md) | Accepted 2026-10-22 under conditional maintainer authority after verified review corrections: identity-independent prechecks, one bounded profile preparation and three qualified stock-console assignment detachments. Implementation and automatic-path qualification remain pending; the [acceptance record](../roadmap/phase-02b-delivery-log.md#adr-0068-acceptance-and-implementation-2026-10-22) owns the three-step plan and proof status. | | 0069 | [Phase Documents and Delivery Logs](0069-phase-documents-and-delivery-logs.md) | Accepted 2026-10-22 after one review round: each phase keeps its plan in the phase document and its dated packet records in a sibling append-only delivery log whose Accepted decision packages stay normative; packet status lives once, in the phase document's bounded Status block; a whole-tree Markdown fragment audit and a roadmap structure guard enforce both, with Phase 02a, Phase 01 and ten Phase 02d entries declared as legacy shapes. | +| 0070 | [Owned Tenant BFF Sessions](0070-owned-tenant-bff-sessions.md) | Accepted 2026-10-23: openid-client, bounded native credential custody and host-only opaque cookies; qualified development provider events. Dated ADR-0035/0053/0054 disclosures preserve earlier bodies. The [acceptance record](../roadmap/phase-02b-delivery-log.md#p02b-7-acceptance-and-implementation-2026-10-23) owns authorized implementation and proof status. [Amendment 1](0070-owned-tenant-bff-sessions.md#amendment-1--delivered-proof-owners-and-read-failure-clarification-2026-10-24) names delivered proof owners and clarifies ordinary-read availability and source-IP limits. | +| 0071 | [Uncertain AccountSite Refusals](0071-uncertain-account-site-refusals.md) | Accepted 2026-10-24: explicit ordinary-read AccountSite status decision and ratification; ambiguous 401 retains local ownership without granting checked authority, while callback/post-refresh failures still refuse/revoke. [ADR-0070 Amendment 2](0070-owned-tenant-bff-sessions.md#amendment-2--explicit-accountsite-status-decision-2026-10-24) preserves its original history; the [acceptance record](../roadmap/phase-02b-delivery-log.md#adr-0071-acceptance-and-review-corrections-2026-10-24) owns the authorized correction plan and proof status. | ## Proposed ADRs diff --git a/docs/glossary.md b/docs/glossary.md index 7eccdf7e..f1d5e584 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -31,6 +31,10 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **Development realm reconciliation** | P02b-6 tooling operation that converges the named tenant-realm configuration and demo identity attributes on a fresh or existing local Keycloak database. It preserves account identity and credentials, does not provision application memberships and is not the runtime `IIdentityProvider`. The [P02b-6 decision package](roadmap/phase-02b-delivery-log.md#p02b-6-decision-package-2026-10-20) owns its approved field/capability boundaries; the [Step 3 record](roadmap/phase-02b-delivery-log.md#p02b-6-step-3--shared-seeddemo-lifecycle-2026-10-21) records delivered entry-point convergence and the Step 4 record owns remaining qualification. | | **Validated tenant identity** | P02b-5 request-local immutable projection of a successfully validated tenant access token: its issuer/subject, stable actor and optional scalar tenant/organization. Actor attribution is independent of tenant authority: a claimless actor can remain HostOnly and cannot enter protected reads. It proves neither live membership nor permission. Accepted [ADR-0064](decisions/0064-tenant-api-authentication-boundary.md) owns the contract; the P02b-5 delivery record owns implementation status. | | **Claimless principal** | A validated tenant API identity without a tenant claim. It keeps the HostOnly public ceiling; it cannot acquire tenant authority merely through its actor UUID. See [ADR-0064](decisions/0064-tenant-api-authentication-boundary.md#access-token-profile). | +| **BFF session owner** | Process-local credential and lifecycle authority in the native web process, separate from public request admission. Accepted [ADR-0070](decisions/0070-owned-tenant-bff-sessions.md) governs it; its opaque host-bound cookie proves no tenant membership or permission. | +| **BFF session revision** | Logical server-side version published by one successful session refresh, independent of cookie delivery. See [ADR-0070](decisions/0070-owned-tenant-bff-sessions.md#effective-lifetime-activity-and-refresh); P02b-7 owns implementation. | +| **Relying party (RP)** | The OIDC client that relies on the identity provider's validated response. LearnStack's native BFF uses openid-client for this role; it is distinct from the API's access-token authority. See [ADR-0070](decisions/0070-owned-tenant-bff-sessions.md#provider-and-library-boundary). | +| **BFF browser group** | Host-bound server index connecting one opaque browser binding to its pending transactions and current local session. Its epoch fences competing callbacks and logout; it grants no tenant authority. See [ADR-0070](decisions/0070-owned-tenant-bff-sessions.md#browser-transactions-and-response-races). | | **AccountSite** | The one authenticated host-backed API read: validated actor UUID, resolved tenant/organization and the existing public site projection. It grants no membership or application permission. See [ADR-0064](decisions/0064-tenant-api-authentication-boundary.md#closed-authenticated-read). | | **TenantAssertionSource** | The closed diagnostic origin of an assertion: `header` or validated `jwt-claim`. It selects neither tenant nor organization. See [ADR-0064](decisions/0064-tenant-api-authentication-boundary.md#rejection-recorder-and-diagnostic-contracts). | | **TenantAssertionAuditBudget** | Singleton API-instance allowance shared by scoped recorders: 600 authenticated audit write attempts per independently defended host tenant per first-request-anchored monotonic minute. Failed writes consume allowance; suppressed occurrences retain refusal, counter and one attributed Warning without a durable row or database I/O. It grants no actor-coverage guarantee, adds no tenant-registry cap and cannot be overridden by tenant `AuditConfig`. Admitted events remain MUST; general mutation durability is unchanged. Accepted [ADR-0065](decisions/0065-bounded-tenant-assertion-diagnostics.md) owns the evidence-loss and capacity costs; the [remediation record](roadmap/phase-02b-delivery-log.md#p02b-5-external-review-remediation-2026-10-20) records delivered implementation and local proofs. | @@ -297,7 +301,7 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | Term | Definition | |------|------------| | **Ingress Provenance** | The private, domain-separated HMAC envelope over the native web listener's captured host, canonical socket peer, method and request target. It is not a tenant identity, API-hop header or browser-visible value. P02d-5 Step 1 implements capture/verification; Step 3 delivers downstream caller admission; Step 4 proves the real API/render boundary. See [ADR-0053](decisions/0053-trusted-public-server-rendering.md). | -| **BFF (Backend-for-Frontend)** | The Next.js server-side proxy layer in `app/api/` that holds session cookies, refreshes Keycloak tokens silently, and forwards calls to the .NET API with `Authorization` and tenant + organization headers. The browser never sees refresh tokens. | +| **BFF (Backend-for-Frontend)** | The app-local server boundary serving browser-specific protocol and API needs. It is not a general proxy or a tenant/organization selector: [ADR-0064](decisions/0064-tenant-api-authentication-boundary.md) confines authenticated reads, and [ADR-0053](decisions/0053-trusted-public-server-rendering.md) owns the trusted host hop. Credential custody and session delivery follow [ADR-0070](decisions/0070-owned-tenant-bff-sessions.md); no tenant/organization authority headers are forwarded from the browser. | | **Idempotency Key** | A client-supplied `Idempotency-Key` header on unsafe operations with external side effects (payments, notification sending, recording start/stop). It is a **nonce inside a tenant's key space, not an identity**: the stored record is addressed by `(tenant, key)` and carries a fingerprint of the organization, principal, method, path, query and body, so the same key presented for a different request is refused rather than replayed. Inbound webhooks do **not** use it — they deduplicate on `(provider, event_id)`. See [ADR-0037](decisions/0037-idempotency-key-contract.md) and [04-api-design.md § Idempotency](standards/04-api-design.md). | | **Problem Details** | RFC 7807 JSON error envelope used by every LearnStack error response: `type`, `title` (the `lockey_*` key), `status`, `code`, `messageKey`, `instance`, `correlationId`, and `errors` for field-level failures. There is no `detail` — an earlier version of this entry listed one and omitted `messageKey`. The canonical example lives in [09-error-handling.md § API Surface](standards/09-error-handling.md); this entry names the fields rather than restating it. | | **Hub Contract Surface** | The enumerated set of endpoints between LearnStack core and the Hub, governed by **two invariants** rather than by a count ([ADR-0034](decisions/0034-hub-contract-surface-invariant.md)): (1) the Hub stores no tenant content — only tenant metadata: plan, subscription, licence, custom domain, compliance caps, aggregated usage; (2) every crossing goes through a named adapter — `IEntitlementProvider`, `IUsageReporter`, `IHubTenantSync` — and no other type may hold a Hub client. All crossings carry mTLS + RS256 JWT + HMAC body signature. Adding an endpoint still requires an ADR, because the surface is a cross-repository contract. The superseded "closed at four endpoints" phrasing was never true, and protecting the number is what caused TLS private keys to be tunnelled through the entitlement payload. ADR-0034 § The endpoint set is the authoritative enumeration; this entry deliberately does not restate a count, because a count here is a second thing to keep in step. | diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 6847d264..efb2db5e 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -50,8 +50,8 @@ not deferred to the showcase phase. maps each exit criterion to its proof. - [Phase 02b: Events, Background Jobs, Identity, and Session](phase-02b-events-auth.md) — **in progress**: P02b-0–5 complete and merged through PRs #29–#33; P02b-6's - implementation merged through PR #34; P02b-7–8 pending. Its Status block owns - packet state. + implementation merged through PR #34. Its Status block owns remaining packet + state and current delivery. - [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) - [Phase 05: Education Catalog and Learning Content](phase-05-education-learning-content.md) diff --git a/docs/roadmap/phase-02b-delivery-log.md b/docs/roadmap/phase-02b-delivery-log.md index a558a571..7071aa59 100644 --- a/docs/roadmap/phase-02b-delivery-log.md +++ b/docs/roadmap/phase-02b-delivery-log.md @@ -156,7 +156,7 @@ each packet decided and delivered, as - [ADR-0066 acceptance and implementation (2026-10-20)](#adr-0066-acceptance-and-implementation-2026-10-20) — 135 lines - [P02b-5 systematic proof and document corrections (2026-10-20)](#p02b-5-systematic-proof-and-document-corrections-2026-10-20) — 399 lines - [P02b-5 merge and closeout (2026-10-20)](#p02b-5-merge-and-closeout-2026-10-20) — 100 lines -- [P02b-6 — Realm and seed](#p02b-6--realm-and-seed) — 5,162 lines +- [P02b-6 — Realm and seed](#p02b-6--realm-and-seed) — 5,181 lines - [P02b-6 decision package (2026-10-20)](#p02b-6-decision-package-2026-10-20) — 446 lines - [P02b-6 Step 1 — executable realm and identity declaration (2026-10-21)](#p02b-6-step-1--executable-realm-and-identity-declaration-2026-10-21) — 97 lines - [P02b-6 Step 2 — owned warm-realm reconciliation (2026-10-21)](#p02b-6-step-2--owned-warm-realm-reconciliation-2026-10-21) — 553 lines @@ -195,6 +195,76 @@ each packet decided and delivered, as - [P02b-6 aggregate producer-state refusal attribution (2026-10-22)](#p02b-6-aggregate-producer-state-refusal-attribution-2026-10-22) — 73 lines - [P02b-6 pending aggregate metadata acquisition (2026-10-22)](#p02b-6-pending-aggregate-metadata-acquisition-2026-10-22) — 110 lines - [P02b-6 hosted browser dependency removal (2026-10-23)](#p02b-6-hosted-browser-dependency-removal-2026-10-23) — 45 lines + - [P02b-6 correction merge and closeout (2026-10-23)](#p02b-6-correction-merge-and-closeout-2026-10-23) — 19 lines +- [P02b-7 — BFF session](#p02b-7--bff-session) — 3,133 lines + - [P02b-7 decision package (2026-10-23)](#p02b-7-decision-package-2026-10-23) — 213 lines + - [P02b-7 proposal review round 1 (2026-10-23)](#p02b-7-proposal-review-round-1-2026-10-23) — 35 lines + - [P02b-7 proposal review round 2 (2026-10-23)](#p02b-7-proposal-review-round-2-2026-10-23) — 26 lines + - [P02b-7 revised decision package (2026-10-23)](#p02b-7-revised-decision-package-2026-10-23) — 202 lines + - [P02b-7 revised proposal review round 1 (2026-10-23)](#p02b-7-revised-proposal-review-round-1-2026-10-23) — 27 lines + - [P02b-7 revised proposal review round 2 and readiness (2026-10-23)](#p02b-7-revised-proposal-review-round-2-and-readiness-2026-10-23) — 34 lines + - [P02b-7 acceptance and implementation (2026-10-23)](#p02b-7-acceptance-and-implementation-2026-10-23) — 33 lines + - [P02b-7 Step 1 — inactive RP foundation and provider events (2026-10-23)](#p02b-7-step-1--inactive-rp-foundation-and-provider-events-2026-10-23) — 48 lines + - [P02b-7 Step 1 review round 1 and corrections (2026-10-23)](#p02b-7-step-1-review-round-1-and-corrections-2026-10-23) — 31 lines + - [P02b-7 Step 1 review round 2 and closeout (2026-10-23)](#p02b-7-step-1-review-round-2-and-closeout-2026-10-23) — 35 lines + - [P02b-7 Step 2 — native session ownership implementation (2026-10-23)](#p02b-7-step-2--native-session-ownership-implementation-2026-10-23) — 45 lines + - [P02b-7 Step 2 review round 1 and visitor correction (2026-10-23)](#p02b-7-step-2-review-round-1-and-visitor-correction-2026-10-23) — 24 lines + - [P02b-7 Step 2 review round 2 and closeout (2026-10-23)](#p02b-7-step-2-review-round-2-and-closeout-2026-10-23) — 19 lines + - [P02b-7 Step 3 — closed HTTP and request projection (2026-10-23)](#p02b-7-step-3--closed-http-and-request-projection-2026-10-23) — 46 lines + - [P02b-7 Step 3 review round 1 (2026-10-23)](#p02b-7-step-3-review-round-1-2026-10-23) — 35 lines + - [P02b-7 Step 3 review round 2 and closeout (2026-10-23)](#p02b-7-step-3-review-round-2-and-closeout-2026-10-23) — 20 lines + - [P02b-7 Step 4 — optional activation and session UI (2026-10-23)](#p02b-7-step-4--optional-activation-and-session-ui-2026-10-23) — 64 lines + - [P02b-7 Step 4 review round 1 (2026-10-23)](#p02b-7-step-4-review-round-1-2026-10-23) — 44 lines + - [P02b-7 Step 4 review round 2 and closeout (2026-10-23)](#p02b-7-step-4-review-round-2-and-closeout-2026-10-23) — 26 lines + - [P02b-7 Step 5 — real lifecycle proof in progress (2026-10-24)](#p02b-7-step-5--real-lifecycle-proof-in-progress-2026-10-24) — 61 lines + - [P02b-7 Step 5 — implementation and local qualification (2026-10-24)](#p02b-7-step-5--implementation-and-local-qualification-2026-10-24) — 37 lines + - [P02b-7 Step 5 — independent review round 1 (2026-10-24)](#p02b-7-step-5--independent-review-round-1-2026-10-24) — 16 lines + - [P02b-7 Step 5 — stopped-provider public demo (2026-10-24)](#p02b-7-step-5--stopped-provider-public-demo-2026-10-24) — 18 lines + - [P02b-7 Step 5 — round 1 corrections and qualification (2026-10-24)](#p02b-7-step-5--round-1-corrections-and-qualification-2026-10-24) — 46 lines + - [P02b-7 Step 5 — independent review round 2 (2026-10-24)](#p02b-7-step-5--independent-review-round-2-2026-10-24) — 24 lines + - [P02b-7 Step 5 — round 2 corrections and local closeout (2026-10-24)](#p02b-7-step-5--round-2-corrections-and-local-closeout-2026-10-24) — 36 lines + - [P02b-7 hosted frontend ingress correction (2026-10-24)](#p02b-7-hosted-frontend-ingress-correction-2026-10-24) — 31 lines + - [P02b-7 first hosted A — fixture corrections (2026-10-24)](#p02b-7-first-hosted-a--fixture-corrections-2026-10-24) — 56 lines + - [P02b-7 second hosted A — remaining handoff precondition (2026-10-24)](#p02b-7-second-hosted-a--remaining-handoff-precondition-2026-10-24) — 46 lines + - [P02b-7 third hosted A — closed browser-readiness evidence (2026-10-24)](#p02b-7-third-hosted-a--closed-browser-readiness-evidence-2026-10-24) — 44 lines + - [P02b-7 first complete A and failed B — preserve the first cause (2026-10-24)](#p02b-7-first-complete-a-and-failed-b--preserve-the-first-cause-2026-10-24) — 52 lines + - [P02b-7 hosted causal-restart correction (2026-10-24)](#p02b-7-hosted-causal-restart-correction-2026-10-24) — 71 lines + - [P02b-7 hosted BFF-browser diagnosis (2026-10-24)](#p02b-7-hosted-bff-browser-diagnosis-2026-10-24) — 53 lines + - [P02b-7 implementation and hosted qualification closeout (2026-10-24)](#p02b-7-implementation-and-hosted-qualification-closeout-2026-10-24) — 72 lines + - [P02b-7 external review corrections and disposition (2026-10-24)](#p02b-7-external-review-corrections-and-disposition-2026-10-24) — 131 lines + - [P02b-7 correction reviews and local validation closeout (2026-10-24)](#p02b-7-correction-reviews-and-local-validation-closeout-2026-10-24) — 41 lines + - [P02b-7 correction source handoff (2026-10-24)](#p02b-7-correction-source-handoff-2026-10-24) — 16 lines + - [P02b-7 startup-counter cleanup confirmation (2026-10-24)](#p02b-7-startup-counter-cleanup-confirmation-2026-10-24) — 18 lines + - [P02b-7 hosted recovery proof follow-up (2026-10-24)](#p02b-7-hosted-recovery-proof-follow-up-2026-10-24) — 51 lines + - [P02b-7 fixture image failure classification (2026-10-24)](#p02b-7-fixture-image-failure-classification-2026-10-24) — 58 lines + - [P02b-7 retained correction-source A/B qualification (2026-10-24)](#p02b-7-retained-correction-source-ab-qualification-2026-10-24) — 40 lines + - [P02b-7 second correction package and ADR-0071 proposal (2026-10-24)](#p02b-7-second-correction-package-and-adr-0071-proposal-2026-10-24) — 63 lines + - [ADR-0071 acceptance and review corrections (2026-10-24)](#adr-0071-acceptance-and-review-corrections-2026-10-24) — 33 lines + - [P02b-7 correction foundation implementation (2026-10-24)](#p02b-7-correction-foundation-implementation-2026-10-24) — 48 lines + - [P02b-7 foundation first review corrections (2026-10-24)](#p02b-7-foundation-first-review-corrections-2026-10-24) — 18 lines + - [ADR-0071 dependent mapping and composed controls (2026-10-24)](#adr-0071-dependent-mapping-and-composed-controls-2026-10-24) — 25 lines + - [P02b-7 foundation second review closeout (2026-10-24)](#p02b-7-foundation-second-review-closeout-2026-10-24) — 17 lines + - [P02b-7 recovery UI and proof observation implementation (2026-10-24)](#p02b-7-recovery-ui-and-proof-observation-implementation-2026-10-24) — 39 lines + - [P02b-7 CI and real-provider proof corrections (2026-10-24)](#p02b-7-ci-and-real-provider-proof-corrections-2026-10-24) — 41 lines + - [P02b-7 current carrier reconciliation (2026-10-24)](#p02b-7-current-carrier-reconciliation-2026-10-24) — 49 lines + - [P02b-7 policy, UI and evidence first review (2026-10-24)](#p02b-7-policy-ui-and-evidence-first-review-2026-10-24) — 27 lines + - [P02b-7 real-provider clock and HTML negative correction (2026-10-24)](#p02b-7-real-provider-clock-and-html-negative-correction-2026-10-24) — 37 lines + - [P02b-7 second review and final SDK proof census (2026-10-24)](#p02b-7-second-review-and-final-sdk-proof-census-2026-10-24) — 27 lines + - [P02b-7 SDK first review and counterfactual qualification (2026-10-24)](#p02b-7-sdk-first-review-and-counterfactual-qualification-2026-10-24) — 24 lines + - [P02b-7 final correction source handoff (2026-10-24)](#p02b-7-final-correction-source-handoff-2026-10-24) — 36 lines + - [P02b-7 recurrence qualification failure observation (2026-10-24)](#p02b-7-recurrence-qualification-failure-observation-2026-10-24) — 38 lines + - [P02b-7 recurrence observation review closeout (2026-10-24)](#p02b-7-recurrence-observation-review-closeout-2026-10-24) — 23 lines + - [P02b-7 browser launch qualification observation (2026-10-24)](#p02b-7-browser-launch-qualification-observation-2026-10-24) — 43 lines + - [P02b-7 browser observation first review (2026-10-24)](#p02b-7-browser-observation-first-review-2026-10-24) — 17 lines + - [P02b-7 same-source qualification and causal fixture scheduling (2026-10-24)](#p02b-7-same-source-qualification-and-causal-fixture-scheduling-2026-10-24) — 67 lines + - [P02b-7 final correction qualification (2026-10-24)](#p02b-7-final-correction-qualification-2026-10-24) — 84 lines + - [P02b-7 review correction contracts and proof controls (2026-10-24)](#p02b-7-review-correction-contracts-and-proof-controls-2026-10-24) — 116 lines + - [P02b-7 correction review round 1 — ownership and proof (2026-10-24)](#p02b-7-correction-review-round-1--ownership-and-proof-2026-10-24) — 27 lines + - [P02b-7 correction review round 2 — independent recheck (2026-10-24)](#p02b-7-correction-review-round-2--independent-recheck-2026-10-24) — 31 lines + - [P02b-7 hosted observer argument failure and deterministic control (2026-10-24)](#p02b-7-hosted-observer-argument-failure-and-deterministic-control-2026-10-24) — 43 lines + - [P02b-7 observer correction review round 1 (2026-10-24)](#p02b-7-observer-correction-review-round-1-2026-10-24) — 24 lines + - [P02b-7 observer correction review round 2 and source freeze (2026-10-24)](#p02b-7-observer-correction-review-round-2-and-source-freeze-2026-10-24) — 26 lines + - [P02b-7 review correction qualification (2026-10-24)](#p02b-7-review-correction-qualification-2026-10-24) — 83 lines - [Status archive](#status-archive) — 112 lines @@ -17678,6 +17748,3158 @@ green assignment. [ADR-0063](../decisions/0063-complete-bounded-ci-test-shards.m requires both variants only for plan, algorithm or fixture-lifetime changes. This decision replaces the final-source A/B requirement stated in the entries above. +### P02b-6 correction merge and closeout (2026-10-23) + +Read-only GitHub verification confirms [PR #35](https://github.com/HodeTech/LearnStack/pull/35) +merged at `2026-10-23T14:55:58Z`, merge commit +`935d8da66878286d6a1695f3d41d985ebce4e791`. Its source is +`395d078cd07af6619ed3b8237a368e0589c10dfc`. +[Run 39964808314](https://github.com/HodeTech/LearnStack/actions/runs/39964808314), +attempt 1, reports completed/success for that exact source, with all 27 CI jobs +and all seven required contexts successful. External review-bot status is not +included in that CI count. This verification reads run/check metadata; it does +not download or independently revalidate every test artifact. + +The preceding record owns the maintainer's one-green-assignment qualification +decision. Earlier failed attempts and the unresolved historical constructor +cancellation retain their recorded evidence limits; a green source does not +prove a historical cause. PR #34's implementation and this merged correction +close P02b-6. The newly requested P02b-7 opens its own decision pass below; +no session implementation or G13 acceptance is inferred from either merge. + +## P02b-7 — BFF session + +### P02b-7 decision package (2026-10-23) + +**Historical first proposal.** This unaccepted draft and its reviews below are +retained for chronology. The +[revised proposal](#p02b-7-revised-decision-package-2026-10-23) replaces its +library/cookie/admission/event-policy assumptions before acceptance. + +**Status: Proposed — maintainer approval pending.** The user requested this +packet on `development`, with document review before implementation and a +commit plus two fresh independent review rounds for every step. G13's session +part remains Open. [ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md) +proposes the server-side custody and exact admission authority; no dependent +runtime or Accepted ADR body is changed by this proposal. + +#### Problem and inherited boundary + +P02b-6 issues the strict API profile and reserves two exact callback URLs, but +the browser cannot sign in. P02b-7 connects that provider to one safe browser +session, the existing AccountSite read and guarded Studio/Portal shells. It +does not add Phase 03 permissions, membership, account administration or a +generic authenticated API proxy. Public sites retain their credential-free +transport, live host admission and stopped-Keycloak rendering proof. + +The document/source pass inspects `395d078c`. PR #34's merged implementation +is the starting point; PR #35 is now merged, as independently checked in the +[correction closeout](#p02b-6-correction-merge-and-closeout-2026-10-23). +This new packet changes no unexplained background-job test. Its PR/CI evidence must +identify its actual final source rather than borrow earlier receipts. + +#### Reviewed documents and verified dispositions + +| Surface | Finding at the inspected source | Required action | +|---|---|---| +| Phase 02b G12/G13, session scope and completion criteria; P02b-5/6 Accepted packages | Client, callback, token profile and host authority are settled; session custody/lifetimes/state machine are not | Preserve settled decisions; accept ADR-0070 and this sequence before dependent code. | +| ADR-0004/0036/0064/0067/0068 | Provider, matrix, public ceiling, exact AccountSite read, development issuance and safe reconciliation remain authoritative | No password/permission/membership/provider-role change; no production token or realm policy rewrite. | +| ADR-0053/0054/0055 | Public caller excludes credentials; listener permits only GET/HEAD; native request holder owns only request/public state | Add a separate authenticated caller and session holder; disclose exact auth POST admission in ADR-0054 and additive caller seam in ADR-0053 at acceptance. | +| Security 11; Frontend 07; Architecture 13/14 | Cookie-held refresh prose, explicit `Domain=` advice, implicit Server Action CSRF and independent-logout claims conflict with the complete session contract | Reconcile to server custody, no Domain, three CSRF classes, response ownership and shared-provider-SSO limits under ADR-0070. | +| Audit 18; Architecture 13/31; Phase 03 | Generic login/revocation examples and provider-owned events need an explicit BFF disposition | Record the proposed provider-owned/diagnostic-only boundary and its durable-evidence loss; do not borrow AccountSite Off or invent an audit-write API. | +| Frontend 03/07/08/16; Architecture 14; skills for routes/feature gates | Server-only SDK use, localization, timeout warning and future `auth()` examples must agree with real helpers | Implement localized sign-in/error/timeout controls and update both skill illustrations with the shipped safe projection. | +| Standards 06/10/13/14/17/18/20/21; Phase 02d/03/06/11; glossary | Existing test, ingress, observability, record and demand-gating owners apply | Register actual proof owners, bounded diagnostics and the distributed-store trigger; keep full browser OIDC journey in Phase 06. | +| `apps/web` package and official tagged library sources | Next 15.5.27/React 19; Auth.js absent. Beta.32 is compatible; RSC auth discards Set-Cookie; no automatic refresh owner | Explicitly approve the beta pin; prove supported hooks and response-owned state transitions before activation. | +| Native ingress, middleware, root/i18n layouts, public source graph, SDK, launcher environment | POST and auth paths are refused; Cookie/Origin are dropped downstream; layouts require public admission; AccountSite transport absent | Change exact branches and graph ownership together, with real pinned-Next negative/positive controls. Do not exempt whole middleware or leak secrets into the public-only launch. | + +#### Decisions presented together + +ADR-0070 owns the state machine and costs; this package owns its closed route +set, delivery sequence and proof map. Approval accepts all of the following: + +- Process-local credential custody, an encrypted opaque host-only cookie and + restart sign-out. No distributed/durable continuity or application identity + persistence is claimed. The future named store adapter is a Phase 11 trigger. +- Thirty-minute idle/eight-hour absolute lifetime, sixty-second refresh lead, + accessible idle warning/extension, and earlier provider expiry. Asset, prefetch + and passive status traffic do not keep a session alive. +- At most 1,024 total owner entries, with at most 128 pending sign-ins and a + five-minute pending lifetime. Logout reuses its existing entry as a bounded + return transaction, without spending a sign-in reservation or allocating + another slot. Closing/in-flight owners retain their slots. Full capacity + refuses new admission with 503; unrelated tenants can be affected. These are + initial resource budgets, not measured fit. +- One refresh owner/version, revocation before logout I/O and no late-result + resurrection. An uncertain/failed refresh ends the local session without + grant replay; short provider faults can require a new sign-in. +- Keycloak SSO logout with honest failure and cross-host consequences. The + provider-directed ID-token-hint redirect is a bounded exception; access and + refresh tokens never enter a browser-visible surface. +- Provider-owned authentication events and closed local lifecycle diagnostics; + no durable application session audit trail or new audit API. Existing MUST + mutation/rejection coverage stays unchanged. +- Exact auth-route admission, separate authenticated AccountSite transport, + strict same-origin plus the appropriate CSRF proof, and the beta library pin. + +The earlier 900s issuance setting and API acceptance/skew are unchanged. No +session default is inferred from them. No additional decision is intentionally +left for implementation; a disproved feasibility premise reopens this pass. + +#### Proposed exact route and input matrix + +Every path first requires verified native provenance and successful live API +host bootstrap, independently of cookies. Health/assets retain their current +exemptions and methods. Public/bootstrap API calls carry no browser credential. + +| Path | Methods | Required evidence and behavior | +|---|---|---| +| `/api/auth/signin` | GET/HEAD | Same-host localized sign-in entry; only return destinations `/`, `/studio`, `/portal`. No submitted provider/absolute URL. | +| `/api/auth/csrf` | GET/HEAD | Auth.js pre-session CSRF response; host-only cookie, no-store; no provider traffic. | +| `/api/auth/signin/keycloak` | POST | Exact Origin, Auth.js pre-session CSRF and pending reservation; fixed provider and callback. Bind the reservation to the library-generated state before redirect. | +| `/api/auth/callback/keycloak` | GET | Matching host, state/nonce/verifier and unexpired single-use reservation, then library-validated exchange; no anonymous fallback for a failed callback. | +| `/api/auth/session` | GET/HEAD | Explicit minimal projection or unauthenticated state; passive read does not extend idle time. Never expose handle/provider profile/tokens. | +| `/api/auth/error` | GET/HEAD | Closed localized recovery state; no reflection of provider error text or callback parameters. | +| `/api/auth/signout` | POST | Exact Origin and session-bound CSRF in addition to the library's applicable check; revoke locally before end-session redirect. GET cannot sign out. | +| `/api/auth/extend` | POST | Exact Origin and session-bound CSRF; touch only an active, unexpired session, never its absolute deadline. | +| `/` with an owned logout-return transaction | GET | Check returned logout state and clear its cookie before the ordinary same-host root redirect. The registered post-logout URI remains exact `/`; arbitrary query values grant nothing. | +| `/studio`, `/portal` | GET/HEAD | Response-owned session admission, then safe RSC projection and exact AccountSite read; otherwise redirect to sign-in. No application writes. | + +No catch-all action is admitted merely because Auth.js implements it. Deny +providers/session-update/sign-out GET, other providers/methods, unsupported +Server Actions and alternate callback paths. HEAD never performs code exchange +or mutates authenticated state. Bound auth form bodies to 16 KiB, accept only +the declared URL-encoded scalar fields, and reject duplicate security fields. +Bound the full auth-cookie family to 16 KiB before Next; individual issued +cookies retain browser-compatible size/chunking. Header and transport limits +must cover slow/oversized input, not only a stated Content-Length. + +Use the native host and HTTPS port for the library request; discard automatic +environment/forwarded authority. A login transaction uses Auth.js state/PKCE/ +nonce checks, not a replacement protocol. Its native reservation has a shorter +five-minute authority even if the library's sealed cookie can decode longer. +Concurrent sign-in tabs use the last transaction-cookie family; failed older +callbacks cannot clear or replace a newer valid session. Provider requests have +one ten-second total deadline including body reads and a 256 KiB decoded body +ceiling; retained credentials still obey the smaller bundle limit. No automatic +token-request retry is introduced. + +`LEARNSTACK_BFF_ENABLED` is absent/false for a public-only launch. That mode +projects no BFF secrets and makes no provider call; the web-client secret +already used by realm tooling does not count as a partial BFF configuration. +Explicit true requires one complete, agreeing private group: issuer, existing +web-client secret and a separate freshly generated session encryption secret. +The session key never aliases the public hop/client secret. Partial/conflicting +enabled configuration refuses startup. Setup documents explicit enablement; +existing private files are not silently repaired or overwritten. Neither mode +contacts Keycloak at startup. Only BFF-enabled runtime receives client/session +secrets; API, data Seeder, browser, public-only CI proof and receipts do not. +When disabled, auth endpoints are masked 404 and Studio/Portal return a bounded +503 sign-in-unavailable state without AccountSite access. Public pages remain +unchanged; no protected shell is rendered as an anonymous fallback. + +#### Five implementation steps after acceptance + +Every step ends with a clean commit, then a fresh correctness/security reviewer +and a fresh evidence/corpus reviewer. A second different pair reviews the +committed result after verified findings are fixed and committed. Reviewers +start with standards-check and then code-review; the implementation owner +serializes real runtime/provider/browser work. Material fixes after round two +receive focused independent confirmation. Routine completion advances to the +next step automatically. New authority decisions return to the maintainer. + +1. **Library and inactive protocol foundation.** Pin dependencies/lockfile, + implement bounded provider configuration/transport, complete closed logger + overrides and safe Auth.js callbacks. Prove actual library code/state/nonce/ + PKCE behavior against an owned stub issuer and validate metadata/token/log + canaries. No production registration, route or listener exemption activates. +2. **Native session ownership.** Implement the store port/default/native holder, + transaction reservations, monotonic expiry, resource accounting, single-flight + refresh and revocation/stop lifecycle. Exercise held I/O, waiter cancellation, + refresh/logout/expiry races, restart, capacity and late outcomes. RSC auth + remains a safe projection; old cookies never restore absent state. +3. **Closed HTTP protocol and authenticated transport.** Implement the exact + ingress/middleware/route matrix, bounded cookie/form carriers, response-owned + refresh, CSRF classes, logout return, pure authenticated SDK wrapper/parser + and app-local configured AccountSite caller. Prove real pinned-Next behavior, + immutable per-request projection and missing/forged provenance refusals. + Production route registration and native exemptions stay closed; test-owned + wiring exercises the full path before the complete guards ship together. +4. **Atomic activation and usable signed-in surface.** Wire the complete optional + BFF configuration group, known-host sign-in/recovery, Studio/Portal guards, + AccountSite views, logout and localized timeout warning/extension. Retain + accessible keyboard/focus/error behavior, no-store/history revalidation and + the public graph/caller/IdP-down boundary. Update the two skills' examples. +5. **Production proof and closeout.** Exercise route-level actual Auth.js/stub + issuer and a real BFF/warm-Keycloak/API chain: a seed user completes code + exchange through the actual BFF, its cookie authorizes AccountSite on each + host, and provider logout is verified. An HTTP harness suffices for this + provider chain; independent token issuance tests cannot replace it. Run the + owned browser against both retained HTTPS hosts to prove cookie storage/send/ + isolation and logout/back behavior. Preserve the nine-page stopped-Keycloak + public demo. + Complete catalogue controls, docs, exact CI discovery/assignment and measured + budget fit. Record actual source-bound checks/reviews before preparing the PR; + approval or local tests do not certify hosted CI or authorize merge. + +#### Proof and carrier map + +ADR-0070 names four new canonical obligations and their actual frontend owners. +Register them and update catalogue count/status controls at acceptance; keep +each Registered until its implementation and proof ship together. Existing +public/native/request-holder fences get exact extensions, never broad exclusions. + +Required negatives include wrong/missing state/verifier/nonce, duplicate fields, +unregistered callback, wrong issuer/audience/purpose/actor/scope, query/forwarded +host substitution, default vendor log output, script/RSC/session credential +canaries, stale cookies, failed refresh, logout races, cross-demo-Origin POST, +correct-Origin/missing-token POST, capacity and HMR duplicate ownership. Each +has a reachable positive control. Actual Auth.js cookie chunker grow/shrink/ +clear controls complement naturally small opaque production cookies. + +`TenancyIdentity/TenantBffSessionTests.cs` owns the real BFF/warm-provider chain +in backend Integration, with the Docker trait and real app-role API/database +boundary. It reuses the existing production-rendering fixture's Node/tooling +and owned browser/process lifecycle. The ordinary jobs already provision those +tools; no new execution VM or required-check name is proposed. Register its +complete discovered population and measure actual setup/proof/cleanup before +claiming fit within the existing budgets. Browser process/version evidence is +retained; the separate public Lighthouse proof keeps its pinned browser policy. + +Real TLS browser evidence uses the supported native launcher and both actual +seed hostnames; developer hosts/trust setup remains manual. An isolated CI +fixture may provide its owned hostname resolution/CA to its managed browser; +it does not modify workstation hosts or install machine trust. Record this as +stub-provider cookie proof, distinct from the real-provider HTTP chain and not +a full three-process real-Keycloak browser flow. +That full journey remains Phase 06. Backend provider/API tests still use the +real pinned realm; a stub never replaces those proofs. + +Acceptance updates G13 and the phase's custody/HTTP plan, appends named ADR +disclosures and reconciles Standards 07/11/18/21, Architecture 13/14/31, glossary +session terms, Phase 03's custody/permission boundary, Phase 11's store trigger, +environment/setup guidance and the +catalogue registry. Runtime slices update actual owners and test counts with +their first consumers. Update packet status only in the phase Status block; +dated records stay here and `make docs-index` regenerates this log's index. + +### P02b-7 proposal review round 1 (2026-10-23) + +Two initial read-only audits inspect the inherited contracts and frontend +feasibility before proposal authoring (`gpt-6-astra`, high contract/security; +`gpt-6.1-sol`, high source/evidence). They identify the Open session gate, +GET/HEAD-only ingress, missing authenticated transport, cookie/Domain/CSRF +carrier contradictions and provider-SSO logout scope. The proposal explicitly +answers these instead of treating existing Auth.js illustrations as runtime. + +A different fresh pair reviews proposal commit `baeb32fa` +(`gpt-6-astra`, high security/correctness; `gpt-6.1-sol`, high evidence/governance). +Both request changes. Verified findings and their dispositions: + +- A provider-directed logout redirect cannot guarantee local recovery after + navigation. The ADR now distinguishes pre-navigation recovery and later local + visits from browser/provider error pages; local revocation remains immediate. +- The shared pending-login/logout cap conflicted with guaranteed logout at full + sign-in admission. Logout now reuses its existing owner entry; only pending + sign-ins spend the 128 allowance, within the unchanged total 1,024 bound. +- Separate stub-BFF and real-token tests do not prove the required warm-realm + BFF sign-in. Step 5 now names an actual BFF/warm-Keycloak/AccountSite HTTP + chain and provider logout, with a Docker Integration owner. Browser cookie + proof remains separate from the full Phase 06 browser OIDC journey. +- Phase 03's refresh-storage wording also needs an acceptance-time custody + correction; it is now named in the carrier map. Proposed glossary terms make + no runtime or approval claim. + +The evidence reviewer independently verifies the package versions/licences, +Next/React peers and tagged Auth.js response/logger seams. Both preserve the +source-inspection versus runtime-proof boundary. No reviewer edits source or +runs a provider/build/browser. Whole-tree document guards, seven ADR parser +controls, relative-path checks and diff hygiene pass before the first draft +commit. A second different review pair inspects the corrected proposal before +maintainer approval is requested. + +### P02b-7 proposal review round 2 (2026-10-23) + +A different fresh pair (`gpt-6-astra`, high security/correctness; +`gpt-6.1-sol`, high evidence/governance) reviews the complete proposal through +`30933cbb`, not only the first findings. Both approve its design/evidence +boundary, with no verified Blocker or Major. The security reviewer requests +one wording correction: provider-free cookie-less requests must mean ordinary +public traffic, since the explicitly admitted sign-in/callback must contact the +issuer. The ADR now names that exception. + +Root self-review also catches an optional-configuration ambiguity: the existing +realm-tooling web secret cannot make a public-only launch partially configured. +An explicit `LEARNSTACK_BFF_ENABLED` opt-in now distinguishes the modes; both +reviewers inspect and approve that clarification. Disabled auth paths and +protected-shell unavailability are explicit, without anonymous protected views. + +The evidence reviewer independently verifies npm pins/peers/licences, tagged +library behavior, actual ordinary-job Node/pnpm/browser prerequisites and the +real warm-BFF/provider test owner. It passes the whole-tree document guards and +seven parser controls and verifies the committed generated index, while noting +it must be regenerated after the clarification. The security reviewer checks +the native/callback/safe-projection seams, expiry/refresh/revocation, CSRF and +resource ownership. Neither runs runtime/provider/build/browser work. Approval +here means proposal coherence, not maintainer acceptance or implementation. + + +### P02b-7 revised decision package (2026-10-23) + +**Status: Accepted — 2026-10-23.** The maintainer approved ADR-0070 and this +revised five-step package after both new review rounds. The initial proposal was +not accepted; its records remain historical. The acceptance entry below records +the source and bounded carrier changes. No runtime qualification is inferred. +[ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md) owns the revised +contract; this entry owns exact delivery/proof placement. + +#### Verified findings and dispositions + +| Review topic | Verified source/effect | Revision | +|---|---|---| +| Durable authentication evidence | Realm has no events settings; pinned provider defaults do not persist refresh events | Step 1 enables named development user events and qualified seven-day retention; Step 5 reads actual login/logout/refresh-error evidence. Explicit local-only evidence gaps remain. | +| Cheap pending/active saturation | 128 anonymous pending entries lack source caps; 1,024 entries lack subject/replacement policy | Add 16 pending per verified visitor IP and eight established/closing per issuer/subject across hosts, plus same-browser replacement. Preserve residual distributed/NAT/self-denial costs. | +| Library alternative and maintenance | Auth.js v5 is beta; official 2025 transition recommends Better Auth for new work; direct oauth4webapi is not custom crypto | Select openid-client 6.8.8 with a plain opaque cookie; fairly compare direct oauth4webapi and Better Auth, and explicitly replace Standards 07's Auth.js rule at acceptance. | +| Extension/provider idle | Realm idle is 1,800 seconds; a local touch does not renew provider idle | Warning uses effective minimum deadline; extension forces/joins real refresh and publishes actual renewed expiry. | +| Reclamation latency | A 64-entry batch every 30 seconds revisits 1,024 entries in eight minutes | Full finite scalar-state sweep and one full expiry pass before global-capacity refusal; retain in-flight ownership. | +| Carrier inventory | ADR-0035 lacks the distinct store; Architecture 25 targets auto-scaling; glossary describes tenant/org header proxying | Add the gated inventory amendment and Architecture 25/31/Phase 03/11 carriers. Correct the stale generic BFF definition using existing trusted-host/API authority. | +| Callback cookie race | Fixed Auth.js transaction names can clear a newer tab's cookie | Use independent per-transaction cookies plus server browser epochs; mismatched state changes no cookies; failed callbacks never clear a primary session cookie. | +| Activation/refresh ownership | Opt-in was package-only; expired-access refresh and idle-touch owner were underspecified | ADR now names explicit opt-in/disabled behavior, on-demand refresh after access expiry and response-owned idle updates. | +| Other notes | Backchannel owner, logout-token retention, RSC activity and dependency deduplication were incomplete | Name Phase 03's backchannel trigger; discard tokens after logout-URL construction; classify non-prefetch RSC; require one resolved dependency copy. | + +The audit/storage finding is accepted, not dismissed as a terminology issue. +Provider event history is still not an atomic durable BFF ledger. This remains +an explicit scoped Standards 18 decision requiring maintainer approval; local +expiry/provider outage does not magically create a provider event. + +#### Revised decisions presented together + +- Active RP library (`openid-client`) and plain random opaque cookies; no Auth.js + runtime, session-encryption key, credential-bearing cookie or user/account ORM. + OIDC/JOSE verification remains library-owned, explicitly signature-enabled. +- Native single-process authority, existing 30-minute/eight-hour limits, + effective provider deadline, on-demand/early refresh and real extension. + Restart signs sessions out; uncertainty ends the local session without replay. +- Global 1,024/pending 128 budgets plus source 16 and subject eight; no eviction + of another device. A successful validated same-browser replacement transfers + its own old credit only after old owned I/O settles. Full finite reclaim runs + before a global refusal. These are selected limits, not load measurements. + Logout-return transactions keep their minimal subject key and credit until + return consumption/expiry and actual owned-operation settlement; repeated + login/logout cannot evade the eight-owner cap. +- Browser epochs and per-transaction cookie binding prevent late publication. + Delayed already-sent cookie responses can still force reauthentication; no + uninterrupted-cookie delivery guarantee is made. +- Tenant development user-event persistence, with seven-day default retention + and explicit preservation/refusal of customized warm policy. This affects all + users of that realm, including account-console; Hub/admin-event policy is not + changed. Stored personal data and asynchronous retention have real costs. +- The prior local-logout, shared-SSO, provider navigation/outage and ID-token-hint + limitations remain. Immediate remote admin-disable/backchannel handling is + Phase 03 before its sign-out-everywhere contract, not an unowned omission. + +G12's callback/client topology, ADR-0067 issuance, ADR-0064 API profile/read +ceiling and the credential-free public caller remain unchanged. The accepted +P02b-6 field/preservation allowance receives a dated event-policy extension at +acceptance; no source-role, credential or SSO-timeout adjustment is authorized. + +#### Exact route and input matrix + +Every auth/page path first verifies native provenance and live API host +bootstrap. The public bootstrap carries no browser credential. Keep existing +health/assets admission, default GET/HEAD and production upgrade closure. + +| Path | Methods | Admission and response ownership | +|---|---|---| +| `/api/auth/signin` | GET/HEAD | Localized sign-in form with host-only random pre-session CSRF proof; return path only `/`, `/studio` or `/portal`. No password form or issuer call. | +| `/api/auth/csrf` | GET/HEAD | Same-origin pre-session CSRF response, no-store, no provider or native reservation. | +| `/api/auth/signin/keycloak` | POST | Exact Origin and double-submit CSRF; source/global reservation, then fixed-provider authorization request. Store library-generated state/nonce/verifier and independent browser/transaction binding. | +| `/api/auth/callback/keycloak` | GET | Exact state, transaction cookie, host/browser epoch and single-use reservation; library code/nonce/PKCE/signature validation followed by actual-token AccountSite validation before publication. | +| `/api/auth/session` | GET/HEAD | Minimal active/anonymous projection and effective expiry; passive read makes no refresh/idle update or provider call. No handles/profile/tokens. | +| `/api/auth/error` | GET/HEAD | Closed localized recovery; no reflection of provider error text/query values. | +| `/api/auth/signout` | POST | Exact Origin and current-session CSRF; close browser epoch/revoke first, reuse owner entry for logout proof, build end-session URL, then discard credentials. | +| `/api/auth/extend` | POST | Exact Origin and current-session CSRF; force/join refresh even for fresh access, then publish actual effective expiry; absolute deadline never moves. | +| `/` with owned logout-return proof | GET | Host/browser-bound returned state; consume/clear only that return proof, then ordinary same-host root redirect. Correlation alone is not remote-termination proof. | +| `/studio`, `/portal` | GET/HEAD | Response-owned session admission, then safe RSC projection/AccountSite. Missing/ended session redirects to sign-in; disabled BFF returns bounded 503. No authoring capability. | + +No generic catch-all auth action, session update, arbitrary provider/target, +sign-out GET or Server Action is admitted. HEAD is bodyless and never exchanges +a code, refreshes or updates authenticated idle state. Auth form bodies are at +most 16 KiB, URL-encoded declared scalar fields only; duplicate security fields, +unsupported media types and slow/oversized input refuse before dispatch. +Reject browser Authorization and ambiguous/oversized cookies. The whole auth +cookie family is bounded at 16 KiB, including browser/session/pre-CSRF and +per-transaction names; reject foreign/duplicate format rather than arbitrary +chunk reconstruction. The session itself is fixed-size, not chunked. + +Provider operations have a ten-second total deadline through discovery/token/ +JWKS/body work and a 256 KiB decoded body ceiling. AccountSite uses its separate +existing finite API caller budget. Unknown/unbounded provider refresh lifetime, +invalid signatures or wrong protected identity never fall back to anonymous +callback success. A normal public response after later session invalidation can +remain anonymous 200, as required. + +`LEARNSTACK_BFF_ENABLED` absent/false selects public-only operation. True requires +`LEARNSTACK_BFF_ISSUER` and the existing `KEYCLOAK_WEB_CLIENT_SECRET`, with source +agreement. No session-encryption key is needed. Ignore automatic Auth.js-style +URL/trust configuration and all forwarded authority. Only the BFF-enabled web +receives this group; API/data Seeder/public-only CI receive no BFF client secret. +Existing private values are not overwritten. Startup makes no provider call. + +#### Five revised implementation steps after acceptance + +Each step is committed, followed by a fresh independent correctness/security +and evidence/corpus pair, verified fixes/commit, then a different fresh pair. +Material fixes after the second round receive focused confirmation before the +next step. The primary agent owns serialized runtime/provider/browser work; +reviewers inspect source/offline evidence. Routine progress continues without +new permission, but an actual new authority decision returns for approval. + +1. **Inactive RP foundation and provider-event policy.** Pin the RP library and + exact resolved dependencies, implement bounded lazy configuration/transport, + explicit signature-enabled grant validation and closed diagnostics. Extend + fresh export and the shared warm reconciler only for the qualified event + policy: effective selection union, retention qualification, preserving + listeners/admin events/unrelated realm state, explicit bounded readback and + zero-write rerun. Retention conflicts refuse before preparation/ordinary + writes. Retain the current 5s/120s tooling waits and response budgets; no Admin + credentials enter web runtime. Test actual library grants against an owned + stub and actual fresh/warm provider event persistence independently. Keep + native exemptions/routes/configuration inactive in production. +2. **Native session/transaction ownership.** Implement one store/holder with + global/source/subject accounting, browser epochs, transaction cookies, + verified same-browser replacement, effective deadlines, full reclamation, + one refresh owner and revocation/stop joining. Exercise source 16 versus + global 128, subject eight versus total 1,024, existing-service continuation, + failed replacement, held old I/O, repeated logout without return, competing + tabs, late callbacks/cookies, + waiter cancellation, on-demand refresh, extension and shutdown. Prove no + expired settled entries cause a capacity refusal after the full pass. +3. **Closed HTTP protocol and authenticated SDK.** Implement exact ingress, + middleware, route/field/CSRF matrix, small cookie parser, response-owned idle/ + refresh/projection, logout return and the exact authenticated AccountSite + wrapper/configured caller. Test actual pinned Next provenance and lifetime + behavior, including no refresh/idle writes in RSC facade or passive/HEAD/ + prefetch traffic. Test-owned wiring exercises the full path; production route + registration/exemptions remain closed until the guards ship together. +4. **Atomic activation and usable session UI.** Wire explicit optional BFF + configuration, localized form/recovery, Studio/Portal guards, AccountSite, + same-origin logout and effective-deadline timeout warning/extension. Verify + keyboard/focus/error behavior, non-prefetch RSC navigation, no-store/history + revalidation and public anonymous/source/call-count isolation. Update both + skills to the real app-local `auth()` facade without future permissions. +5. **Real lifecycle proof and closeout.** Through actual BFF plus a reconciled + warm real Keycloak realm, sign a seed user in, consume its cookie for each + host's AccountSite and verify provider logout plus persisted provider event + types. Verify real extension advances provider idle and respects its maximum. + Independent real-token/API controls remain. Run the native HTTPS browser on + both retained seed hosts for actual cookie storage/send/isolation, callback + binding and logout/back behavior. Preserve the stopped-Keycloak nine-page + public demo. Finish catalogue/docs/discovery, measure actual fixture/job + budgets, and record exact-source hosted evidence before the PR is ready. + +#### Test ownership and acceptance carriers + +ADR-0070 names four new Registered obligations with actual frontend owners. +The existing `Tenant_Realm_Issuance_Matches_The_Api_Profile` and +`Development_Realm_Reconciliation_Preserves_Accounts` owners gain event-policy +assertions with their implementation; preserve Implemented status for existing +subjects and name the extension until its first proof ships. Catalogue count/ +status controls change with registration, never by an unsupported count claim. + +`TenancyIdentity/TenantBffSessionTests.cs` owns the real BFF/warm-provider HTTP +chain under Docker with the real app-role API/database boundary. Provider +fixtures/export/reconciliation tests own actual event storage and fresh/warm/ +retention preservation. BFF unit/native route tests own source/subject caps, +effective idle/extension, epoch/replacement and cookie/race negatives. Browser +proof reuses owned Node/browser lifecycle on the two actual HTTPS hostnames; +fixture-owned CA/resolution never edits workstation hosts or machine trust. +The full three-process real-browser OIDC journey remains Phase 06, distinct +from this real-provider HTTP chain and narrow browser cookie proof. + +At least one clean reachable positive precedes every planted negative: missing/ +wrong state, verifier, nonce or browser binding; signature removal; changed +refresh subject versus original identity; protected ID actor/scope disagreement +with AccountSite (which has no subject field); stale epochs; shared-IP +saturation; ninth subject owner including logout-return transactions; failed/ +held replacement; settled expiry across all registry positions; extension with +fresh access; expired-access recovery; disabled/missing opt-in; provider event +storage disabled or refresh types omitted; wrong retention; event-config loss +of listeners/custom selection; readback drift and zero-write rerun. No artificial +cookie-chunk exercise is required for a fixed-size opaque format. +Late logout returns consume only their dedicated proof cookie, never a newer +session/browser binding; sign-out clears session/sign-in cookies separately. + +At acceptance, append dated ADR-0035/0053/0054 disclosures and the P02b-6 event +allowance; reconcile G13/phase Auth.js and chunking text, Standards 06/07/10/11/ +18/20/21, Architecture 13/14/25/31, Phase 03/11, glossary, realm/environment/ +setup guidance and the two skills. The ADR-0035 additive inventory names the +port/default/Phase 11/multi-process trigger without editing old decision bodies. +Architecture 25 must distinguish auto-scaled targets from single-process session +support. Phase 03 explicitly owns signed back-channel token ingress and replay/ +provider-session indexing before immediate disablement/sign-out-everywhere. + +No new execution VM/required-check name or timeout enlargement is proposed. +Ordinary jobs already provide Node/pnpm/browser prerequisites. New tests enter +complete discovery and source-bound assignment with zero skips; measured setup/ +proof/cleanup fit remains a qualification gate, not an acceptance assertion. +Run `make docs-index`; packet/gate status remains only in the phase Status/ +register and dated evidence remains in this log. + +### P02b-7 revised proposal review round 1 (2026-10-23) + +Two fresh independent source reviews inspect `325558e5`. GPT-6-astra high covers +protocol, custody, admission and browser races; GPT-6.1-sol high covers source +claims, library/provider behavior, governance carriers and proof ownership. +The evidence review approves; the security review requests one lifecycle fix +and one contract clarification. Neither runs a provider/browser/build or writes +repository files. + +- Logout-return entries now explicitly retain their subject key and credit + through return consumption/expiry and actual work settlement. They count in + the eight-owner limit; repeated sign-in/logout without return gets a planted + negative. Existing bootstrap limits affect throughput, not this accounting + rule; no measured one-IP global-denial claim is made. +- AccountSite has actor/tenant/organization but no issuer/subject fields. + Protected ID-token actor/scope claims match that projection; independently + validated ID-token issuer/subject must retain the original session pair on + refresh. No `sub == actorId` or nonexistent response-field check is promised. +- The primary pass separates logout-proof cookies from cleared session/sign-in + cookies. A late return consumes only its own proof, never a newer session or + browser binding. Provider credentials are still discarded after URL creation. + +The first pass reproduces 2,163 fragment links, roadmap structure, both generated +indexes and Proposed lifecycle parsing. Corrections require the same offline +checks and a second fresh pair against the resulting committed source. Runtime +qualification and maintainer acceptance remain pending. + +### P02b-7 revised proposal review round 2 and readiness (2026-10-23) + +Two different fresh reviewers inspect `ecb34b57`: GPT-6-astra high covers the +protocol/session contract; GPT-6.1-sol high covers source claims, authority, +provider events, delivery and proof ownership. Both approve without a remaining +verified actionable finding. Their source checks confirm the RP dependency +ranges/signature opt-in and Keycloak's default event selection and explicit +retention update behavior. They recheck R1's credit/AccountSite corrections, +separate logout proof, closed activation and real-provider/browser test split. + +The primary's final source pass distinguishes a missing organization claim from +the API's resolved organization: `TenantResolutionAttempt` uses claim or host +organization and still requires API membership. The protocol reviewer confirms +the clarification: compare any present protected organization claim, accept +resolved scope only from AccountSite, and preserve both original claim values +and resolved scope across refresh. Current row 10 remains DenyAll until Phase +03; this grants no membership or endpoint. Logout proof uses its own transaction +cookie with the same host-only security attributes. + +Read-only/offline validation at the reviewed source passes 2,164 fragment links, +roadmap structure, both generated indexes and diff hygiene. The primary also +checks 672 relative file targets across the five proposal carriers with none +missing, seven ADR parser controls, Proposed lifecycle and the ADR prose width. +Final index regeneration and document guards pass with 2,165 resolving fragment +links, conforming structure and both current indexes; parser controls still pass. +No runtime/build/provider/browser proof is claimed by these inspections. + +The revised ADR/package are ready for renewed maintainer approval. The RP +library/plain-cookie choice, source/subject admission and development event +storage/retention are presented together with their costs. G13's session part +remains Open and dependent implementation remains unstarted until acceptance. +After approval, the five-step commit/two-review-pair cycle proceeds automatically +unless a genuinely new decision or blocker requires the maintainer. + +### P02b-7 acceptance and implementation (2026-10-23) + +The maintainer explicitly approved the revised ADR and five-step plan at +`d3033f30`: “Revize ADR ve planı onaylıyorum; uygula”. G13's session part is now +Accepted. This is separate from the earlier unaccepted Auth.js proposal and its +historical reviews. Work stays on `development`; no push, PR or merge is implied. + +- ADR-0070 is Accepted, with native openid-client custody, opaque cookies, + effective lifetime, source/subject/global admission and qualified development + provider-event storage/retention. Its acceptance-time source has no runtime. +- Dated ADR-0035/0053/0054 disclosures record the additive store/caller and exact + POST extension. Original Accepted bodies and metadata remain unchanged. +- **P02b-6 allowance extension:** The shared development reconciler may now read + and update only the ADR-0070-qualified tenant user-event configuration. It + unions effective existing selection, preserves listeners/admin policy and + qualified retention, checks conflicts before any preparation/ordinary write, + verifies separately and requires a completed zero-write rerun. All other + account/credential/role/client/profile/SSO/Hub preservation limits remain. + Existing preparation/uncertain-progress rules still apply. No web runtime + obtains Admin credentials or generic realm mutation authority. +- Mutable standards, architecture, glossary and G13/phase plans reconcile the + selected authority; four catalogue obligations register with named owners. + Existing Implemented counts remain unchanged. Actual setup/env/skill and + per-rule implementation updates accompany their owning delivery steps. + +The five revised steps above are authorized in order. Step 1 starts with the +inactive RP foundation and fresh/warm provider-event policy; Step 2 owns the +native store; Step 3 owns closed routes/caller; Step 4 activates all guards/UI +atomically; Step 5 supplies actual lifecycle/browser and final-source CI proof. +Each step commits, passes two fresh independent review pairs and commits verified +fixes before the next. The primary alone runs serialized provider/browser/build +work. A new authority decision requires approval; routine implementation does not. + +### P02b-7 Step 1 — inactive RP foundation and provider events (2026-10-23) + +**Implementation/review in progress; not step closeout.** The acceptance commit +`eb911862` precedes code. This step adds the inactive server-only RP adapter and +qualified development user-event configuration without native route exemptions, +production registration or environment-secret projection. + +- `openid-client` 6.8.8 resolves exactly one oauth4webapi 3.8.8 and jose 6.2.13. + Explicit signature-enabled code/refresh processing uses mandatory injected + fixed-purpose transport, one ten-second operation deadline through decoded + body consumption, 256 KiB response and 32 KiB credential limits. No global + fetch fallback/public-caller exemption or raw provider error is introduced. +- Callback state/nonce/S256, signed issuer/audience/lifetime and protected + identity are checked; refresh cannot change identity. Effective refresh time + is conservative; initial custody requires a refresh token. This grants no API + authority: AccountSite validation remains the later publication requirement. +- Fresh export preserves all 95 pinned default-saved types and adds refresh + success/error storage. Warm qualification unions the effective prior selection, + preserves listener/admin policy and qualified retention, and checks before + metadata preparation. One bounded history row disproves safe empty adoption. + Independent readback and completed zero-write rerun remain mandatory. +- The first real warm-provider run exposes the pinned omitted-zero expiration + representation. `ModelToRepresentation.toEventsConfigReprensetation` omits zero; + the adapter now recognizes that exact default and still refuses enabled + indefinite policy or any nonempty prior history. Actual fresh/warm tests pass + after correction. This is an implementation correction, not an ADR policy change. +- Historical success/resume assertions now count the one separately authorized + event-policy PUT; they keep credential/role/profile/scope/Hub preservation and + explicit zero-write controls. Older delivery receipts retain their old counts. + +Targeted validation completed: 27 pinned-library signed-grant controls; the +complete realm Node census (367 passing cases in eight files, zero skips/todos), +six executed-gate controls, frontend lint/typecheck and the complete guarded +frontend run (1,879 cases in two packages). Five targeted export/real-provider +cases pass, including actual stored LOGIN/CODE_TO_TOKEN/REFRESH_TOKEN/ +REFRESH_TOKEN_ERROR and warm empty adoption/nonempty refusal. Acceptance-time +CorpusConsistencyTests pass 5/5. + +A further actual HTTP incomplete-body cancellation control has been added and +requires its executing frontend rerun. The broad real historical reconciliation +regression is still running; its loaded old numeric preparation assertions expose +the expected +1 event-policy write and have been corrected in source. Those +specific cases require fresh execution after the owner finishes. No source-bound +hosted qualification or complete Step 1 review is claimed. The two fresh review +pairs inspect committed code and the acceptance carriers; any pending/failed +qualification must be resolved before Step 2 starts. + + +### P02b-7 Step 1 review round 1 and corrections (2026-10-23) + +Two new read-only reviewers inspect `1d513938`: GPT-6-astra high covers the RP +protocol/transport and GPT-6.1-sol high covers provider policy, preservation, +proofs and acceptance carriers. Both request corrections; neither executes +provider/build/browser work or claims pending runtime evidence passed. + +- Qualified loopback HTTP sign-in must also construct its logout URL. The fresh + end-session configuration now carries the same explicit allowance; library + URL failures cross the closed error boundary. A real signed loopback grant + reaches the end-session builder without extra transport and verifies the + resulting fixed endpoint and token hint. +- Realm readback budgeting now includes the three event fields before either + metadata preparation or ordinary actions escape. Pinned Keycloak + [realm representation](https://github.com/keycloak/keycloak/blob/26.6.2/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java#L463-L470) + carries these fields. Four planted controls cover both planning paths at the + exact 256 KiB boundary and one byte over, with reachable successful readback. + The owned provider model now reflects the fields into the root realm. +- The accepted ADR-0070 index row is restored to the Active ADRs table. + +Validation after corrections: complete realm Node census 371/371 across eight +files, six executed-gate controls, frontend lint/typecheck and guarded workspace +1,881/1,881 across two packages, including 29 RP controls. The earlier broad +historical provider run has 19 passing cases and four old exact-count failures; +all four corrected cases pass their focused real-provider rerun (zero skips). +The extra authorized event-policy PUT is explicitly counted, not hidden by a +relaxed bound. Fresh architecture/event checks and a second new review pair +still precede Step 1 closeout. No production activation or hosted evidence is +claimed. + + +### P02b-7 Step 1 review round 2 and closeout (2026-10-23) + +Two new independent read-only reviewers inspect `26ccc731`: GPT-6-astra high +covers RP security/protocol and GPT-6.1-sol high covers provider policy/corpus. +Both find one actionable issue. The author verifies each against pinned source, +fixes it, and each reviewer independently confirms its focused correction. +Neither reviewer executes runtime or claims independent test evidence. + +- openid-client wraps custom transport errors in `ClientError.cause`. The RP + boundary now recognizes only that actual library type with an actual closed + `BffProviderError`, and emits a fresh closed reason without retaining the cause. + Three actual-library controls cover token/JWKS and refresh network failures, + distinct transport classification, no grant replay and no private diagnostics. +- **Correction to the initial Step 1 receipt:** the earlier 95-default/97-total + assertion was incorrect. Its source extraction omitted digit-bearing OAUTH2 + names. Pinned Keycloak has **103 default-saved event types**; all 103 plus two + refresh types now appear in the export. An independent source-bound fixture + records the complete selection and tagged source SHA-256 + `e96d8c99c67ff697d6798d2919bfcbeda238ff58dc923daaf59250672ab9b0b6`. + The export validator requires the full baseline, and a reachable control + removes each of its 103 types and requires the specific preservation violation. + The actual fresh provider also verifies the entire baseline after import. + +Final local validation: frontend lint/typecheck and guarded workspace **1,884 +passing cases** in two packages, including 32 RP controls; complete Node census +**371 passing cases** in eight files and six gate controls; six targeted export/ +actual-provider cases and 14 architecture/corpus/Docker-trait/genericity controls, +all with zero skips. Backend execution uses `CI=true`. The earlier four focused +historical-count reruns pass; the other 19 broad historical cases already pass. +No repeated broad execution, native route activation or hosted qualification is +claimed. Both required independent review rounds and their verified corrections +are complete. **Step 1 is locally complete; Step 2 begins automatically** under +the accepted five-step package. Production BFF paths remain closed. + + +### P02b-7 Step 2 — native session ownership implementation (2026-10-23) + +**Implementation submitted for review; not step closeout.** Step 1 closes at +`fd708462`. The native `BffSessionStore` port/default and immutable process holder +now exist; response/cookie grammar, request projection and activation remain +with Steps 3–4. Production entry points still install no BFF runtime or exemption. + +- `InMemoryBffSessionStore` privately owns credentials, transactions, browser + groups/epochs, subject credits and actual operations. Server methods return + closed display/deadline/CSRF projections or response-owner opaque proofs; + they expose no credential-returning capability. The typed AccountSite witness + must match protected actor/tenant, any present organization claim and prior + resolved scope. Subject remains independently validated opaque identity. +- Global 1,024 / pending 128 / source 16 / subject eight bounds include unsettled + owners. A same-browser replacement transfers its existing subject credit only + after actual old I/O settles; its pending owner holds that credit meanwhile. + Failed validation/capacity leaves the prior primary intact. Logout-return + credits survive later sign-ins and refresh cancellation until their own return/ + expiry and settlement. No other browser is evicted. +- Single-use transaction proof/state, independent host/browser bindings and + browser elections fence competing callbacks. A missing primary cookie creates + an independent browser group rather than authorizing eviction. Older logout + returns cannot revoke a newer primary. Actual HTTP cookie writes remain Step 3. +- Monotonic idle/absolute/provider deadlines, on-demand/forced single-flight + refresh, stable protected claims/resolved scope, conservative merged credential + bounds and independent waiter cancellation are covered. `peek` is passive; + qualifying reads use the API witness. Stop closes publication/admission, aborts + owned I/O, joins actual promises and stops the thirty-second full sweep. +- A compiled native child proves one frozen global holder across independently + loaded module copies, no provider call at construction, no fallback/reinstall, + separation from the public admission holder, and joined shutdown. The pure + provider error value no longer carries a Next-only import sentinel; credential + modules/facades and transitive client fences remain private. +- The exact native-source census gains only this compiled entry and preserves + nested generated paths. Planted unknown entries still refuse, and its real + source plus transitive helper remain in demo-genericity coverage. + +Validation: 51 unit ownership controls and four native composition controls pass; +frontend lint/typecheck and the complete guarded workspace pass **1,939 cases** +in two packages with zero skips/todos. Native compilation runs with ordinary +Node, without a global React/server condition. No actual browser/provider-session +or hosted qualification is claimed by these unit/native proofs. Two fresh +independent review rounds still precede Step 2 closeout. + + +### P02b-7 Step 2 review round 1 and visitor correction (2026-10-23) + +Two fresh independent read-only reviewers inspect `11cd37d0`: GPT-6-astra +xhigh covers lifecycle/security races; GPT-6.1-sol high covers proof adequacy, +native/client/genericity boundaries and authority. The proof review approves; +the lifecycle review identifies one verified port defect. + +`complete` previously used the sign-in request's visitor for callback AccountSite +validation. It now requires the callback's independently verified canonical +source and validates it before consuming state. The actual AccountSite hop uses +that current source; pending admission retains its original source credit until +promotion or actual settlement. IP is not a callback authentication binding. +A planted network-change control proves both accounting facts; malformed/missing +callback sources leave the valid transaction untouched and dispatch no grant. + +Frontend lint/typecheck and native compilation pass. The complete guarded +frontend run passes **1,941 cases** in two packages, zero skips/todos, including +53 ownership controls and four composition cases. To distinguish proof levels, +one composition case runs an actual separately compiled native child; three +inspect passive facade/descriptor/source behavior under Node Vitest. No actual +Next request integration, production activation or provider/browser journey is +claimed yet. The second fresh independent review pair still precedes closeout. + + +### P02b-7 Step 2 review round 2 and closeout (2026-10-23) + +Two new independent source-only reviewers inspect `47ffca71`: GPT-6-astra +xhigh covers lifecycle/security concurrency; GPT-6.1-sol high covers proofs, +confinement and corpus. Both approve without an actionable finding. They confirm +current callback-source validation and original pending-source accounting, +held-work credit ownership, replacement validation before revocation, epoch +fences, one refresh owner, stable identity/scope, independent logout returns and +joined shutdown. Neither reviewer executes runtime or claims independent passes. + +The preceding source's full frontend guard passes 1,941 cases in two packages, +zero skips/todos, with frontend lint/typecheck/native compilation and document +guards passing. No code changes follow this round. **Step 2 is locally complete; +Step 3 begins automatically.** HTTP cookie/CSRF/route and request-projection +integration, atomic activation, real BFF/provider/browser evidence and exact-source +hosted qualification keep their accepted later-step owners; none is inferred +from native unit controls. + + +### P02b-7 Step 3 — closed HTTP and request projection (2026-10-23) + +The third implementation slice supplies the exact auth method/path matrix, +Origin plus presession/session CSRF, state-named transaction and independent +logout proof cookies, bounded scalar forms/cookie families and closed responses. +Native POST exemptions remain disabled by default and the production launcher +and middleware do not install or call the BFF yet. Only the three exact POST +paths can use the explicit test-owned opt-in. Native admission rejects oversized, +unknown-length or transfer-encoded bodies before Next can clone them; the form +reader independently checks actual bytes, UTF-8, field names and duplicates. + +A separate native request holder publishes one immutable safe projection after +live-host bootstrap and response-owned admission. App-local `auth()` reads it +without refresh, idle mutation or cookie writes. Actual response finish/close, +request abort and stop revoke request authority. Exclusive sign-in/callback work +follows request cancellation while keeping unsettled work owned; cancellation of +a shared-refresh waiter still leaves its owner running. AccountSite receives the +current callback/read visitor and trace rather than retained initial attribution. + +The SDK gains one injected `getAccountSite` capability under its separate +`authenticated-server` entry. A narrow native build makes that same typed parser +available to the native caller. The configured caller alone attaches the stored +access token and verified host/current visitor, applies existing API budgets and +projects only its closed witness. Public wrappers retain their original contract. +Source controls model the exact generated SDK/native entries, confine the two +hop setters and continue forbidding global fetch in the authenticated adapter. +Shared native/Next modules use NodeNext specifiers with explicit Next resolution. + +Frontend lint/typecheck and native compilation pass. The full guarded frontend +run passes **2,034 cases in two packages**, zero skips/todos. Tests cover native +HTTP opt-in/refusals, the actual pinned Next request/response bridge, safe RSC +reads, callback cancellation and cookie/CSRF/refresh/logout negatives. One owned +production Next copy runs real native HTTPS, middleware and RSC bundles through +sign-in/callback/session/logout, confirming cross-bundle lifetime/projection and +multiple cookie delivery. Its grant and AccountSite capabilities are explicit +fakes: it is not real Keycloak/API or browser proof. Actual Next qualification +also requires absolute local redirect URLs; only the verified host and three +closed return paths form them. Existing native GC proof retains its original +collection assertions with the new shared path-policy dependency copied too. + +Both independent review rounds still precede this step's closeout. Atomic +production activation/localized UI remain Step 4; real provider/app-role API, +browser and exact-source hosted qualification remain Step 5. The four BFF +catalogue obligations remain Registered until their complete controls ship. + + +### P02b-7 Step 3 review round 1 (2026-10-23) + +Two fresh independent source-only reviewers inspect `3279258d`. GPT-6-astra +xhigh reviews protocol/security/lifecycle; GPT-6.1-sol high reviews evidence, +confinement and corpus. The evidence reviewer approves without an actionable +finding. The protocol reviewer verifies one major defect against pinned Next: +its middleware adapter removes Flight headers before invoking middleware and +restores them afterwards. Classifying the middleware headers therefore treats +ordinary RSC navigation as passive. The initial production fixture observes +rendering/confinement but does not detect that missing refresh/idle behavior. +Neither reviewer runs runtime or independently reproduces reported passes. + +The native request holder now captures only a closed read/passive scalar from +the original incoming protocol headers before Next. The scalar is bound to the +same request lifetime and read by the response owner before publication; no +request/header object is retained as session authority. RSC `auth()` remains a +read-only safe projection lookup. Unit controls mutate/remove the original +headers after capture and verify classification and phase/lifetime closure. + +The actual production Next fixture now uses a private deterministic clock and +scalar IPC observations. RSC with `Accept: */*` and empty fetch destination +refreshes expired access, validates AccountSite once and advances effective idle. +Actual prefetch, HEAD and passive session polls do none of those operations. +The same fixture independently builds a copied handoff with native activity +removed: its preceding sign-in/callback positive succeeds, then the RSC assertion +fails at that exact stage. A build/startup/cleanup failure cannot satisfy this +counter-control. Grant/account capabilities remain explicit fakes; this is not +provider/browser qualification. + +Frontend lint/typecheck/native compilation pass. The complete guarded frontend +suite passes **2,036 cases in two packages**, zero skips/todos, including both +actual Next runs. The correction is committed before the second fresh review +pair. Production activation remains closed pending Step 4. + + +### P02b-7 Step 3 review round 2 and closeout (2026-10-23) + +Two new independent source-only reviewers inspect `2ebae0c9..7fded69c`. +GPT-6.1-sol xhigh covers protocol/security/lifecycle; GPT-6-astra high covers +proofs, confinement and corpus. Both approve without an actionable finding. +They independently trace the pinned Flight-header behavior and native scalar +handoff, safe projection lifetime, callback/current attribution, cancellation +ownership, CSRF and independent logout proofs. They confirm the actual Next +counter-control reaches its RSC assertion after successful sign-in/callback. +Neither reviewer runs runtime or independently attests reported passes. + +No code changes follow this round. The reviewed source's complete frontend +guard passes 2,036 cases in two packages with zero skips/todos; lint, typecheck, +native compilation and documentation guards pass. **Step 3 is locally complete; +Step 4 begins automatically.** Production optional configuration/activation, +localized session UI and skill reconciliation now have the next step's owner. +Real provider/app-role API/browser/history and exact-source hosted evidence +remain Step 5 gates, not claims inferred from fake capabilities or source review. + + +### P02b-7 Step 4 — optional activation and session UI (2026-10-23) + +The native launcher activates both session/request holders and the closed POST +admission together, only after agreeing private configuration passes. Absent or +false opt-in returns no BFF issuer/secret configuration. Enabled source files are +bounded, regular, single-link and owner-private; permission/link/conflict +controls prove refusal without repair. Provider construction/discovery remains +lazy. Native shutdown seals requests and joins actual session-owner settlement; +the existing failed hard-stop ceiling is not evidence of successful cleanup. + +Middleware resolves the live public host/site and validates locale membership +before cookie/provider work. Disabled auth stays 404 and disabled shells render +localized no-store 503. Enabled shells redirect anonymous visitors to the closed +same-host sign-in flow. Enabled locales still take precedence over a same-spelled +studio/portal root. Rebuilt downstream headers carry no browser cookies, Origin +or Authorization. Expected browser errors render bounded localized recovery; +owner-authorized transaction-cookie edits survive without clearing another flow. + +Complete English/Turkish `auth` catalogues have typed callsite/ICU controls and +whole-catalogue fallback. Next's production compiler refuses react-dom/server in +middleware, so the closed sign-in document uses escaped text/attributes, validated +four-color CSS and no script, password field or provider-error reflection. +Studio/Portal render the safe AccountSite projection and native sign-out form; +no roles, permissions or authoring capability are invented. Session warning uses +effective expiry, offers actual forced refresh, reports unchanged expiry honestly +and offers reauthentication at the known absolute maximum. A later admitted RSC +projection supersedes an older in-flight client result. Failure hides the account +view and focuses recovery; passive countdown never touches the server session. + +The browser action adapter permits only a fixed same-origin no-store extension, +with bounded response/closed projection. Dirty URL/header/cache/redirect/renaming +controls prove that exception does not authorize arbitrary fetch or API calls. +Navigation uses document anchors. A fixed pre-interactive history script veils +before caching and keeps a restored document hidden until network reload; this +mechanism's actual cookie/history behavior remains the Step 5 browser proof. +Route, feature-gate and i18n skills now describe the shipped app-local facade, +not a nonexistent Auth.js package or permissions array. Configuration examples and +setup/security/catalogue carriers are reconciled. + +Root-owned validation before the implementation commit: + +- Complete guarded frontend suite: **2,081 passed in two packages**, zero skips + or todos. Frontend lint, typecheck and native compilation pass. +- Production Next build passes. A copied full production app exercises both + translated sign-in documents, HEAD, unknown-host refusal, callback, protected + HTML/Flight, passive requests, cross-host cookie rejection, real owner + extension and local logout. This uses explicit fake grant/AccountSite + capabilities; it is not real-provider or real-browser evidence. +- Existing PostgreSQL/app-role `PublicAdmissionRenderingTests` passes, one case, + zero skips, 37 seconds reported test duration. Its anonymous shell overlap now + verifies the intended 503/site palette without RSC dispatch. Browser fallback, + late HEAD and real HMR use an existing public catalogue instead of bypassing + the new shell guard; exact API-read accounting remains asserted. +- Five architecture corpus controls and all documentation guards pass; 2,190 + relative fragment links resolve and both delivery-log indexes are current. +- Initial full-suite refusals identify stale projection, catalogue and anonymous + shell expectations; they are updated to the new closed contracts. Fixture + declaration files and the changed native instrumentation anchor are aligned. + +Step 4 awaits its two fresh independent review pairs. Real warm-provider/API, +native HTTPS browser/history, stopped-Keycloak demo and final-source hosted +qualification remain Step 5. The four BFF catalogue rules remain Registered. + + +### P02b-7 Step 4 review round 1 (2026-10-23) + +Two fresh source-only reviewers inspect implementation `09b3aa62`: +`gpt-6-astra` at xhigh for security/lifecycle and `gpt-6.1-sol` at xhigh for +UI/proof/corpus. Both identify the same development diagnostics Blocker; the UI +reviewer also identifies the missing skip link. Neither runs a provider, browser, +build or test; root owns all runtime confirmation. + +- Pinned Next's development request handler records the raw URL even when + middleware owns the response. Its independent reporter persists callback + code/state and logout state in `.next/trace`; disabling console logging alone + is insufficient. The native launcher now suppresses performance spans before + importing Next, using a threshold above every duration the pinned tracer can + accept. Next automatic request/fetch logging is also disabled. This loses + public development performance events too; separate build traces and API + telemetry remain. Security 11 and web setup disclose the cost. +- An actual enabled development copy completes sign-in, protected rendering, + logout return and a refused provider-error callback. Private random canaries + are scanned in stdout/stderr and the persisted trace after the actual native + child exits successfully. Removing only native trace suppression leaves + console logging disabled but fails specifically at the trace canary, after + successful callbacks. Raw trace data and canary values never leave its owned + temporary root. This is fake-grant protocol evidence, not provider/browser + qualification. +- Sign-in/recovery documents and enabled protected shells now place a localized + skip link before their content, targeting a focusable main element. They reuse + the existing public UI translation. DOM assertions cover both languages. +- The expanded source graph makes the existing multi-mutation ownership test + exceed Vitest's implicit five-second limit. Only that finite complete-graph + control receives twenty seconds; all planted variants remain. The Turkish + skip-text assertion is corrected for its actual dotted capital letter. +- A complete rerun also exposes CPU contention in three unchanged lint controls + when Vitest starts file workers by machine CPU count alongside native Next + builds. Web file concurrency is now four; those controls keep their existing + timeouts and all discovered cases still run under the same package deadline. + +Root validation: **2,083 frontend cases pass in two packages, zero skips/todos**; +the web population completes in 61 seconds with four workers. Lint, typecheck, +native compilation and all document guards pass (2,191 fragment links). The real +PostgreSQL/app-role renderer case passes again, one case, zero skips, 45 seconds +reported test duration. Second-round closeout follows below. The four broader +BFF rules remain Registered; real provider/API/browser and hosted proof remain +Step 5. + +### P02b-7 Step 4 review round 2 and closeout (2026-10-23) + +A different fresh source-only pair reviews complete Step 4 through `2cf2a25b`: +`gpt-6-astra` at xhigh for security/lifecycle and `gpt-6.1-sol` at xhigh for +UI/evidence/corpus. The security reviewer approves without verified findings, +independently tracing the pinned reporter's accepted-duration bound and native +import order. The UI reviewer confirms the first-round fixes and identifies one +Major keyboard-recovery gap: unchanged/newly maximum extension removes the +focused button without announcing or focusing its replacement; automatic expiry +also removes focused descendants without recovering focus. + +Recovery notices now have status semantics. Only a transition to ended/recovery +state moves focus, and only if focus remains in the session container or has +fallen to the body; an external active control and the ordinary warning retain +focus. Nine component controls pass, including focused unchanged/maximum/expiry +and external-focus controls for both expiry and unchanged response. Focused lint +passes. The same UI reviewer performs source-only focused confirmation and +approves the fix, with no remaining actionable issue. + +Together with first-round complete validation above, **Steps 1–4 are locally +implemented and both independent review rounds are complete. Step 5 begins +automatically.** Its real warm-provider/app-role API, native HTTPS browser and +history, stopped-Keycloak demo, discovery/budget and exact-source hosted proofs +remain mandatory. None is inferred from fake grants, DOM unit tests or source +review; the four broad BFF catalogue obligations remain Registered. + +### P02b-7 Step 5 — real lifecycle proof in progress (2026-10-24) + +Step 4 closes at `a48011cb`; Step 5 now connects the existing production RP, +native launcher and AccountSite caller to a reconciled historical provider and +real Kestrel/application-role database. A separate stock-browser fixture owns +the two retained HTTPS seed hosts. It does not claim a full browser OIDC journey. +The four catalogue obligations remain Registered pending complete proof. + +The shared owned Next fixture compiles the copied current native sources rather +than symlinking stale workstation output. The guarded frontend test entry and +ordinary CI preparation explicitly compile native web/authenticated SDK entries. +Existing protocol positives still pass after extraction. No execution job, +required context, shard range or whole-job ceiling changes. + +Initial real-chain readiness failures expose fixture issues, not successful +product qualification. The first closed input guard omitted the real `tr-TR` +seed locale; refusal also left stdin held, so the parent waited for its deadline. +Input-failure cleanup now closes that stream, and private IPC explicitly uses +flushed UTF-8 without a BOM and finite progress/error tokens. A small no-provider +startup control then exposes its noncanonical trailing-slash API origin; its +input is corrected to the existing production origin contract. An independent +real-mode startup/cleanup pipe control passes without provider/API access. +The real chain subsequently reaches actual sign-in, refresh, provider logout, +invalid-grant revocation and compressed provider-maximum checks. Fixture +assertions are corrected to read the projection's fractional-millisecond number +and include the ordinary protected read's independent AccountSite witness; +neither changes the product lifetime or weakens the witness guard. The corrected real-chain case passes in 81.524 seconds, with zero skips: English +and Turkish native lifecycles take 29.202/28.759 seconds, seven authenticated API +witnesses each, provider startup 17.040 seconds and joined cleanup 0.736 seconds. +Both owned containers and the network are independently absent. The passing +receipt proves actual provider refresh, maximum, logout/error/events and the +zero-write warm rerun; it is separate from browser storage/history evidence. + +The actual browser discovers a product issue: `no-referrer` on the sign-in HTML +serializes the native navigation POST's Origin as `null`. Only that HTML response +now uses `same-origin`; token-bearing provider redirects remain `no-referrer`, +and null/missing/cross-origin admission negatives still refuse. The +[Fetch Origin algorithm](https://fetch.spec.whatwg.org/#append-a-request-origin-header) +is the source of this distinction. The header regression and private-output +scanner controls pass (38 cases). + +Stock Chromium `156.0.8078.48` passes both HTTPS hosts' native sign-in, +secure/HttpOnly/Lax/host-only opaque cookie storage, script inaccessibility, +cross-host transaction refusal, keyboard skip-link, extension, logout deletion +and actual back-navigation admission. No natural persisted restore occurs in +this run. An explicit persisted-event control separately reaches the restoration +branch; removing only the copied history reload kills the counter there. +A real owned HTTPS logout stub replaces an invalid Playwright redirect-target +interception assumption. Fake grants/API remain explicit; this is not the real +provider HTTP proof or the Phase 06 full browser OIDC journey. + +All failed provider runs join and independently verify absence of their two +owned containers and network. No raw input, token, cookie or exception payload is +retained in results. The complete guarded frontend run passes 2,089 cases in two packages with zero +skips/todos. Lint and typecheck pass. Offline CI controls pass 67 assignment, +26 aggregate and 63 execution-owner cases, including new native/SDK compilation +gate mutants. The actual source fixture census includes the event and BFF test +classes; shard counts, required names, whole-job ceilings and authority remain +unchanged. Final runtime counts, two review rounds, demo and hosted qualification +will be appended after they actually complete. + +### P02b-7 Step 5 — implementation and local qualification (2026-10-24) + +The final local source passes all three `TenantBffSessionTests` cases with zero +skips: real warm lifecycle 85.062 seconds, actual browser positive/counter pair +32.476 seconds and enabled no-network startup/stop 13.387 seconds. Each grant +and successful refresh independently increments the real authenticated API +witness before publication, in addition to the seven-per-host complete journey +count. English/Turkish lifecycles take 29.839/28.876 seconds; provider startup is +19.040 seconds, joined provider cleanup 2.124 seconds. The two containers and +network are independently absent. Stock Chromium `156.0.8078.48` observes zero +natural persisted restores; the explicit restoration counter is killed at +`persisted_event`, never reported as a natural BFCache result. + +The passing local TRX has SHA-256 +`e7e98d28870397f11c8e0962dafc0c7fa2473cd9f25d9113098ba292e1df7d78`. +Its three method durations enter the source-controlled plan as advisory local +body costs. The receipt embeds no immutable source identity; `sourceSha` stays +null, and collection startup/hosted wall fit are not inferred from method sums. +The complete source fixture census also includes the Step 1 event test class. + +Fresh complete VSTest discovery finds 5,927 cases: 3,351 non-Docker and 2,576 +Docker, with the latter exactly 31 native plus 2,545 ordinary. Both complete +A/B assignments are disjoint unions, fit the existing selector bounds and +actually differ across architecture, ordinary and shared-fixture composition. +This is local discovery/assignment evidence, not hosted timing qualification. + +The guarded frontend run passes 2,089 cases in two packages with zero skips/todos; +lint/typecheck pass. The complete Release solution builds with zero warnings and +errors. Seven corpus/status/Docker-trait controls pass with zero skips. +Offline assignment/aggregate/execution controls pass 67/26/63 cases; +source topology refuses skipped or changed native/SDK fixture compilation. + +The four broad BFF obligations remain Registered until all their proof owners +ship. Two fresh independent review rounds, the fresh stopped-provider public +demo, final catalogue promotion and exact-source hosted A/B qualification remain +open. No PR-ready or packet-complete status is claimed by this local checkpoint. + +### P02b-7 Step 5 — independent review round 1 (2026-10-24) + +Two fresh source-only reviewers inspect `a48011cb..5efecf70`: Astra/xhigh +reviews lifecycle/security and Sol/xhigh reviews browser/evidence/CI integrity. +Neither runs builds, tests or providers. Their actionable findings overlap on +abortive parent disposal: `BffNativeDriver` kills Node before its fixture owner +can remove the temporary build/TLS tree. The lifecycle reviewer additionally +finds that successful receipts establish group absence without requiring the +native launcher's zero exit/no signal. Both are verified in the current source; +normal passing runs do not disprove failure-path defects. + +Root queues focused failure/counter reproductions after the already-running +fresh public demo, preserving the sole runtime executor and port ownership. +Corrections and their actual validation remain pending in this entry; no review +approval or successful failure-cleanup proof is inferred from source inspection. + +### P02b-7 Step 5 — stopped-provider public demo (2026-10-24) + +A fresh tracked-only clone of the same `development` branch qualifies source +`5efecf708457841b14d43d75d0626bae68b977d1`, tree +`412ddd8619ded86869298aa5502877a4a9b887c8`, through the unchanged +`make demo DEMO_MODE=ci` lifecycle. No workstation environment file is copied. +Its nine seeded public pages pass 27 Lighthouse runs after the selected +Keycloak is stopped; all 27 JSON/HTML pairs and nine screenshots are present. +The receipt records `passed: true`, setup 82.502 seconds and proof 577.291 +seconds, with pinned Playwright 1.64.0/Lighthouse 13.5.0 and managed Chromium +revision 1248 (`156.0.8078.4`). Both negative TLS controls refuse. + +Cleanup passes in 10.726 seconds: owned processes stopped, temporary Compose +project empty, private files/environment/lock removed. This is a local exact- +source public proof, not hosted qualification or a new manual accessibility +pass. Round 1's subsequent changes concern proof ownership and failure cleanup; +they do not replace this receipt with a claim about a different source tree. + +### P02b-7 Step 5 — round 1 corrections and qualification (2026-10-24) + +Both verified findings are corrected. The C# parent creates a private outer +fixture directory and supplies it only as the owned child's temporary location. +Disposal closes the private input first, drains output and joins under a fresh +cleanup budget, removes only an independently empty outer directory and always +fails a forced fallback. The browser reads one bounded initialization line, +keeps stdin actively draining as its lifetime channel and closes it on terminal +paths. Its stock browser is synchronously spawned/owned before CDP connection, +using the existing public-demo pattern; no unresolved launch can acquire an +unowned process after cancellation. Native spawns check ownership before spawn. + +Successful BFF receipts now require native exit 0 without a signal and no forced +group escalation. The shared stop helper exposes a cached immutable `forced` +result without changing existing consumers' behavior. Its real uncooperative +surviving-descendant controls prove that a zero-exit leader can still require +forced cleanup; BFF proof treats that as failure. All resource attempts still +run, including when an earlier stop fails. + +All five BFF Docker cases pass with zero skips: actual warm provider/API +90.731 seconds, actual browser positive/history counter 33.541 seconds, ordinary +no-network startup/stop 7.219 seconds, early-parent cleanup 7.903 seconds and the +nonzero-native-stop counter 7.465 seconds. The early-parent case independently +observes its exact outer directory disappear. The shutdown mutant changes only +the copied native exit to 1 and is rejected at `complete/native-stop`; a general +fixture failure cannot stand in for this counter. Both retained hosts still have +seven real API witnesses each; provider startup is 24.386 seconds and joined +cleanup 0.644 seconds, with both containers/network independently absent. + +The passing local TRX has SHA-256 +`a8aed7b2684219ebec622a931c85640c8a59fcfa40407aabe9f0be95a1208fb5`. +The plan keeps this as a new advisory local source, preserving the prior receipt; +no embedded immutable source or hosted wall fit is claimed. All 2,089 frontend +cases still pass with zero skips/todos; the 35 focused ownership/output controls +also pass. Release compilation with CI warning policy reports zero warnings and +errors. The seven corpus/status/Docker-trait controls pass with zero skips. +Fresh actual discovery contains 5,929 rows: 3,351 non-Docker and 2,578 Docker +(31 native plus 2,547 ordinary). Both genuinely different local A/B assignments +have complete disjoint unions and bounded filters; this proves assignment, not +hosted fit. The four complete BFF catalogue controls are locally Implemented, +with current owners and counters updated together. Assignment (67), aggregate +(26) and execution (63) offline controls pass; all 2,197 Markdown fragment links +resolve and roadmap/index guards pass. Fresh round 2 and exact-source hosted A/B +remain required before PR readiness. + + +### P02b-7 Step 5 — independent review round 2 (2026-10-24) + +A different fresh source-only pair inspects `a48011cb..83858d12`: +GPT-6-astra/xhigh covers lifecycle/security and GPT-6.1-sol/xhigh covers proof, +browser, CI and corpus integrity. Both request corrections. Neither executes +runtime work. Root verifies the two actionable findings in current source: + +- The session fixture's async input iterator stops consuming while awaiting + compile/build. Parent EOF can therefore remain unobserved until acquisition + finishes. Post-ready early disposal does not prove this earlier path. +- The extracted production protocol/UI proof still accepts cleanup without + checking native zero exit and unforced settlement. Development checks exit + but also needs to reject force escalation. + +Corrections keep a bounded private frame pump flowing independently throughout +acquisition and install EOF as an owner lifetime signal. A held owned build +child is exposed only through a closed fixture mode, so a new parent-disposal +control can stop before native readiness and independently observe the exact +private tree disappear. All protocol/UI modes now require unforced settlement, +zero exit and no signal; a copied exit-1 counter must fail at `native-stop`. +Actual execution and both reviewers' focused confirmations remain pending in +this review entry. No approval is inferred from the earlier five passing cases. + + +### P02b-7 Step 5 — round 2 corrections and local closeout (2026-10-24) + +Both fresh round-2 reviewers independently confirm the focused source fixes +without a remaining actionable defect. They inspect the active bounded input +pump/EOF lifetime channel, owned held-build acknowledgement and exact directory +absence control, and the protocol/UI/development zero-exit/unforced requirement +with its copied shutdown mutant. Their confirmations are source reviews, not +independent runtime execution. + +Root re-executes all six actual BFF Docker cases, with zero failures or skips: +held-build parent EOF 0.971 seconds, post-ready early disposal 7.367 seconds, +nonzero native-stop counter 6.539 seconds, normal no-network startup/stop 6.595 +seconds, stock HTTPS browser positive/history counter 32.914 seconds and actual +warm Keycloak/native BFF/application-role API 79.261 seconds. The passing TRX +SHA-256 is +`eb29f34d707a87e2ddcdc9c0c269cd011c983cce4a79d8592ffaf9c95aa2ccf0`. +The separate advisory plan source retains that receipt; no immutable-source or +hosted timing claim is inferred from a local TRX. The complete guarded frontend +passes 2,090 cases in two packages with zero skips/todos, including the exact +protocol exit-1 counter rejected only at `native-stop`. Release compilation under +CI warning policy has zero warnings and errors. The architecture description +also names the actual local sign-in form before explicit provider navigation; +the realm guide removes the obsolete future-BFF consumer wording. + +Frontend lint/typecheck pass. Fresh actual discovery contains 5,930 rows: +3,351 non-Docker and 2,579 Docker (31 native plus 2,548 ordinary). The A/B +assignments are genuinely different, complete and disjoint; every generated +filter remains within its bound. Local assignment is not hosted timing proof. + +All five implementation steps and both fresh review rounds per step are locally +complete. The earlier exact-source stopped-provider public demo is retained +separately. Same-source hosted A/B execution remains qualification work before +packet completion or PR readiness; no failing, missing or skipped execution is +waived. + + +### P02b-7 hosted frontend ingress correction (2026-10-24) + +[PR #36](https://github.com/HodeTech/LearnStack/pull/36) is draft. Its first +[A attempt](https://github.com/HodeTech/LearnStack/actions/runs/40037549651) +uses source `fedffbe82d2dcac254d62e05714885df9219ff3f`, whose tree equals +`b600a0f9` after synchronizing the already-merged main history. Frontend +lint/typecheck/build and all 2,090 guarded cases pass; its subsequent standalone +ingress proof fails because its legacy `/studio` and `/portal` rows still expect +the old 200 placeholder. Actual disabled-BFF 503 behavior matches ADR-0070 and +the existing activation controls. This is not a successful full qualification. + +The correction changes only those two expected statuses and adds no-store, +30-second retry, no-cookie, no-protected-markup and no-form assertions; GET +requires an HTML document and HEAD remains bodyless. Exact bootstrap counts, +unsupported-method refusal, TLS, upgrade and source-isolation controls remain. +Both round-2 reviewers independently approve this focused source correction. +A fresh production build, the actual complete native ingress proof and all 19 +failed-fixture cleanup controls pass locally. No automatic flaky retry, product +behavior change, check removal or timeout enlargement is used. + +Separately, screenshot-only instrumentation in a private copy of the existing +actual HTTPS browser proof captures English/Turkish sign-in and Studio views. +The normal positive browser checks still pass and root visually inspects all +four images. These are fake-grant/API UI images, not a new real-provider browser +journey, natural BFCache result or assistive-technology claim. No private raw +HTML or credentials enter retained repository documentation. + +The remaining first-attempt jobs are allowed to finish for their independent +failure/timing evidence before a new complete source-bound run. Both final A/B +assignments and current-head required checks remain qualification gates. + +### P02b-7 first hosted A — fixture corrections (2026-10-24) + +The complete [first A attempt](https://github.com/HodeTech/LearnStack/actions/runs/40037549651) +on source `fedffbe8` finishes with 22 successful and five failed jobs. Its backend +aggregate passes all 3,351 non-Docker cases; native qualification passes all 31. +All six new BFF cases pass, including the real warm-provider/app-role API chain +and separate actual HTTPS browser proof. The integration aggregate correctly +refuses the failed ordinary producers. These component passes do not qualify the +whole source. The already-recorded frontend correction is committed as `d3d7c041`. + +Three ordinary failures share one copied-fixture omission: the public local +launcher copies the real ingress module but omits its new direct, pure +`auth/http-paths.ts` dependency. Copying that leaf into the existing private +fixture fixes the actual TypeScript compilation; all three real Docker launcher +cases pass locally, with no product or credential change. + +The remaining ordinary failure is the existing native held-constructor +`host_stop` case. Its closed receipt locates cancellation at `handoff`, before +constructor entry or invocation capture; cleanup subsequently completes. A local +execution of the exact same 236 selected cases passes. Neither that pass nor the +receipt proves the historical hosted cause. + +Source analysis identifies a real competing precondition: Prepare/Arm owns the +row lock, the renewal query skips that row, and the acknowledged empty batch +requests safe cancellation before constructor entry. The existing +[held-Prepare renewal proof](../../backend/tests/LearnStack.Tests.Integration/Database/BackgroundJobPositiveSdkTests.NativeSettlementPrepare.cs) +deterministically demonstrates that separate path with real transactions. The +held-constructor test now reuses its existing real-callback pacing owner to +establish constructor entry before requesting host stop or expiring the SQL +lease. Clocks, lease/deadline timers and production behavior stay unchanged. The +lease-loss variant additionally requires actual renewal refusal, with no elapsed +lease substituted. Every exit releases holds and joins runtime and actual timers +within the original shared cleanup budget. + +Both round-2 reviewers independently approve the focused corrections after a +separate source diagnosis. Release compilation has zero warnings/errors. All +five focused real-worker/renewal cases pass without skips; their TRX SHA-256 is +`6480ff89659f2e8f234882517a94fe7ee4ca09d5d44d85c3a5598517a1a0c696`. +The two constructor cases take 2.044/2.158 seconds. This is a fixture-ordering +correction, not a claim that the old hosted failure was conclusively reproduced. + +Failure-only handoff diagnostics retain finite native/durable state, routing, +lease/attempt and hint-issuance facts. The CI parser admits only the exact two +registered cases and closed fields; raw messages, identities and credentials +remain excluded. These observations never supply completion or admission +capability, and successful aggregate rules remain unchanged. All 65 execution +protocol controls and 26 aggregate controls pass. They exercise admitted values +and private/malformed/duplicate counterexamples; no passing-path diagnostic is +claimed as an observed failure. + +Repository overview pointers now describe optional native sign-in and link its +setup/security rules instead of presenting BFF sessions as future-only work. +Exact-source complete hosted A/B qualification remains required before PR #36 +leaves draft; no failed-only rerun, timeout increase, skipped case or check waiver +is used. + +### P02b-7 second hosted A — remaining handoff precondition (2026-10-24) + +The [A run on `19cd8102`](https://github.com/HodeTech/LearnStack/actions/runs/40040983884) +finishes with 24 successful jobs and three failures. Frontend and both public +launcher populations pass. All six BFF cases and 31 native cases pass again; +the 236-case ordinary-2 producer now passes both held-constructor cases in +4.534/3.462 seconds, with complete ownership, no forced cleanup and Docker +absence. Backend aggregation succeeds. Whole-source qualification still fails. + +One failure is a local sequencing mistake: the final receipt sentence was +edited after index generation/check, leaving two generated line counts stale. +Meta correctly rejects it. Regeneration follows the final content edit and the +index/lint checks run again after commit; this changes no workflow rule. + +The other producer failure is the existing one-hint cleanup control, +`Native_fixture_start_waits_for_previous_actual_cleanup_before_one_same_generation_hint`. +All 252 producer cases finish, with only this case failing: cancellation at the +post-helper constructor wait after 31.493 seconds. The producer correctly refuses +completion/ownership qualification and the integration aggregate refuses it. +A location alone does not prove the historical cancellation cause. + +A complete source census finds this is the last caller of the shared startup +helper still using one-second renewal. Its subject is preceding real cleanup +and exactly one later hint, not renewal refusal. It composes the existing real +renewal-pacing provider beneath its existing held-disposal timer. The controlled +initial empty recovery creates no typed lease/deadline; the later real normal +publication does. The concurrent pacing proof leaves the original cleanup and +premature-hint counterexample intact, then requires actual constructor and first +callback entry. Stop releases both holds before actual runtime/timer joining. +No production behavior, timeout, lease clock, grant, job count or test population +changes. Direct-owner renewal/refusal controls remain unchanged; source-only +possibilities in unrelated causal/policy journeys are not labelled reproduced +faults or used to justify a broad patch. + +Both independent round-2 reviewers inspect the applied correction and find no +actionable defect. Release compilation has zero warnings/errors. All six +focused real-worker/handoff/renewal cases pass, zero skipped; TRX SHA-256 is +`12be32c0e7326198d183cbfdafefa373432981e331044826d980c65376c0d837`. +The one-hint control takes 2.196 seconds. Its early-hint assertions remain under +the actual original cleanup hold. This is a test-precondition correction, +not a claim to have reproduced the hosted failure's exact causal interleaving. + +A new complete source-bound A/B qualification follows these corrections. The +failed runs and their independent component evidence remain in this log; PR #36 +stays draft until qualification succeeds. + +### P02b-7 third hosted A — closed browser-readiness evidence (2026-10-24) + +The [A run on `1784507c`](https://github.com/HodeTech/LearnStack/actions/runs/40043096810) +finishes with 25 successful jobs and two failures. Meta and both corrected +native worker populations pass; all six BFF cases, 31 native cases, backend +aggregation and stopped-provider public demo pass. The ordinary-9 producer +fails only the existing public-admission browser fallback case; integration +aggregation correctly refuses it. This is not complete source qualification. + +The case lasts 59.507 seconds. Its retained location is the child exit assertion; +the closed detail is `browser 429` / `browser-devtools`. The first value names the +intended scenario, not an observed HTTP failure. Existing attribution cannot +distinguish owner closure, unreadable/incomplete port-file state and the live +browser readiness deadline. The VM memory peak is not evidence of OOM, and the +phase alone does not prove a timeout. The exact real case passes locally before +the correction, so the hosted cause remains unproven. + +The test-only readiness observer now retains five branded closed kinds: +`owner-inactive`, `port-unreadable`, `deadline-missing`, `deadline-empty` and +`deadline-malformed`. A single file read replaces the existence-check/read pair; +ENOENT remains incomplete readiness within the existing poll. This closes a +structural check/read race without claiming it occurred in CI. The original +20-second/50-millisecond readiness budget/poll stays intact; final owner/liveness +observation adds no wait and keeps observed child death distinct. There is no +browser launch retry, security-flag change, skipped case or timeout enlargement. +Raw exceptions, file bytes/paths, ports and browser output remain private. + +Only the exact public-admission case and its two browser scenarios may export +the five kinds through the existing parser. Plain forged diagnostic objects do +not gain the JavaScript brand. Both independent round-2 reviewers inspect the +applied change and approve its privacy, timing and positive/counter controls; +this is focused confirmation of the material diagnostic refinement. + +All 51 focused browser-observation controls and 2,097 guarded frontend cases +pass, zero skipped/todo. Lint/typecheck pass. The actual Docker/browser case +passes with the correction in 36.294 seconds; TRX SHA-256 is +`91453c18f5af9927f743f357408e817c65b2da1e812b5ebd555f28edd98c7df5`. +Actual file states, partial publication, private exceptions, unchanged deadline, +boundary death, eventual readiness and forged diagnostics have reachable +controls. All 65 execution-protocol and 26 aggregate controls pass. Passing local +observations do not label the old failure harmless or prove its cause. Complete +same-source hosted A/B qualification remains pending. + + +### P02b-7 first complete A and failed B — preserve the first cause (2026-10-24) + +The [run on `c245afcc`](https://github.com/HodeTech/LearnStack/actions/runs/40045256382) +uses merge checkout `77085cbff7e4782564ef3c33d96df4d089eaf87c`, whose tree equals +the reviewed source. Attempt 1/A succeeds in all 27 jobs and seven required +contexts: all 5,930 .NET cases, all 2,097 frontend cases, all six BFF cases and +31 native cases pass without skips. The stopped-provider public demo passes. +Attempt 2/B finishes with 25 successful jobs and two failures. Its sole failed +case is the existing source-floor contention proof; integration aggregation +correctly refuses the failed ordinary-7 producer. All 5,930 cases finish with +5,929 passes and one failure. This is not complete same-source A/B qualification. + +The failing case takes 41.552 seconds and exports cancellation without a source +location. Its original explicit cleanup can replace an earlier failure, so the +receipt cannot establish whether startup, execution or cleanup first failed. +The unchanged case passes locally in 17.979 seconds. No production defect or +historical causal interleaving is inferred from either observation. + +The test now captures its first catchable body/explicit-cleanup failure, attempts +every pressure-connection disposal, releases both holds and separately attempts +stop and actual completion. Failure-only memory snapshots preserve closed +boundaries, task states, pressure counts, runtime state and finite lease-loss +counters before and after cleanup. A fixed source alias restores bounded line +locations. The exact-case parser rejects private, malformed, duplicate and +wrong-channel data. These observations never supply cleanup or admission +capability. No pacing, lease clock, startup/execution/cleanup budget, production +behavior or successful assertion changes. + +Both independent round-2 reviewers approve this focused source correction; +neither claims to have reproduced the hosted cause. Release compilation reports +zero warnings/errors. Two separate corrected real PostgreSQL/native-worker +executions pass in 18.024 and 17.895 seconds, zero skipped; their TRX SHA-256s are +`4dfce5cd0d8914d197f9b7f3154fbe51bc67fe5dc2dc6a91c58817c47902e2cf` +and `2ee1e199ea9a1786a8a3f3def2c2670913f21448cfa589e652399f924bae16ae`. +All 68 execution-protocol and 26 aggregate controls pass. Local passes do not +prove that the historical hosted cancellation is resolved. + +Evidence retention has a separate limit: the all-jobs rerun removes earlier +Actions artifacts. The original A aggregate receipts, workflow metadata and one +producer package were saved first; all 20 original producer job logs remain +available and are independently captured. Their actual 5,930 unique case IDs, +outcomes and durations match the retained complete discovery, with all producer +ownership/cleanup results successful. Six masked display names are discarded, +not reconstructed. These derived case records are explicitly job-log recovery, +not original Actions artifacts. All 20 original B producer packages are now +retained. Future complete A evidence is downloaded before starting B. + +PR #36 stays draft. New complete source-bound A/B execution remains required; +no failed-only rerun, skip, timeout increase or failing-check waiver is used. + + + +### P02b-7 hosted causal-restart correction (2026-10-24) + +The [A run on `f2a3814b`](https://github.com/HodeTech/LearnStack/actions/runs/40049101128) +finishes with 25 successful jobs and two failures. All 5,930 cases complete: +5,929 pass and the existing causal-restart cancellation case fails in 52.717 +seconds. Integration aggregation correctly refuses ordinary-4. The preceding +source-contention case passes in 16.585 seconds and the public-admission browser +case in 56.026 seconds, with complete ownership/cleanup and no forced cleanup. +All six BFF cases, 31 native cases, backend aggregation, frontend and public demo +pass. All 20 original producer packages and workflow metadata are retained; no +B rerun follows this failed A. + +The failed case retains only source locations 199/217/235, without an exception +kind or row facts. Three unchanged local variants pass in 22.156/22.231/23.436 +seconds. Those observations do not establish the historical failure's exact +cause. Source analysis does verify an incorrect expectation: Finalize may retry +under fresh authority, but this causal test requires exactly one finalizer and +two ledger rows. Successful terminal state and its terminal MUST audit already +share the actual Finalize transaction; adding a generic audit-dispatch wait +would not correct that expectation. + +A narrow actual counter delegates all work to the genuine registered Finalize +handler, then raises one concurrency conflict after a successful real nested +write. Its next admission uses a different actual application source owner. +With the original assertions unchanged, this counter records two handler calls +and exactly one forced conflict, then fails with expected 1/found 2. The red TRX +SHA-256 is +`88f55b4b8766ec40fdd03b2e9891e10847c3882a5d2939da8da36f30dbd2e98d`. +The counter claims its one injection after genuine success, preventing a natural +first-call failure from bypassing the planted conflict. + +The corrected assertion requires one immutable normal attempt, 1–6 admitted +finalizers and exactly one ledger row per admission. The counter additionally +requires at least two finalizers, exactly one injected conflict and different, +actually cleaned injected/final source owners. One Execute, zero external +effects, immutable provider completion/evidence, one reservation release, one +committed final organization, one terminal audit and the two committed physical +application transaction roots remain exact. The same positive assertion still +rejects the deliberately false cancellation report. + +The count census also finds that completed Reconcile reads may repeat if the +private gate closes before Outcome admission; actual cleanup and lease recovery +then reread the same provider attempt. The test therefore requires at least one +real read and, for the dirty adapter, at least one mutation bounded by actual +durable read calls. It invents no six-read ceiling. Execute/effect and committed +business/audit evidence remain unchanged. Both fresh round-2 reviewers approve +these focused corrections after independent source diagnosis. This establishes +legal retry behavior, not the historical hosted interleaving. + +Failure-only projections retain finite boundaries, already-read app-role row +counts, a fixed nine-column null mask, closed state/policy/reason and runtime +counters. No additional SQL, provider call, identity or raw exception is exported. +The transaction-witness boundary starts only after row decoding. The first body +failure survives later explicit cleanup failures; request/release/stop and +actual completion are attempted under the original cleanup budget. + +Five focused real-worker/retry cases pass, zero skipped; TRX SHA-256 is +`de0f7bdb0eaff3edf7874aedb2222d7bb5b73b71ddff7e8546c925ff95a22bd4`. +The three causal variants are independently repeated in 22.226/59.409/22.166 +seconds with the final diagnostic attribution; TRX SHA-256 is +`8f004ff7e3631179620ed3ca5cc8873402520ec147f537cca224eb2a6b3d242d`. +The final repeat-read assertion reconciliation is source-qualified separately; +these local TRXs do not embed an immutable source. Final Release compilation has +zero warnings/errors; all 71 execution, 67 assignment and 26 aggregate controls +pass. The extra real retry journey increases case cost, so its measured local +selector sum updates the existing advisory weight with its own retained source. +No test row, runtime behavior, lease clock, operation budget, job count or ceiling +changes. Complete source-bound hosted A/B qualification remains required before +PR #36 leaves draft. + + +### P02b-7 hosted BFF-browser diagnosis (2026-10-24) + +[Run `40052155438`](https://github.com/HodeTech/LearnStack/actions/runs/40052155438) +uses source `419a8d9beb6682d4b27d2b7610345a75b0b98d4d`, source tree +`6cff20b2996482fc441d1e5b57e33fdbf8346038` and merge checkout +`ca3826d5662f50701dfc9920bbe0912062ded0bd`, whose tree matches the source. +Attempt 1/A passes all 27 jobs and seven required contexts. All 20 original +producer packages, both aggregate receipts, workflow metadata and public-demo +evidence are retained before starting attempt 2/B. Independent verification +checks their artifact hashes, exact 5,930-case union, disjoint ownership and +successful actual cleanup without forced cleanup. The largest producer's +whole-job duration is 999 seconds. Manifest and plan digests are respectively +`43abf742a0454afe30495c117978008f84c75a31a30b4adda7a2a479987995e2` +and `69bbbf02314096983765b4fda1ded636c3698903d4b714393b4280c5e504016b`. + +Attempt 2/B finishes with 25 successful jobs and two failures. Its sole failed +case is the BFF HTTPS browser proof in ordinary-11, taking 24.009 seconds; +integration aggregation correctly refuses that producer. All 5,930 cases +complete, with 5,929 passes and one failure. All 20 original producer packages +and workflow metadata are retained. The three causal-restart variants and +public-admission browser proof pass. This is not complete A/B qualification. + +The failed browser case exports only `invalid_state`, with no source location +or closed fixture stage. `owned_completion_missing` is the nonzero-workload +refusal. The failure receipt's forced-cleanup/default Docker fields alone do +not establish a browser leak or the first failing operation. The unchanged +actual browser case passes locally in 33.333 seconds, zero skipped; TRX SHA-256 +is `98b9a222b44b3f9d321f5c5e0810b0614fb8a786bef3e2a7515cde9c5ae3a0b7`. +Neither observation establishes the hosted failure's cause. + +Source inspection verifies two diagnostic defects: both the browser fixture +and its .NET driver can replace an earlier failure with a later cleanup +failure, and the CI projection omits BFF source aliases and closed reasons. +The fixture now retains the original stage and separately marks failed cleanup. +The driver always attempts actual disposal, preserves its first catchable body +failure and separately records secondary cleanup failure. Cleanup-only failure +still propagates. Exact-case CI diagnostics retain finite journey/reason codes +and bounded source locations; private text, paths, credentials and arbitrary +output remain excluded. No elapsed time or diagnostic becomes cleanup evidence. + +Two independent focused source reviewers approve the correction. All 74 +execution, 67 assignment and 26 aggregate controls pass together in 23.742 +seconds, inside their existing thirty-second allocation. Release compilation +reports zero warnings/errors. The corrected actual browser proof, including +positive/counter journeys and direct closed-reader controls, passes in 31.917 +seconds, zero skipped; TRX SHA-256 is +`e9db4e5bf584fe0fd78835f21452ce79e3b01f8e35e5dc7550ea741d8043b675`. +These local receipts do not claim a reproduction or resolution of the hosted +interleaving. No runtime policy, readiness algorithm, timeout, retry, case row, +advisory weight, job count or ceiling changes. Source-bound complete hosted +A/B qualification remains required; PR #36 stays draft. + + +### P02b-7 implementation and hosted qualification closeout (2026-10-24) + +All five implementation steps and both independent review rounds per step are +complete; the [Step 5 local closeout](#p02b-7-step-5--round-2-corrections-and-local-closeout-2026-10-24) +retains their source and local proof records. The subsequent dated CI records +retain failed attempts and focused corrections without replacing that history. + +[Run `40056362565`](https://github.com/HodeTech/LearnStack/actions/runs/40056362565) +qualifies implementation source `4ce7d4dbf7619fadeabe7d1c35932339c483b1fd`, +tree `62c97d0b31f641eedf0d4c10ecc38f1182534c6a`. Both attempts use merge checkout +`a8161e6899ad8e9fe644a8cad62781fe7ca00915`, whose tree matches that source. +Attempt 1/A and the complete all-jobs attempt 2/B each pass all 27 jobs and +seven required contexts. No failed-only rerun, skip or failing-check waiver +supplies this qualification. + +All 20 original producer packages per attempt, aggregate receipts, workflow +metadata and public-demo evidence are retained locally. A's originals are saved +before starting B, which can replace earlier Actions artifacts. Independent +verification recomputes retained file hashes, exact case/selector unions, ownership, +unforced cleanup, scoped Docker absence and native-only collector authority. +Each attempt passes the same 5,930 .NET cases: 3,351 non-Docker and 2,579 Docker, +including 31 native crash-proof cases. There are zero skipped or failed cases. +Manifest and plan digests are respectively +`43abf742a0454afe30495c117978008f84c75a31a30b4adda7a2a479987995e2` +and `69bbbf02314096983765b4fda1ded636c3698903d4b714393b4280c5e504016b`. + +Actual co-membership differs in both movable populations and in the ordinary +methods of the 77 source-declared `SharedSchema` classes. Owner changes are: + +| Population | Selectors changing owner | Cases changing owner | +|---|---:|---:| +| Architecture | 220 | 371 | +| Ordinary Docker | 1,074 | 2,256 | +| `SharedSchema` ordinary subset | 901 | 1,874 | + +The last row is a subset, not an additional population. Source, plan, complete +case set and actual toolchain match between attempts: SDK `10.0.402`, console +`18.7.0-release-26505-111` and loaded adapter `2.8.2.0`, including their retained +hashes. The largest producer whole-job durations are 938/908 seconds for A/B; +backend aggregates take 16/21 seconds and integration aggregates 28/29 seconds. +All remain within the unchanged twenty-/five-minute ceilings. The workflow +still uses 20 backend/test execution VMs and the same seven required names. + +Both attempts also pass 2,097 frontend cases, zero skips/todos, and the public +demo's nine pages and 27 Lighthouse JSON/HTML report pairs with Keycloak stopped. +Public-demo cleanup independently confirms stopped processes, empty Compose +ownership and removed private files/environment/lock in 13,141/12,963 ms. +All six BFF cases pass in each layout, including the actual warm-provider/native +BFF/application-role API chain and the separate actual HTTPS browser proof. +The browser proof uses fake grants/API; its explicit history-event counter is +not a natural BFCache claim or a full browser-driven OIDC journey. Phase 06 +retains that broader suite. Local English/Turkish sign-in and protected-shell +visual inspection remains recorded in Step 5. + +The earlier browser failure's historical cause remains unproven. Passing this +pair does not retrospectively establish it. The retained correction preserves +first failures and secondary cleanup separately. Three additional local driver +controls exercise actual owned Node children: body plus failed disposal, +body plus failed disposal and a throwing observer, and cleanup-only failure. +Each retains the expected first failure and confirms child absence; these are +controlled protocol/cleanup counters, not product browser proof. Their closed +three-pass receipt has SHA-256 +`0f04126fecca80ba7eec888ee4979af9ac9b8866edcf8b24dd5cc2e250e8b941`. + +This closeout changes documentation only. Runtime/test sources, plan and +workflow remain byte-identical to the qualified implementation. Normal +current-head checks are still required before [PR #36](https://github.com/HodeTech/LearnStack/pull/36) +leaves draft; maintainer review and merge follow separately. The +[phase Status](phase-02b-events-auth.md) owns packet state; P02b-8 owns remaining +exit enforcement and erasure inventory work. + + +### P02b-7 external review corrections and disposition (2026-10-24) + +The correction baseline is `5fc18ce7`. Current-code inspection verifies the +findings individually; mutation-survival totals and speculative scenarios are +not treated as reproduced product failures. PR #36 returns to draft while the +changed source is validated. Earlier source-bound receipts remain historical. + +#### Verified behavior and proof corrections + +- Enabled public projection treats unsupported Authorization and malformed BFF + cookies as anonymous. Qualifying GET documents clear only malformed owned + cookie names, with bounded deletion headers and no reflected values. HEAD, + prefetch and passive requests make no cookie writes. Auth routes retain their + strict input/Origin/CSRF checks. +- A temporary ordinary AccountSite failure withholds the response's authenticated + projection, preserves the live owner and does not advance idle time. Protected + reads return 503; public pages render anonymously. Identity refusal or scope + conflict still revokes. Post-refresh authority failure still revokes under the + consumed-grant rule; no grant is retried. Owner and signed-provider controls + also prove replacement refresh-token use and omitted-token preservation. +- The native sign-out response confines Location to the fixed provider logout + endpoint or the independently verified local root. It suppresses Next's 303 + URL body, preserving separate cookies and write/end callbacks. Actual Next + canaries exercise the ID-token-hint Location exception without a body leak. +- BFF responses and protected HTML gain frame denial. Accept alone no longer + qualifies activity. Header links have spacing; expired UI has its own message; + Studio/Portal gain localized document titles, retaining their existing h1s. + Institution display text is labelled as institution, not a person's name. +- RP and SDK actor UUID validation matches the API's non-nil UUID contract rather + than requiring version 7. Configuration failures have six closed causes; + startup forces TLS verification before loading Next. Source agreement and + private-file validation remain unchanged. +- Browser waiters attach rejection handlers before keyboard actions. Readiness + uses the shared atomic DevTools reader and checks failed spawn/exit/signal with + its original ten-second bound. Sign-in, extension and sign-out use Enter. + Protocol/session/browser proofs preserve first failure and separately mark + cleanup failure; the driver accepts only each matching closed two-line form. +- Cookie diagnostics retain all output up to a fixed 256 KiB per child, recheck + subsequently learned canaries and refuse overflow. They do not silently lose + earlier text through a rolling window. CSRF's allowed form/JSON projection is + distinct from its prohibition in diagnostics. The default protocol harness is + labelled as a minimal harness; the separate UI mode copies product middleware. +- Added planted CI controls reject missing durations, a foreign partition case + with matching surrounding evidence and false/ill-typed Docker-absence claims. + The four BFF catalogue names identify their existing executable owners; stale + extension statuses and phase ownership/flow illustrations are reconciled. + +The first real local run exposes two correction-fixture defects, not passing +proof. Removing Accept-only activity correctly makes its synthetic document +request passive, so the real-provider fixture supplies document fetch metadata +and retains the exact seven-read expectation. The browser reaches the expected +303 but its Playwright route does not intercept the redirected authorization +request; a closed debug observation records `ERR_NAME_NOT_RESOLVED`. Pinned +Playwright's redirect continuation bypasses that route interception. Authorization +and logout now use actual endpoints on the proof's owned HTTPS provider stub; +both authorization visits, wrong-host rejection and real callback navigation +remain required. This does not establish the cause of the earlier hosted failure. + +The corrected real run passes all six BFF cases and three causal-restart variants, +zero skips. The warm historical realm is automatically prepared/reconciled and +then converges with zero writes before real native BFF/application-role API, +refresh/logout and provider-event verification on both hosts. Chromium +`156.0.8078.48` completes the separate fake-grant/API browser positive and history +counter in 25.957 seconds. Natural persisted restores remain zero; the explicit +history-event counter is not a natural BFCache claim. Real-provider evidence +passes in 76.352 seconds; owned provider containers/network are independently +absent after cleanup. TRX SHA-256: +`d6ae73206ce7684af8f78a8aded3db302d6d9566b6e25e22933e9761dca77898`. + +#### Causal-restart assertion and evidence reconciliation + +Unforced paths with no observed runtime lease loss again require exactly one +Reconcile, one finalizer and two ledger rows; the dirty report is applied once. +Only observed lease loss or the explicit real-handler concurrency counter uses +the legal retry ranges. Normal attempt, Execute, effects, final business write, +reservation release and terminal audit retain their exact assertions. Closed +counts are emitted on every successful qualification. This local run observes +`1/1/2` Reconcile/finalizer/ledger in every unforced path with zero lease loss; +the deliberate conflict observes `1/2/3` and different actually cleaned sources. + +The original successful A/B pair was the first complete successful pair for this +packet. It followed six runs with a failed attempt: +[40037549651](https://github.com/HodeTech/LearnStack/actions/runs/40037549651), +[40040983884](https://github.com/HodeTech/LearnStack/actions/runs/40040983884), +[40043096810](https://github.com/HodeTech/LearnStack/actions/runs/40043096810), +[40045256382](https://github.com/HodeTech/LearnStack/actions/runs/40045256382), +[40049101128](https://github.com/HodeTech/LearnStack/actions/runs/40049101128) and +[40052155438](https://github.com/HodeTech/LearnStack/actions/runs/40052155438). +The preceding dated records distinguish demonstrated fixture defects from +historical source-contention, causal-restart and browser interleavings whose +causes remain unproven. Passing later runs does not explain those interleavings. +No claim that success assertions were unchanged is retained in the PR summary. + +Control accounting is also clarified: the former 67 combined assignment controls +already included 26 aggregate controls. They are not additive totals. The current +scripts pass 75 execution controls and 68 combined controls (41 assignment plus +27 aggregate), in 17.404 and 3.020 seconds locally. No workflow ceiling changes. +Original A-attempt producer artifacts were retained locally before rerunning all +jobs for B; they are not claimed available from GitHub after replacement. + +#### Findings that do not justify another policy change + +| Concern | Current evidence and disposition | +|---|---| +| IPv6 addresses, NAT/proxy peers and capacity monopoly | The limit is per canonical verified IP, not per person or /64. Multiple addresses can spend multiple allowances; a shared proxy combines visitors. ADR-0070's dated clarification and the support guide state the limit; subnet fairness and production proxy qualification remain Phase 11's named trigger, not a new guarantee. | +| Published development secret accepted by a production-mode Next build | Production-mode compilation is not a qualified production deployment. Existing single-process/development-provider support remains explicit; Phase 11 owns production credentials, provider events, host registrations and proxy/distributed qualification before activation. No unsupported production security claim is inferred. | +| Conflicting root/web environment values; unsafe files in disabled mode | Source agreement and no-follow/bounded private loading are intentional. Guidance now teaches changing every present activation source and the launcher distinguishes closed failure causes. Silently overriding or ignoring an unsafe file would weaken the accepted boundary. | +| Secret visible in Next server process; ambient proxy/CA settings | A local probe confirms server-process environment visibility, not browser export. Native and Next code share one server trust boundary; no public alias is emitted. The actual ambient TLS-disable bypass is closed. No exploit or forbidden endpoint was demonstrated from trusted operator proxy/CA configuration. | +| JWKS fetched again per grant | Confirmed; each grant keeps its own finite transport owner. A shared mutable fetch callback would violate isolation. No measured fit failure justifies changing that ownership for an unmeasured cache optimization. | +| Server monotonic sleep semantics and browser clock | The server, including its expiry and epoch checks, authorizes requests; the browser clock only drives display. A server-suspend exploit was not reproduced. Production clock/availability behavior is not established by this development proof. | +| Five-minute transaction vs provider login timeout | The deliberately shorter local transaction refuses a late callback; it promises no provider-screen lifetime. Increasing it would change the accepted capacity/lifetime tradeoff. | +| Real historical identity path, token rotation and full browser OIDC | The existing real-provider case executes historical preparation, zero-write convergence and authenticated BFF/API on both hosts; signed adapter/owner rotation controls are strengthened. The separate browser proof remains fake-grant/API. Full browser-driven provider login and Firefox/WebKit remain Phase 06's explicit scope. | +| Different A/B co-membership and narrow CI timing margin | The guard compares method groups, rejecting shard renumbering; it does not promise a movement percentage. Earlier actual movement is quantified in the original closeout. Weights are advisory and measured hosted fit is source-bound; no automatic topology/budget expansion is introduced. | +| Aggregate API failures and failed-only reruns | Fail-closed current-attempt ownership under ADR-0063 requires a complete rerun. Opportunistic evidence reuse or an unqualified retry policy is not added as a review shortcut. | +| Duplicate standards, owner wording and bounded readers | Correct stale owners, terms and executable source references rather than broadly refactor independent protocol boundaries. No demonstrated shared-reader bug or obligation requiring a new generic abstraction was found. | + +ADR-0070 gains a dated delivered-owner/read-failure clarification without rewriting +its Accepted decision or claiming future runtime was false when accepted. The +Phase 03, Architecture 04/13, Security 11, Catalogue 21, glossary, web guide and +review skill carry their current responsibilities. New tenant-host callback and +post-logout registration is explicitly owned by Phase 11 before production +activation. No new ADR, schema, role, grant, endpoint, token TTL, owner capacity, +operation timeout, shard plan, execution VM or required-check name is introduced. + +The first fresh independent review pair finds and closes local-root fallback, +passive cookie writes, late-learned canary retention and planned-vs-delivered +proof-owner attribution. Final complete frontend/lint/typecheck, second fresh +review and new source-bound hosted qualification are still required; none is +inferred from the original green head. The phase Status owns current delivery. + + +### P02b-7 correction reviews and local validation closeout (2026-10-24) + +Both fresh independent correction review pairs complete. The second pair +confirms product authority/lifecycle behavior and closes two additional evidence +paths: strict causal-count failures now emit their closed census before the +assertions and enter the existing diagnostic catch; startup disposal always runs +without replacing an earlier catchable startup error, including when a secondary +observer throws. The original Accepted ADR table remains intact; its delivered +owner clarification lives only in dated Amendment 1. + +Two actual Node children deliberately retain their own private trees after +reporting a closed compile/cleanup failure. Startup must preserve that exact +first stage, separately observe actual disposal failure and join child exit, +with both ordinary and throwing observers. The counter independently confirms +child absence, then removes only its validated owned retained tree. The existing +parent-EOF/held-build case includes both controls; no new case row is added. +Its final passing TRX SHA-256 is +`58b74cc2188697e1f17bbcb8d65f54997b16022df4cbf28c1b81a89ba2420644`. +The three causal-restart variants and the startup case also pass together, zero +skips; TRX SHA-256 is +`dfb794aa029a43c49eff28874374c2e8abadd6e048c4cbfdb4856b5039b2d22e`. +Passed count output lives in these local raw TRXs; sanitized hosted receipts do +not retain successful StdOut, and no contrary remote-retention claim is made. + +The complete guarded frontend passes 2,160 cases in two packages with zero +skips/todos, including actual minimal Next protocol, actual product UI and +negative diagnostic/activity/shutdown controls. Lint and typecheck pass. Backend +Release compilation reports zero warnings/errors; all 2,616 Unit cases and five +CorpusConsistency architecture cases pass. ADR parser controls pass seven cases; +ADR-0070 remains Accepted. The separate nine-case real integration receipt, +75 execution controls and 68 combined assignment/aggregate controls remain +recorded above. Independent round-2 checks additionally pass 154 targeted +runtime/security tests and 57 proof-support/DevTools tests; these are subsets, +not additive populations. + +No service credentials, private review files or foreign worktrees are changed. +The final working-tree receipts establish local validation, not immutable-source +hosted qualification. Commit, complete new-source A/B execution and current-head +required checks precede PR readiness; PR #36 remains draft meanwhile. + + +### P02b-7 correction source handoff (2026-10-24) + +Commit `903ed92f` contains the verified runtime/test/document corrections. Both +fresh review pairs and focused confirmations are complete. This handoff changes +only delivery documentation; runtime/test sources remain byte-identical to that +commit. PR #36's Tests section records the new qualification source, hosted run, +A/B attempts and current required-check result as they complete. Earlier runs +and locally retained originals are not substituted for those current checks. + +The phase Status records implemented/reviewed delivery without copying transient +CI job state. Required checks and exact-source A/B qualification still govern PR +readiness; maintainer review and merge are separate. Recording live CI receipts +in the PR avoids another documentation-only source change after qualification. +No full provider/browser, production or historical failure-cause claim is added. + + +### P02b-7 startup-counter cleanup confirmation (2026-10-24) + +A final focused inspection finds that the new startup counter's failure branch +could delete its trees before independently confirming child absence. Recursive +cleanup now requires that confirmation; unknown/alive child state retains both +trees and fails. Both ordinary and throwing-observer actual Node controls, plus +the held-build parent-EOF journey, pass again with no skips. Release compilation +has zero warnings/errors. TRX SHA-256: +`99532b0b06846d7ab0655e499cf6f42a34e2f6fcc8e812d34f08e7f3fe98c17a`. +The independent reviewer confirms the focused delta before commit/push. + +Run [40070734838](https://github.com/HodeTech/LearnStack/actions/runs/40070734838) +on the preceding source is intentionally cancelled for this test-hygiene +correction and supplies no qualification. It is not a passing rerun or a waived +failure. No further delivery-only commit is needed for live CI receipts: the +PR records the final source and its complete new A/B attempts. + + +### P02b-7 hosted recovery proof follow-up (2026-10-24) + +Run [40071690886](https://github.com/HodeTech/LearnStack/actions/runs/40071690886) +on correction source `5e07cce6` completes attempt 1/A with all 27 jobs and seven +required contexts passing. Its 5,930 .NET cases include 31 native cases; frontend +passes 2,160 cases and the demo supplies nine pages and 27 Lighthouse pairs. +Attempt 2/B refuses before native provisioning/test launch because Docker +image-layer provenance cannot be established. Its other 19 producers pass 5,899 +cases. The refusal proves neither an actual dump nor a specific Docker failure. + +The complete attempt 3/A passes native 31/31 and 25 jobs but records one failed +normal Docker case: the lost-acquisition-acknowledgement variant of +`Actual_native_recovery_owner_renews_past_old_deadlines_without_admission_or_another_Execute`, +in 21.443 seconds. Its other variant passes in 20.649 seconds. All original +producer packages and available aggregate/demo receipts are retained locally; +independent case accounting gives 5,929 passed and one failed case. The integration +aggregate correctly rejects that failed producer. Workload/ownership refusal +fields do not independently establish a resource leak. + +Three unchanged-source local repetitions pass all six recovery cases. They do +not establish the historical cause. Source inspection instead verifies three +narrow proof/diagnostic gaps, now corrected: + +- Capture the initial timer-change count before starting control and require a + later change. Initial timer setup alone is not renewal evidence; the real SQL + lease, old-window survival and exact provider/attempt assertions remain. +- Preserve the first catchable body error while attempting actual-operation, + work and control cleanup. A later cleanup error stays a separate observation. +- Add the missing source alias and retain exact failed-case-only closed + observations for this pair. Stages, exception categories, task states and bounded memory + counters contain no provider text, identifiers, credentials or raw paths. + Diagnostics perform no extra SQL or provider call and grant no completion or + cleanup authority. Capture the cancellation-token value before private-source + disposal so post-cleanup observation does not lose its own record. + +The final Release build has zero warnings/errors. All four actual recovery and +Prepare/renewal controls pass, zero skips, in 41 seconds; five corpus controls +and the documentation checks pass. Final focused TRX SHA-256: +`7cd4581ec96fe72a3f1287e5133cb35c53927528ea1a7eecd2b534498ac3396a`. + +The first independent source review catches that disposed-source observation +error; the captured-token correction closes it. Two fresh independent focused +reviews then approve the C# lifetime/proof and Python containment/control changes. +The full 75 offline execution controls pass, including exact-case, wrong-channel, +foreign-assembly, duplicate, bounds, success-path and canary counterexamples. +Production code, operation/test budgets, shard weights/counts and required checks +are unchanged. The historical CI cause remains unproven. A new complete +source-bound pair remains required; live results belong in PR #36 so recording +those results does not invalidate the source again. + + +### P02b-7 fixture image failure classification (2026-10-24) + +The recovery-proof correction at `c2a7e979` completes the full A assignment in +[run 40076725886](https://github.com/HodeTech/LearnStack/actions/runs/40076725886), +attempt 1: 27 jobs, seven required contexts, 5,930 .NET cases, 2,160 frontend +cases, 31 native cases, nine demo pages and 27 Lighthouse pairs. All 20 original +producer packages, both aggregates and demo evidence are retained locally and +independently hash/identity/case-union audited. The longest producer whole-job +observation is 1,004 seconds; the aggregates take 19 and 39 seconds. These are +measured observations, not future timing guarantees. + +Attempt 2/B fails before restore/build/tests while preparing the fixed fixture +images, in ordinary-10 and ordinary-11. The closed error is +`fixture_image_pull_failed`; the observed ordinary-11 preparation returns in +about 1.75 seconds, rather than exhausting its budget. The doomed attempt is +cancelled after preserving both setup-failure logs and available artifacts. +Attempt 3/A is deliberately cancelled rather than repeat the retained complete +A assignment; it supplies no qualification. All jobs run again in attempt 4/B, +but ordinary-7 returns the same preparation refusal. Its failure log and +available artifacts are preserved before cancellation. No earlier passing +siblings are supplied to an aggregate, and no cancelled attempt qualifies. + +The original helper discards Docker stderr, so these observations cannot +distinguish a registry, network, credentials, manifest or local-daemon failure. +Further blind reruns stop. Only the two fixed image pulls opt into a diagnostic +projection through the existing command owner: + +- Default callers retain stderr discard, direct argv, the scrubbed environment + and their original failure behavior. No new CLI/environment command selector + is introduced. +- Opted-in stderr stays in an unlinked owner-only temporary file, closes on every + path and is inspected within the existing output bound. Only a fixed component + and a closed error category leave the boundary; raw provider text, URLs, paths, + credentials and exception messages are not exported. +- First command failure survives later spool-cleanup failure. A cleanup-only + failure still refuses. The diagnostic is an observation, not a causal verdict + or a positive execution/cleanup witness. +- Fixed images, 55-second command caps, the shared 110-second preparation bound, + execution/cleanup reserve, shard plan/topology and required checks stay intact. + No automatic image-pull retry or additional Docker command is introduced. + +The first review closes primary-error masking by spool cleanup, an invalid +classifier-return success path and spool-setup deadline accounting. Two fresh +independent focused reviewers then approve the final process and contract +changes, including the deadline correction. The final 75 owner controls pass +in 18.837 seconds; 68 combined controls pass in 2.780 seconds (41 assignment +plus 27 aggregate, not additive totals). Actual owned-stub timeout/reaping, +spool closure, canary containment, default behavior, fixed component/image +selection and remaining-reserve controls pass. Five corpus controls, Python +compilation and the diff whitespace check pass. No hosted result is inferred +from these offline controls. + +The historical pull cause remains unknown. The next complete source-bound +qualification must establish the new source; the retained A result is historical +once this diagnostic change is committed. Live CI receipts stay in PR #36 rather +than creating a delivery-only commit after each run. + + +### P02b-7 retained correction-source A/B qualification (2026-10-24) + +This durable receipt records the qualification previously carried in PR #36. +It certifies implementation source `c12f4eed524f218518497eb2355b447ce5d108f1`, +tree `906f934f6f56aa2a9d4b0e940ff6faefc6410098`, against base +`935d8da66878286d6a1695f3d41d985ebce4e791`. The tested merge checkout is +`52d0d57928e6f9cca66c0b8ab2ee19b0736c82db`, with the same tree. It does not +qualify later source corrections. + +[Run 40080617716](https://github.com/HodeTech/LearnStack/actions/runs/40080617716) +completes attempt 1/A and attempt 2/B: all 27 jobs and all seven required +contexts pass in each attempt. Each assignment executes 5,930 .NET cases: +3,351 non-Docker and 2,579 Docker, including 31 native and six BFF cases. +Each frontend population passes 2,160 cases without skips/todos. The public +demo supplies nine pages and 27 Lighthouse report pairs in each attempt. + +All 20 original producer packages, both aggregates and the demo evidence from +each attempt are retained and independently audited. Attempt A's originals +were saved before the full B rerun replaced remote artifacts; this receipt does +not promise their continued GitHub availability. Source/case/selector unions, +current-attempt identities, natural process ownership/cleanup and toolchain +equality pass. Architecture, ordinary, shared and SharedSchema method cohorts +have genuinely different co-membership between A and B; a changed bin number +alone is not the independence witness. + +| Retained identity | SHA-256 | +|---|---| +| Discovery manifest | `43abf742a0454afe30495c117978008f84c75a31a30b4adda7a2a479987995e2` | +| Assignment plan | `69bbbf02314096983765b4fda1ded636c3698903d4b714393b4280c5e504016b` | +| Independent complete-pair audit | `3df848bfbd12ba13e6c6178bc6543699d5d78ebb42da50feaa54d22456ab9709` | + +The longest observed producer whole-job durations are 1,010 seconds for A and +924 seconds for B, within the unchanged 1,200-second ceiling. The two aggregates +take 17/34 seconds in A and 21/27 seconds in B, within 300 seconds. Demo cleanup +takes 10,665/12,371 milliseconds; every required process/container absence and +removal observation is true. There is no forced-cleanup qualification. These +are observations of this source, not guarantees about later duration or +capacity. Both fixed image pulls succeed here; the historical preparation +failure's root cause remains unknown. + +### P02b-7 second correction package and ADR-0071 proposal (2026-10-24) + +A further source review distinguishes current defects, regression-proof gaps +and suggestions that would change policy. PR #36 returns to draft while these +corrections are implemented and qualified. The completed A/B receipt above is +historical evidence; it is not reused as qualification for changed code. + +The only newly proposed runtime policy is +[ADR-0071](../decisions/0071-uncertain-account-site-refusals.md). ADR-0064's +present invalid-or-undecidable credential contract returns 401 for both causes. +The actual API control +`Discovery_failure_on_an_actual_public_route_is_401_and_never_anonymous` +passes independently: a normal signed token plus discovery 503 produces API +401. The current BFF instead treats every valid 401 problem as proven identity +rejection and closes the ordinary-read owner. The proposal retains that owner +without touching idle time and withholds checked authenticated content; initial +callback and post-refresh checks retain their stricter rules. Genuine 401 +revocation is consequently delayed. Approval is required before that mapping +or its dependent normative carriers change. + +The correction sequence is bounded by the existing session/CI contracts: + +1. Close exact HTTP/source census and cancellation proofs; independently bind + session lifetimes, logical custody cleanup, response handoff and provider + transport/TTL arithmetic. Apply compatible security patches and prove the + existing native Next.js seam against the resolved version. +2. Correct accessible recovery and warning state; strengthen the actual + provider/browser, real API, warm-realm, causal-job and CI ownership controls. + Retain closed diagnostics and current operation, cleanup and job budgets. +3. Reconcile current architecture, standards and named future-phase acceptance + owners. If ADR-0071 is approved, accept it first, append its own ADR-0070 + disclosure and implement its isolated mapping and composed controls. +4. Complete both fresh independent review rounds for each correction package, + fix verified findings, then freeze implementation and run the complete + source-bound hosted assignments. Record their source/tree, populations, + cohort independence and cleanup in the repository; compact the PR body and + attach a safe current UI image. Merge remains the maintainer's action. + +| Finding family | Verified disposition | +|---|---| +| Ordinary AccountSite 401 | Policy defect confirmed against actual API behavior; ADR-0071 proposed, dependent mapping unchanged pending approval. | +| Matcher, routes, methods, Origin, raw logout body and HTTP cancellation | Current production gates are present; add independently discriminating negative controls through their real owners. | +| Session lifetime/custody and provider bounds | Add independent clocks, provider windows, scalar logical retention/index observations and valid-response transport twins; scalar cleanup is not heap zeroization. | +| Expiry projection and recovery UI | Stabilize monotonic-to-UTC projection; keep protected content hidden after failure while preserving sign-out/reauthentication and a truthful interactive retry. | +| CI ownership, warm event policy and real API proof | Plant isolated violations in the existing controls; use retained original cookies and explicit cross-tenant audit expectations rather than a cleared cookie jar. | +| Causal job observations | Tighten the forced, no-observed-lease-loss branch and retain closed success/failure facts. No production renewal policy is inferred from an unproven historical cause. | +| Dependencies | Patch verified affected compatible releases; independently assess any remaining advisory against actual runtime/build exposure. No required audit check or topology is silently added. | +| Documentation and future-phase ownership | Correct stale current descriptions; add concrete deliverables/criteria for already named future work rather than promising unowned proof. | +| Request nonce, extension cooldown and lock-contention renewal change | No current defect established. ALS/provenance binds each owned request; accepted explicit extension refreshes provider idle; historical contention causality remains a hypothesis. | +| Blanket reason-name census and all suggested mutations | A string appearing in a test is not semantic proof. Add isolated controls for the verified guards and preserve meaningful clean twins. | +| Existing JWT detector, fixture cancellation and Secure assertion | Existing independent controls already exercise these requirements; extend only distinct uncovered boundaries. | + +Two independent first-round ADR reviewers and two fresh second-round reviewers +inspect the source contracts, policy boundary, governance and proof plan. The +first round corrects the Phase 06 reference and narrows the callback negative +to preserve a prior live replacement owner. Both fresh second-round reviewers +approve without an actionable finding. These are source reviews, not execution +claims. The ADR parser reads Proposed; all seven parser controls and the full +documentation checks pass, including 2,222 fragment links. Implementation +corrections and their tests remain in progress; no passing hosted result or +policy approval is inferred from this proposal. + + +### ADR-0071 acceptance and review corrections (2026-10-24) + +The maintainer authorizes acceptance and immediate implementation after closing +still-valid review findings, without another approval request. The revised +[ADR-0071](../decisions/0071-uncertain-account-site-refusals.md) is Accepted before +its dependent mapping changes. The approved correction sequence above remains +the delivery plan; this record grants no additional endpoint or grant policy. + +The follow-up clarifies that Amendment 1 of ADR-0070 enumerates no status map: +ADR-0071 completes that gap and ratifies its ordinary-read availability choice, +rather than superseding an invented 401 clause. Existing availability retention +and the new 401 mapping are distinguished. All other valid API problems are +covered; masked 403/404 remain conservative access-boundary refusals with an +explicit configuration-outage cost. The shared legacy `identity` category does +not certify an invalid person. Callback/extension 401 changes BFF +400/`identity` to 503/`provider_refused`, while refuse/revoke remains mandatory. + +The decision records cached-key and pre-refresh availability limits, explains +the new package's positive API witness requirement and rejects publishing a +refreshed package without it. A later user read is distinct from an automatic +retry. Reason overloading, named Phase 03/06/11 owners, concrete existing +catalogue obligations and the taxonomy references are explicit. No new glossary +term or second failure taxonomy is introduced. The API discovery-failure control +is being expanded from its actual public route to the actual AccountSite route. + +Two fresh focused policy/governance reviewers independently inspect these +corrections against the source and Accepted contracts; both approve without an +actionable finding. These are source reviews. ADR-0070 gains its own dated +Amendment 2, its earlier history remains intact, and Security 11/Architecture 13 +reference the new decision. The decision index moves the single 0071 row to the +active table. Runtime and composed proof completion follow in a separate commit. + + +### P02b-7 correction foundation implementation (2026-10-24) + +The first correction package closes exact matcher/config/route/Server Action +source census, imported production-directory and transport/storage bypasses, +the complete method/path set, all three Origin-negative forms and HEAD side +effects. Cookie attributes/lifetimes are checked at the actual route; raw TCP +observation proves that logout emits no body even when a client would trust +`Content-Length: 0`. The unnecessary protocol-proof genericity exemption is +removed. These gates preserve the approved product surface. + +The owner now anchors monotonic-to-UTC deadline projection once per entry, +preventing sampling jitter from claiming an extension. Native counts expose +only scalar logical credential/transaction/proof/CSRF and state-index occupancy, +never values or owner snapshots. Independent provider windows/clocks bind the +literal lifetime/refresh/sweep boundaries, late authority checks and release +symmetry. Actual HTTP protocol cancellation reaches the real store; unsettled +work retains its owner until completion. Provider controls use valid response +twins and literal byte/time boundaries. The reachable RP graph and shared jose +copy are checked without treating stale installation directories as authority. + +Compatible security updates select Next/eslint-config-next 15.5.28, Vite 6.4.4, +Vitest 4.1.12, and scoped transitive Zod 4.6.6 / fast-uri 3.1.9 in the lockfile. +The [Next advisory](https://github.com/advisories/GHSA-2464-fhr4-w9xw), +[Vite advisory](https://github.com/advisories/GHSA-587h-7wmj-h5pm) and +[Zod advisory](https://github.com/advisories/GHSA-mrhx-c7xc-m3w5) +support the affected/patched versions. The post-update audit has zero critical, +high or low advisories and one moderate postcss-selector-parser advisory in +Tailwind/postcss-nested's trusted build-only path. Its +[published exposure condition](https://github.com/advisories/GHSA-rj75-hqrm-r3gf) +requires synchronous attacker-controlled selectors, which this application +does not parse there. Forcing major version 7 outside both parents' version-6 +ranges is not a justified minimal repair. This is an exposure assessment, not +a zero-advisory claim or a new required audit gate. + +Focused validation passes: 437 surface controls, 157 owner/provider/protocol +controls and 21 fast request-runtime controls. Those selections deliberately +exclude existing compiled/heavy cases, not the final suite. App/native +TypeScript and selected repository lint checks pass. The rebuilt native seam +and actual pinned Next protocol proof pass on 15.5.28. In a private temporary +copy of 14 actual source/test files, the clean HTTP baseline passes 132 cases; +each of eight isolated extra-route/Origin/HEAD/cookie/method/logout-body mutations +fails its intended assertion. No shared source is mutated by those controls. + +The package is committed for its two independent review rounds. Full build, +browser/provider and complete hosted qualification remain required after the +remaining correction packages settle; focused success is not a completion claim. + + +### P02b-7 foundation first review corrections (2026-10-24) + +Two fresh independent reviewers inspect committed foundation `0fa1afe9`, one +covering the closed surface and one the owner/provider/cancellation boundaries. +Neither finds a production behavior defect. They identify two proof gaps: +post-declaration configuration mutation can evade the new source census, and a +quoted scoped pnpm key can evade the forbidden-library check. + +The configuration binding may now appear only at its declaration and approved +plugin argument; independent `Object.assign` and alias-escape counterexamples +exercise that restriction, including middleware configuration. The dependency +control checks bare/single-/double-quoted lock keys and both manifest dependency +sections, with independently planted forbidden entries. The current clean +configuration and reachable dependency graph remain passing twins. All 284 +public-boundary controls pass; the targeted graph control passes. These fixes +are committed before the fresh second review pair. + + +### ADR-0071 dependent mapping and composed controls (2026-10-24) + +After acceptance commit `e782f971`, the AccountSite caller removes 401 from its +`identity` status set. Valid 403/404 remain conservative access-boundary +refusals; all other valid API problems use `provider_refused`. No store retry, +grant-publication, lifetime or API response policy changes. + +`account-site-policy.test.ts` composes the actual SDK caller, real session store, +native request/admission runtimes and real middleware/protocol response owners. +A held valid 401 produces anonymous public/503 protected responses with no cookie +edit, second grant or idle advance. A later valid response admits the same +handle. Fresh and replacement callbacks independently return +503/`provider_refused`, clear only their transaction proof and publish no new +owner; a prior live replacement owner survives. The post-refresh 401 returns +that same status/category while revoking its owner and refusing replay. + +The caller table includes valid other-4xx/5xx problems and malformed 401/403/404 +twins. Four focused files pass 146 cases; app TypeScript and repository lint +pass. The actual API discovery-failure theory separately passes both public and +AccountSite routes, two cases with no skip. These compose the two contract +halves; they do not claim a full browser-driven real-provider outage journey. +The fresh implementation review rounds and complete final-source qualification +remain part of the correction closeout. + + +### P02b-7 foundation second review closeout (2026-10-24) + +Two fresh second-round reviewers inspect `0fa1afe9` and `9a323182`, separately +covering surface and owner/provider/cancellation correctness. The owner reviewer +approves without a finding. The surface reviewer finds that a locally replaced +plugin import can retain the checked call shape. The final correction pins the +actual runtime default import to `next-intl/plugin`, the type import to Next, +and the four top-level configuration statements. Changed-source, type-only and +wrong-binding counterexamples retain the clean current configuration twin. + +All 284 boundary controls and app TypeScript pass. The surface reviewer then +independently confirms the focused fix and approves without a remaining +actionable finding. Both required review rounds for this foundation package +are complete. These reviews do not claim that the separate in-progress policy, +UI, CI or complete real-provider/hosted packages have already qualified. + + +### P02b-7 recovery UI and proof observation implementation (2026-10-24) + +The protected scaffold withholds admitted content after failed extension but +keeps sign-out and a closed Studio/Portal reauthentication destination available. +A user-triggered retry never restores the old content while checking; only a +positive result can restore it. The earlier success announcement disappears in +the next warning window. Extension controls appear only after hydration; +non-JavaScript output states that limitation while retaining working sign-out +and sign-in forms/navigation. English/Turkish catalogues own the new copy. + +The component and actual shell tests cover both route destinations/locales, +failure/retry, no-JavaScript rendering and focus/announcement behavior. Existing +localized metadata/no-index controls remain present. All 21 focused component/ +catalogue cases pass. This is not a complete assistive-technology conformance +claim; Phase 06 retains the named real-browser/manual expansion. + +The actual native fixture and its control share one child-output wiring owner. +Real Node stdout/stderr canaries emitted before cookie discovery must be detected +later. Browser proof checks raw cookie attributes, including explicit SameSite, +instead of relying on the browser's Lax default. Privacy failure is reported at +the closed privacy stage after actual cleanup; it is not relabeled a cleanup +fault. Proof text comes from the selected committed catalogue with independent +locale checks. Existing JWT, cancellation and keyboard controls remain intact. + +All 57 focused proof-support/fixture/genericity controls pass, as do selected +lint, formatting and TypeScript. A private copied-source clean baseline passes +nine cases; four isolated child-cookie/raw-SameSite/locale/privacy mutations are +rejected. The actual native Next UI proof passes. The actual Chromium proof +passes on two hosts with Chromium `156.0.8078.48`, exit zero, empty stderr and +joined natural cleanup. `naturalRestores` is zero: no natural BFCache claim. + +The [English](../assets/bff-session/studio-en.png) and +[Turkish](../assets/bff-session/studio-tr.png) page-only images show the current +owned fake-grant/API fixture at 1280×720, without browser chrome or credentials. +They illustrate the scaffold and are not a real-provider browser journey. +The real Keycloak/API chain and full current-source qualification remain +required; fresh correction review rounds follow these implementation commits. + + +### P02b-7 CI and real-provider proof corrections (2026-10-24) + +Existing CI owners receive isolated dirty controls for outer population union, +nonzero test exit despite complete passing outputs, non-root identity, root +workflow permissions, both assignment-independence callers, aborted/cancelled/ +error completion flags and unsafe private diagnostic-file metadata. The fixed +fixture-image list is compared with its actual C# owners. Docker socket/daemon +access failures now classify as unavailable before the broad registry-auth +pattern; raw diagnostics remain private and the original command failure wins. +No retry, image, budget, topology, assignment weight or required check changes. + +Warm event controls start with two unrelated prior event types, independently +require all eight selected identity events and refuse each missing readback. +The existing 28-case per-file population is preserved. The actual API discovery +503 theory covers both public and AccountSite paths and proves 401 before +membership/controller dispatch, two passed cases with no skip. + +Real BFF controls now replay the original pre-sign-out and pre-failed-refresh +cookie headers, instead of relying on cleared browser state. Cross-host replay +requires the exact closed redirect, no cookie edit and no authenticated reader. +An opposite-tenant real persona must fail before publication and add exactly +one security audit under each defended host tenant; AccountSite Off coverage +and provider-event ownership still add no session-audit row. Provider-time HTTP +observations replace blind sleeps without replacing the actual provider clock. + +The forced finalizer-retry branch requires two calls, two finalizer admissions +and three ledger rows when no lease loss was observed; its one deliberate +conflict remains exact. The separately justified observed-loss branch retains +its existing bounds. Passed causal cases export only exact-case closed scalar +observations in measurements, never raw output or alternative completion +authority. NativeHandoff preserves its first failure boundary, task phases and +lease counters from memory, without a diagnostic query or repair. + +Offline validation passes: 72 combined shard controls, including the separately +executed 30 aggregate controls; 82 native owner controls; 28 realm-event controls. +These totals are not added twice. The integration Release build has zero +warnings/errors. The actual Keycloak/BFF/native-job journeys remain to execute +on the final correction source; compilation and offline twins do not replace +those runs or the fresh independent correction reviews. + + +### P02b-7 current carrier reconciliation (2026-10-24) + +Current mutable architecture/standards and route guidance now describe native +credential custody, exact AccountSite transport and the actual Studio/Portal +scaffolds rather than a general `app/api` proxy or already-enforced admin role. +GET/HEAD remains the default with exactly the three existing native auth POST +exceptions. Standard authority headers are adjacent; current auth catalogues, +cookie retention bounds, recovery/accessibility limits, private transport, +return destinations, bearer/source provenance and closed diagnostics are explicit. +Existing catalogue obligations name their actual control owners without a rule +count or lifecycle-status promotion. + +Phase 03's already assigned back-channel logout and Phase 11's conditional +distributed-session adapter now have concrete deliverables and completion +criteria. Phase 06 explicitly owns real-provider Chromium/Firefox/WebKit, +cold-discovery, cookie/history and manual accessibility journeys. These are +future delivery obligations, not present evidence. Architecture 25 already +states the single-process boundary and multi-process trigger and needs no edit. +Phase 02b qualifies enabled/disabled Studio behavior, native-caller attachment, +development event retention and shared-capacity/availability risks. The setup +guide no longer claims development event storage is absent. + +The earlier Step 2 event-field citation is historical and is not rewritten. +Its precise pinned +[realm-representation locator](https://github.com/keycloak/keycloak/blob/26.6.2/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java#L495-L505) +shows event enablement, expiration, listeners and selected types. The earlier +type-count correction remains in its dated follow-up; inserting an inline edit +would break the append-only record. ADR-0070 already quotes Security 11's former +credential-cookie baseline in its relationship section and explicitly replaces +it with opaque-cookie server custody. Its historical considered-options/body +is not rewritten to restate that comparison. ADR-0053's public-caller forwarding +prohibition remains the named configured-caller clause; the separate authenticated +seam is unchanged. No extra policy decision is inferred from those editorial +suggestions. + +The new browser privacy stage is also carried through the C# driver's closed +reason set and Python sanitizer, with its exact-stage control. This preserves +privacy diagnosis without exporting raw text or weakening actual cleanup. +During commit formatting, the hook's temporary stash conflicted only over two +C# switch-expression layouts. The formatted version was compared with the +saved source, all other work was restored byte-for-byte, and only this task's +verified recovery stash was removed after the successful commit. Foreign +worktrees and older stashes remain untouched. + +Documentation fragment/roadmap/index checks pass; the new privacy-stage control +passes. The correction implementation is committed before the next two fresh +independent review rounds and final real-dependency/hosted qualification. + + +### P02b-7 policy, UI and evidence first review (2026-10-24) + +Two fresh independent reviewers inspect the committed policy/UI/proof and +CI/realm/C#/carrier corrections through `4ead28c5`. They verify actual caller/ +owner/publication boundaries, accessible recovery and retained metadata cases, +child/raw-cookie/privacy observations, isolated CI negatives, diagnostic-only +success facts, real persona/audit proof and named future owners. Neither finds +a runtime defect. One mutable-carrier sentence incorrectly groups every Host +header with untrusted selectors. It now explicitly excludes unverified headers +while preserving the independently verified native host/port for callback and +logout authority, as ADR-0070 requires. No behavior or policy changes. + +The guarded full frontend passes 2,293 cases in two packages with zero skips/ +todos. Complete workspace lint, TypeScript and production build pass on the +updated dependency set. Documentation and all seven ADR parser controls pass. +Real Keycloak/API/native-job execution is in progress and is not counted as +passed here. The next fresh review pair follows this committed correction. + +Both source reviewers also distinguish implementation qualification from its +durable receipt: the final implementation source P needs complete A/B with +different cohorts. A subsequent C commit may add only the receipt/generated +index/phase Status; it must identify P explicitly and prove no executable, +plan, toolchain or fixture-lifetime change. C then receives one complete current +assignment under ADR-0063's normal-future-push rule. No A/B result is attributed +to C and no earlier sibling is substituted into an aggregate. + + +### P02b-7 real-provider clock and HTML negative correction (2026-10-24) + +The ten selected real BFF/native-job cases at `4ead28c5` produce nine passes and +one failure, with no skip. All three causal restart rows pass without an +observed lease-loss escape: the ordinary rows have one reconcile/finalizer and +two ledger rows; the deliberately forced sub-run has two finalizers and three +ledger rows. NativeHandoff and the other BFF lifecycle/browser cases pass. + +The warm BFF case disproves the new test's assumption that the provider realm +GET has a Date header. No rerun is used to dismiss it. The replacement observes +only `iat` from a fresh isolated-master issuance through the existing fixed +private TLS authentication path. Keycloak's pinned +[`issuedNow()` clock](https://github.com/keycloak/keycloak/blob/26.6.2/core/src/main/java/org/keycloak/representations/JsonWebToken.java) +provides the integer-second observation. The fixture returns one unique positive +integral DateTime-range scalar, never the credential or identity authority. +The existing five-second request, 256 KiB response and 15-second combined wait +bounds remain; polling is at most once per second, without transport retry or +host-clock fallback. Extra master sessions/credentials are confined to the +owned fixture and its actual destruction; tenant/BFF SSO is untouched. + +The next targeted run passes that clock boundary and reaches a second new test +assumption: the real browser asks for HTML, so its masked 400 has no JSON `code`. +The test now requires the actual HTML projection, no admitted render, no-store, +no new session cookie, no authenticated reader, anonymous replay and the exact +one-row defended-tenant security audit. The JSON taxonomy remains independently +covered by the composed caller/protocol controls. Runtime policy is unchanged. + +The corrected actual warm case passes both hosts in 75.210 seconds, with seven +authenticated API witnesses per host, real refresh/maximum/logout/error/events +and a zero-write rerun. Joined cleanup takes 0.502 seconds and independently +confirms both containers and the network absent. These targeted results are +not relabeled a new complete ten-case run. The failed receipts remain retained. +The first-round CI/provider reviewer independently confirms this focused source +correction without a remaining finding. Fresh round 2 and full hosted +qualification remain required. + + +### P02b-7 second review and final SDK proof census (2026-10-24) + +Two fresh second-round reviewers inspect the policy/UI/proof and CI/provider/ +governance corrections through `9fb1cf3e`. Both approve without a verified +actionable finding. Their source reviews cover the independent publication and +recovery rules, observed child/cookie/privacy controls, real-provider clock/HTML +corrections, CI ownership/diagnostic separation and future-phase carriers. They +do not independently claim the execution receipts or a full OIDC browser journey. +Both review rounds for those correction packages are complete. + +The final finding-to-source census separately confirms one still-open SDK proof +gap: its negative wrapper assertion compares lowercase wrapper names with an +operation identifier. `authenticated-server.test.ts` now checks the actual +operation map, wrapper map and public SDK capability. Twenty-one additional +cases isolate the authenticated wrapper's cancellation, body/error taxonomy, +valid/invalid Problem Details, Retry-After and unexpected-status boundaries. +Existing shared public response-policy controls are not duplicated. The ten +original authenticated cases remain; all 103 SDK cases pass, alongside lint and +TypeScript. No production SDK or API behavior changes. + +The earlier full frontend result remains 2,293 at `4ead28c5`; the new SDK-only +population is recorded separately, not relabeled as a new full frontend run. +All 2,616 unit cases and five corpus controls pass with no skip. The SDK-only +correction is committed for its own two fresh focused review rounds before the +final implementation source is frozen for complete hosted qualification. + + +### P02b-7 SDK first review and counterfactual qualification (2026-10-24) + +Two fresh focused first-round reviewers inspect `4b7f01a3` against the actual +authenticated/public SDK and shared response policy. The mapping reviewer +approves without a finding. The cancellation reviewer raises a possible signal +identity gap in the call matcher. A private four-state control does not reproduce +that gap on pinned Vitest 4.1.12: the original signal passes with/without an extra +identity assertion; a fresh signal fails the existing call matcher even when +that assertion is removed. A composed signal is rejected too. No redundant +assertion or production change is retained for that unverified finding. + +A private actual-source baseline passes all 31 authenticated cases. Five isolated +signal/post-header-abort/post-JSON-abort/SyntaxError/Retry-After mutations each +fail the intended behavioral case, not setup or TypeScript. Only temporary +copies are mutated and removed. The unchanged committed SDK passes all 103 cases, +lint and TypeScript. The complete guarded workspace at `4b7f01a3` passes 2,314 +frontend cases in two packages, with zero skips/todos. This replaces neither +the real-provider proof nor the required hosted current-source checks. + +The next fresh focused pair reviews the SDK-only correction. All earlier general +correction/foundation review rounds remain complete; their conclusions are not +used to skip this separately identified proof population. + + +### P02b-7 final correction source handoff (2026-10-24) + +Both fresh focused SDK second-round reviewers approve `4b7f01a3` without a +verified finding. They independently trace cancellation/settlement, the actual +ownership maps and public capability, sanitized errors/retry delegation, +invalid-response gates and preservation of the original cases. These are source +reviews, not independent execution claims. Only completed reviews count; the +replacement for an unavailable reviewer completes its own inspection. + +The foundation, policy/UI/evidence/carrier and focused SDK correction packages +have completed their required two independent review rounds and verified fixes. +The implementation is frozen for complete hosted qualification. Local evidence +is deliberately separated by source and scope: + +- Full frontend: 2,314 cases in two packages at `4b7f01a3`, zero skips/todos; + complete workspace lint/typecheck/production build passed before the later + SDK-test-only addition; its full 103-case suite/lint/typecheck also pass. +- Backend: all 2,616 unit and five corpus cases pass, with no skip. Both actual + public/AccountSite discovery-failure cases pass. The real BFF/native selection + initially has nine passes and the documented warm-proof failure; the corrected + warm case then passes both hosts in 75.210 seconds with independent cleanup. +- CI controls: 82 native owner, 72 combined shard and its included 30 aggregate + cases pass; 28 warm-event controls pass. These overlapping totals are not added. +- Actual controlled-browser/native UI, source-copy counterfactuals and current + fixture images are recorded above with their scope limits. No full real-browser + OIDC, natural BFCache, production topology or instantaneous revocation claim + is inferred. + +The next complete hosted A/B pair must identify this final source and its tested +merge tree, preserve originals before the second attempt replaces artifacts, +and independently verify exact case/selector ownership, changed co-membership, +toolchains, job ceilings and natural cleanup. Durable final receipts follow in +the repository under the reviewed documentation-only closeout rule. Current-head +checks and maintainer review still gate PR #36; no merge is performed here. + + +### P02b-7 recurrence qualification failure observation (2026-10-24) + +The first complete correction-source run, +[40096245413 attempt 1](https://github.com/HodeTech/LearnStack/actions/runs/40096245413), +tests head `e42cc551a73697f49d22eb415a82d66a172bf0bd` and merge checkout +`a8cbe0ac3c2ffd1409dcc458b9be7fcc866d7935`. Twenty-five jobs pass; ordinary-0 +and its dependent integration aggregate fail. This is not a qualified A result. + +The failed case is the organization variant of +`Actual_runtime_fans_out_to_both_scopes_and_duplicate_native_ticks_create_no_extra_children`. +Its first wait for two completed child jobs times out after 31.406 seconds; +the tenant-wide variant passes in 5.53 seconds. The owner records +`workloadError=tests_failed`, then `owned_completion_missing` with forced cleanup, +so this shard supplies no natural ownership/cleanup proof. The retained closed +location and exception category identify the wait, not its cause. No claim of a +transient failure, infrastructure cause or corrected runtime mechanism follows. + +Independent source inspection finds a separate database per case, SharedSchema +serialization and an AsyncLocal tenant accessor; it finds no verified production +defect or conflicting BFF global-filter writer. A recipient's retained lease can +outlast this test wait, but that remains a possible mechanism, not a diagnosis. +The unchanged targeted two-case selection passes locally, with no skips. After +adding the observation below it passes again; neither local pass explains CI. + +The correction records the last count from the existing SQL poll and finite +runtime/recurrence/bridge/execution counters before stop changes the state. +It adds no database reads, polling, retry, production change, timeout increase, +plan change or alternative completion authority. Only the exact two cases may +export these bounded observations through the existing failure-location channel; +private text, foreign cases/assemblies, duplicate markers, unknown states and +invalid counts are rejected. Diagnostic failure cannot replace the test fault. +All 83 native-owner controls pass; the new control also sends an actual failed +protocol result through the reader and confirms its outcome remains Failed. + +The source is reopened only for this diagnostic gap. Both fresh review rounds +and a new complete final-source hosted pair remain required. The failed run is +retained as evidence rather than overwritten or relabelled as a passing result. + +### P02b-7 recurrence observation review closeout (2026-10-24) + +Both fresh first-round source reviewers approve `d380aa67`. The second fresh +round independently confirms the unchanged polling, timeout, cleanup and +finite export. One reviewer identifies a control gap: iterating the production +allowlist lets removal of a supported row also remove its test. The correction +defines the two expected case names independently and asserts exact set equality +before exercising both. Its focused control passes. + +The original failed source's 20 producer packages, aggregates that exist, +frontend log, demo evidence and tested-source metadata are retained before any +rerun. An independent inventory finds all 5,931 distinct .NET cases: 5,930 Passed +and one Failed. The integration aggregate is absent because its producer fails; +this is not an artifact-path diagnosis. Successful causal-restart observations +are also present for all three cases, including the actual forced subrun's +1 reconciliation / 2 finalization attempts / 3 ledger rows. These observations +do not qualify the failed complete assignment or explain its recurrence timeout. + +The correction changes no production behavior, fixture lifetime, plan, test +population or toolchain. A new final-source pair remains pending. Subsequent +passing observations may qualify that source; they must not be described as a +causal repair of the retained unexplained failure. + +### P02b-7 browser launch qualification observation (2026-10-24) + +Focused second-round confirmation approves the independent two-case recurrence +control in `43b12c70`. Its complete hosted run, +[40098005110 attempt 1](https://github.com/HodeTech/LearnStack/actions/runs/40098005110), +tests head `43b12c70f5124b217f3003596fa7438100bf5817`, tree +`fb1d69bdc3482da36ea7f5a2073e714e27fa3e12` and merge checkout +`7b1db5122d96960dc93625b172897c499ce696ff`. Twenty-five jobs pass; ordinary-9 +and its integration aggregate fail. All 20 original producer packages and the +other available evidence are retained. This is not a qualified A result. + +The failed case is +`Native_https_browser_stores_host_cookies_and_revalidates_logout_history_with_a_live_counter_control`: +22.357 seconds, positive journey, `browser-launch_cleanup_failed`, with +`workloadError=tests_failed` followed by `owned_completion_missing` and forced +cleanup. The previous run's recurrence cases now pass; the failed browser case +passed on that previous source. Neither fact proves a transient cause or repair. +The observed runner image changes from `20261012.334.1` to `20261023.334.3`; +that observation does not establish causality. + +Independent source inspection verifies a diagnostic loss: executable lookup, +spawn, DevTools readiness, CDP connection and context creation share one stage; +the outer catch discards the existing readiness helper's branded finite reason. +The correction separates those stages and maps only actual branded readiness +failures, including exited child, inactive owner, unreadable port and missing, +empty or malformed readiness file at the existing deadline. Unbranded/forged +errors retain the generic closed stage. C# and the sanitized CI reader accept +the exact finite set and keep cleanup failure separate. No raw browser output, +address, path or exception message is exported. + +No browser flag, HOME/XDG policy, sandbox, executable selection, timeout, retry, +cleanup authority or production behavior changes. The lost detail is repaired; +the hosted launch failure's cause remains unproven. Both fresh review rounds +and a new final-source pair remain required. + +The 62 focused frontend controls and web typecheck/lint pass. The actual native +browser case passes locally with zero skips: stock Chromium `156.0.8078.48`, +two hosts, positive cookie/isolation/keyboard/extension/logout/back proof and +the live restoration counter, 26.764 seconds including joined cleanup. Natural +persisted restores are zero; the counter reaches the explicit persisted event, +not a claimed natural BFCache restoration. This is controlled-grant/API browser +proof, not the separate real-provider OIDC/application-role chain. + +### P02b-7 browser observation first review (2026-10-24) + +Both independent first-round reviewers find the same Minor observation error in +`dd57d534`: the readiness helper's not-live predicate can mean a failed spawn as +well as an exited child, so `browser-exited` overstates the fact. The correction +renames it `browser-not-live` across the producer, C# consumer, sanitized CI reader +and their independent controls. The observation records no stronger exit fact. +Both focused CI-reader controls and all ten proof-support cases pass after the +rename. No other verified finding remains from this round. + +The full frontend at `dd57d534` passes 2,315 cases in two packages, zero skips or +todos: 2,212 web and 103 SDK. All 83 native-owner controls pass. The later rename +changes only the closed observation label and its consumers/controls; it adds no +case or runtime authority. The actual local Chromium pass and the hosted failure +retain the distinct scopes recorded above. Fresh second-round reviews and +complete hosted qualification remain pending. + +### P02b-7 same-source qualification and causal fixture scheduling (2026-10-24) + +Both fresh browser-observation second-round reviewers approve +`43b12c70..aeeea13b` without a verified finding. Their source-only reviews confirm +faithful branded observations, finite consumer parity and unchanged authority. + +Run [40100030047](https://github.com/HodeTech/LearnStack/actions/runs/40100030047) +tests head `aeeea13b5fd5dc57043dc399db095364754443e3`, tree +`b21d0b7d73415f67f6f774a2a2f839bbb03ddb70` and merge checkout +`0bc732f4ec74303f00bd807033678e1a8905a8d4` in both complete attempts: + +- A/attempt 1 passes all 27 jobs and seven required contexts. Original evidence + is retained before B. Independent recomputation verifies 20 owners and all + 5,931 distinct Passed .NET cases (3,352 non-Docker / 2,579 Docker), 2,315 + frontend cases, natural ownership/cleanup, job ceilings and the tested tree. + Maximum producer wall time is 917 seconds; backend/integration aggregates + take 22/26 seconds. Six BFF cases, both recurrence rows and all three causal + success observations pass; the forced subrun records 1 reconciliation, + 2 finalizations and 3 ledger rows without observed lease loss. Public demo + passes nine navigations / 27 reports with natural cleanup in 11.343 seconds. +- B/attempt 2 passes 25 jobs; ordinary-9 and its integration aggregate fail. + All 20 original packages and other available evidence are retained. The union + contains 5,930 Passed and one Failed case. Consequently this is **not a + qualified A/B pair**, and A does not waive the failed B result. + +The failed case is the `(independentFailure: False, lieOnlyOnReconcile: False)` +causal-restart row: cancellation after 42.004 seconds at `provider_accept`, one +renewal-refusal first cause, zero elapsed-first causes and no remaining active +execution at observation. There is no database row observation there. The +historical `calls=0` field counts the optional finalizer-retry handler, not +provider calls; `forced=0` in the original-host phase does not identify which +helper invocation failed. These fields establish neither zero provider execution +nor the actual SQL refusal branch. The closed diagnostic now uses `unavailable` +when that optional call-count probe does not exist. + +Source inspection and the existing independent held-Prepare proof establish a +legal counterexample: a short Prepare/Arm row lock can exclude an active identity +from the `SKIP LOCKED` renewal batch; acknowledged empty renewal then closes its +permission. The held-Prepare proof separately witnesses that branch and its +committed Prepare / zero-provider result. It does not recover the hosted trace. +Production renewal policy and SQL remain unchanged. + +The causal-restart fixture instead isolates its intended **post-acceptance** +runtime stop using the already-qualified `NativeSettlementTimeProvider`. Only +the original host uses its distinctive validated 1125 ms pacing. Actual native +lease and deadline timers are armed before publication; the first real pacing +callback stays held through actual provider acceptance and the existing live +Run/token/provider assertions. Explicit runtime stop precedes release. The +restarted host retains its one-second cadence and system clock. + +All exits release holds, attempt actual runtime and timer joins under the same +existing 40-second cleanup token, and preserve the earlier failure. Cleanup is +complete only after required joins; unconfirmed runtime completion retains the +factory/barriers. Lease duration, startup/deadline bounds, provider facts, +quiescence, reservation retention, fresh-host reconciliation, immutable ledger, +audit assertions, dirty-adapter counter and held-Prepare counter remain intact. +This excludes an unrelated lost precondition; it claims no production contention +tolerance or historical causal repair and requires no new policy decision. + +Five real integration cases pass in 2 minutes 17 seconds, zero skips: all three +causal rows and both Outcome-ACK cases, including the existing explicit +held-Prepare counter. Forced/unforced success observations retain exact +1/2/3 and 1/1/2 reconciliation/finalization/ledger counts, with no observed lease +loss. All 83 native-owner controls pass; the integration project builds with +zero warnings/errors after the diagnostic refinement. Fresh reviews and a new +complete final-source A/B pair remain pending. + +### P02b-7 final correction qualification (2026-10-24) + +Both fresh first-round and both fresh second-round source reviewers approve +`aeeea13b..ae2f7f7c` without a verified finding. They independently trace actual +timer ownership, post-acceptance stop causality, unchanged provider/durable-state +assertions, shared-budget cleanup and finite diagnostic semantics. These are +source reviews; the execution receipts below are separate. + +The final implementation source is +`ae2f7f7cc65995957109ba8e738052ac6a2ddd6a`, tree +`e87a8d080a918853b13a6adaa5d31d3766f2a360`. Complete hosted run +[40104248664](https://github.com/HodeTech/LearnStack/actions/runs/40104248664) +qualifies attempt 1/A and full attempt 2/B against the identical merge checkout +`2b26a144b8022f3b0366e831078a3349138cda74`, with unchanged base +`935d8da66878286d6a1695f3d41d985ebce4e791`. Original A packages are retained +before rerunning all jobs for B; B originals are retained independently. No +failed-only or mixed-attempt union supplies this qualification. + +| Observation | A / attempt 1 | B / attempt 2 | +|---|---|---| +| Successful jobs / required contexts | 27 / 7 | 27 / 7 | +| Natural producer ownership/cleanup | 20 / 20 | 20 / 20 | +| Distinct Passed .NET cases | 5,931 | 5,931 | +| Non-Docker / Docker cases | 3,352 / 2,579 | 3,352 / 2,579 | +| Ordinary / native Docker cases | 2,548 / 31 | 2,548 / 31 | +| Frontend cases, zero skips/todos | 2,315 | 2,315 | +| BFF / recurrence / causal cases | 6 / 2 / 3 | 6 / 2 / 3 | +| Maximum producer whole-job seconds | 959 | 953 | +| Backend / integration aggregate seconds | 26 / 25 | 22 / 36 | +| Demo navigations / Lighthouse reports | 9 / 27 | 9 / 27 | +| Natural demo cleanup milliseconds | 10,779 | 10,666 | + +Independent verification recomputes every assignment from actual discovery and +the committed plan, compares exact selector/method ownership, validates every +retained result artifact digest and requires complete Passed unions and actual +natural cleanup. There are no skipped .NET cases, forced successful cleanups or +unowned producers. Execution and aggregate ceilings remain 1,200/300 seconds. +Actual co-membership changes, not merely bin labels: Architecture moves +220 methods / 371 cases; ordinary Docker moves 1,074 / 2,256; SharedSchema +specifically moves 901 / 1,874. The 77-class SharedSchema set is independently +re-derived from current source before comparing group membership. + +The same captured .NET toolchain appears in both variants: SDK `10.0.402`, +console `18.7.0-release-26505-111` and identical console/dependency/adapter hashes. +Both demo receipts capture Node `v22.23.1`, Playwright `1.64.0`, Chromium revision +`1248`, Lighthouse `13.5.0` and identical configuration digest. Each frontend +receipt independently confirms 2,212 web and 103 SDK cases in 71 files. + +All three successful causal observations are retained in each variant. The +unforced paths record 1 reconciliation / 1 finalization / 2 ledger rows; the +deliberately forced subrun records 1 / 2 / 3. Neither reports elapsed or renewal +lease loss. These observations supplement the real results and never replace +completion authority. The separate held-Prepare counter and dirty-adapter +counter remain required and passing. + +- Manifest digest: + `0386c8b99e2a94fda613670ec0d347aaf3bd04b6ff8b4f42c193701a682c3a70`. +- Plan digest: + `69bbbf02314096983765b4fda1ded636c3698903d4b714393b4280c5e504016b`. +- A assignment digest: + `2efb41bb3b00ec0f994ad7c247594b33abf22a3e63c2b6d3a6c610be2a4917d1`. +- B assignment digest: + `71c91e7aa02ff4f60e21e062ac8d101052bd4bcc30bd70d671182932a9a7f1ce`. +- Independent complete-pair audit SHA-256: + `ae6e8634701e854b58a74864f92c56f5a9841435980edbcdddc8123b5293da43`. +- A/B supplementary audit SHA-256: + `86dfa22d6df37fb6404a8c0c5bf0b86059d50fbc693f3229be77c004fb4a4928` / + `fb816df8ee6675ab7e5851b5805fcfc0891d2dc8db6231db5d15d0b8f3bb0cb5`. + +The failed sources above remain failed evidence. Passing final assignments do +not recover their missing historical causes, prove every possible ordering, or +change production renewal policy. The browser proof remains the separately +scoped controlled-grant/API journey; full real-browser OIDC and the browser +matrix remain Phase 06's named deliverable. + +This closeout changes only this delivery record/generated index and the phase +packet Status. It changes no executable, plan, fixture lifetime or toolchain. +Under [ADR-0063](../decisions/0063-complete-bounded-ci-test-shards.md), its normal +current-head push still requires one complete assignment before PR readiness. +This A/B receipt belongs to the implementation source above, not the later +documentation-only commit. [PR #36](https://github.com/HodeTech/LearnStack/pull/36) +remains the maintainer review/merge gate; no merge is performed here. + + +### P02b-7 review correction contracts and proof controls (2026-10-24) + +This correction starts from `a2869504`. Earlier A/B qualification stays bound to +`ae2f7f7c`; its documentation-only follow-up passed separately at `a2869504`. +Neither receipt qualifies the changed implementation and fixture source here. +PR #36 returns to draft until this source completes both fresh review rounds and +its required qualification. No merge is performed. + +**ADR-0071 acceptance evidence.** The maintainer's instruction in this task is: + +> Aşağıdaki review çıktılarını dikkatli bir şekilde inceleyip gerekli aksiyonları al ve sonrasında ADR yi onaylandıya çek ve beklemeden implementasyonu yap lütfen + +The conditional authorization follows the review of the Proposed decision and +permits acceptance and implementation after closing verified findings. +Acceptance is `e782f971073213b1029221d2c2f4544af8c45ec7` (2026-10-24, +14:31:18 +03:00); runtime classification is +`7133f8ad9b90988c4400d62cc4a95d6f7c7b5b9e` (14:44:18 +03:00). +ADR-0071's own dated amendment links this receipt. Its Accepted metadata and +acceptance-time pending wording are preserved. + +**Verified corrections.** + +- Restore the single phase Status source: remove packet histories from root + pointers, correct the stale pending paragraph and join ADR-0071 to its index + table. G13 retains the original leaning recovered from `935d8da6`; its Status + links the normative revised package and acceptance. Architecture 14 labels its + mixed current/target tree, and web setup names real prerequisites, matching API + issuer and disable/restart behavior. Completion Criteria now name actual + settlement, admission, uncertain-401 and distinct browser/provider proof scopes. +- Native auth/protected admission checks the fixed HTTPS authority before + signing it. Unbound ports and trailing-dot BFF origins refuse; anonymous public + normalization remains unchanged. Closed status controls distinguish malformed + input from upstream/server unavailability without changing custody authority. +- Recovery retains focus after a successful retry, announces each failed retry + and keeps its busy button focusable while the existing in-flight guard prevents + duplicate work. First client markup no longer depends on a changed display + clock. Three regressions fail before the fix and pass afterward. No client clock + becomes server authority. Account-unavailable documents offer only closed + checked-retry/reauthentication links and public navigation, without a retained + authenticated projection or a session CSRF value. Sign-in sits outside the + catalogue-labelled navigation landmark. +- Provider wire assertions run outside production error conversion. Independent + controls bind the verifier-derived S256 challenge, nonce/state, exact scope, + confidential exchange and logout parameters. Session CSRF/logout capabilities, + publication clocks and start/callback cancellation have isolated controls. + A separately longer provider window exposes idle touches for every ordinary + availability category. Existing custody/index and shared-waiter controls remain. +- Raw socket observers cover implicit and explicit write/end logout paths and + an unprotected counter; same-path foreign issuer origins are independent + negatives. Qualified ALS twins isolate forged provenance. HMR negatives name + the actual closed stage; valid-CSRF POST controls isolate the return-path set. + Hook/storage census adds not-found/instrumentation and IndexedDB/Cookie Store + with dirty and locally shadowed clean counterparts. The 16 KiB client boundary + uses otherwise-valid JSON on both sides of the byte limit. +- Each standalone native proof builds its current SDK as well as server sources; + held-build and ready share the 90-second compilation budget. Excluded API routes + receive a well-formed signed token on a known host, with a same-token admitted + public control proving that discovery would otherwise run. No .NET case is + added or removed by these refinements. +- Realm controls isolate desired-export flags/retention/types, enablement + readback, byte/type bounds and the final zero-write replan. Actual provider + selection must equal prior types union the required types, rather than merely + contain that union. The Accepted custom-retention refusal is unchanged. +- Diagnostic fixed-image pulls drain both anonymous pipes concurrently under + their existing deadline, enforcing each byte bound while the child is live. + Overflow/cancellation kills and actually reaps the direct child; all pipes and + selector resources close. The non-diagnostic command path is unchanged. Local + expiry and successful signout emit only their existing closed reasons once per + transition; the launcher labels these lifecycle observations accurately. +- The recurrence case shares validated 15-second repair grace between DI + execution and actual control/bridge stores, retaining its 30-second completion + wait and durable-state assertions. Production defaults are unchanged. The real + provider clamp uses case-owned 22-second idle / 35-second maximum with the same + start+10/+20-second refresh schedule; strict growth and the maximum witness + remain. No historical failure cause is inferred from these refinements. + +**Verified dispositions.** ADR-0071 was explicitly authorized; it is not reopened +for an absent approval. ADR-0035 already names the app-local port and all gated +components. Accepted ADRs' Registered/pending descriptions are historical; +current catalogue status is separate. The causal restart case already proves an +uncancelled live execution before explicit stop and durable `runtime_token` +cancellation afterward. Normalized event-policy refusal already has a fixed +remediation notice; indefinite custom history is not silently widened or erased. + +Sequential explicit extensions consume a refresh under the Accepted contract; +concurrent owners remain single-flight. Expired/unknown-owner signout still +refuses without a live CSRF witness and performs no provider action. The safe +projection remains local state and cannot authorize sensitive/business content. +A changed clock authority, JWKS cache reuse across operation signals, trusted +proxy/listener resource policy and full browser error/matrix expansion are not +smuggled into these fixes: the existing Phase 06/11 owners and explicit activation +criteria govern them. The current fixed-issuer TLS transport accepts no +request-selected proxy, endpoint or TLS bypass. Development credential fixtures +remain the previously disclosed loopback capability; no production secret +rotation or realm-role policy is introduced. + +The inherited review records are contemporaneous summaries, not stored full +agent transcripts. This correction's new review receipts name their exact source +and scope below. Local copies retain the original earlier A producer packages; +the GitHub artifact listing at this correction exposes B's 20 packages but no A +packages. The prior A audit therefore relies on that retained original set and +its recorded hashes, not a claim of current A downloads from the GitHub API. + +**Local checks before fresh review.** Frontend lint/typecheck/build pass; +2,376 cases pass in both packages with zero skips/todos. Realm Node controls pass +395 cases; native-owner controls pass 87, and result/aggregate controls pass 30. +Integration Release build has zero warnings/errors. Fifteen real integration +cases pass with no skips: all six native BFF cases (including held build, +controlled browser and real warm provider), two event-policy cases, two actual +recurrence rows and five authentication/discovery exclusion cases. Targeted +private-copy counterfactuals kill 13 provider/store regressions and five realm +regressions; their clean controls pass. These are selected regressions, not a +mutation-coverage percentage. Full current-source hosted A/B remains pending. + + + +### P02b-7 correction review round 1 — ownership and proof (2026-10-24) + +Exact source: `a2869504..cac786dd`. Two fresh independent agents review the +committed delta read-only, with separate ownership/runtime and proof/governance +assignments. Neither edits source or runs services. + +- `/root/pr36_third_r1_ownership`: Approve; no verified actionable finding. + Traces canonical ingress, failure/recovery, hydration/focus, lifecycle + diagnostics, bounded child ownership, SDK acquisition, actual recurrence + configuration and the provider clamp. Independently runs five diagnostic-owner + controls in 2.195 seconds, all passing. Frontend/browser/provider integration + outcomes are inspected as source, not rerun or independently attested. +- `/root/pr36_third_r1_proof_docs`: Approve; no verified Blocker/Major/Minor. + Traces wire/capability/cancellation/clock/return-path negatives and twins, + storage/hook census, realm guards, signed-token exclusion and unchanged durable + assertions. Confirms append-only ADR amendments, explicit acceptance sequence + and phase/package/catalogue agreement. Independently passes `make lint-docs` + (2,252 fragments, structure and both indexes) and `git diff --check`. + It does not rerun runtime suites or attest hosted qualification. + +These are scoped reviewer conclusions, recorded separately from execution +receipts. There is no first-round fix commit because neither produces a verified +finding. The next independent round reviews the cumulative correction and this +receipt; current-source hosted A/B remains pending. + + + +### P02b-7 correction review round 2 — independent recheck (2026-10-24) + +Exact cumulative source: `a2869504..86598f70` (implementation `cac786dd`, +first-round receipt `86598f70`). Two different fresh agents inspect the complete +correction read-only. Neither relies on the first verdict or modifies source. + +- `/root/pr36_third_r2_runtime`: Approve; no verified Blocker/Major/Minor. + Independently traces native authority, HTTP classification/recovery, React + clock/focus/retry guards, lifecycle observations, SDK child/group cleanup, + shared recurrence options and the provider maximum witness. Five isolated + diagnostic-owner controls pass in 2.040 seconds; diff check passes. It does + not rerun frontend/browser/provider/.NET services or hosted assignments. +- `/root/pr36_third_r2_proof_corpus`: Approve; no verified Blocker/Major/Minor. + Independently checks contract assertions outside conversion, capability/ + cancellation/clock/idle witnesses, wire/provenance/HMR/return-path twins, + hook/storage/boundary controls, realm desired/readback/replan guards and exact + provider union. Checks signed-token exclusion's positive control, real clamp + growth and shared repair configuration. Verifies append-only ADRs, explicit + acceptance, original G13 leaning and normative links, Status ownership and + source-bound evidence limits. Core/events Node controls pass 114/114, zero + skips/todos; `make lint-docs` passes 2,253 fragments, structure and both indexes; + cumulative diff check passes. Larger runtime/hosted totals are not its own + execution or attestation. + +No verified finding requires a second-round fix. Implementation and both review +rounds are locally complete. This final source, including the dated receipts and +pending Status, now requires complete same-source hosted A/B qualification; +older passing assignments and local selected checks cannot supply it. + + + +### P02b-7 hosted observer argument failure and deterministic control (2026-10-24) + +[Run 40118767190](https://github.com/HodeTech/LearnStack/actions/runs/40118767190), +source `806d6ba9`, is not a passing assignment. Frontend job `120945546090` fails +its owned-child stderr observer: the child returns exit 9, while the control +requires 0. Web completes 2,272 passing cases and one failure; the workspace guard +refuses before running SDK tests. Other job outcomes cannot supply frontend +success or a complete A/B qualification. + +The observer passes a valid opaque cookie as a positional argument to Node's +`-e` child. That alphabet permits an initial `-`; the command omits the option +terminator. Forcing that legal prefix deterministically produces exit 9 in both +stdout and stderr controls before the intended late-cookie scanner assertion. +Adding `--` preserves the payload as `process.argv[1]`; both controls then reach +and distinguish their clean/leaking observations. The ten-case owner/support +file passes after the fix. The original random CI value was intentionally not +retained, so this records the reproduced matching fault mechanism without +claiming recovery of its private input. + +Only the test's argument framing and deterministic boundary canary change. +Shipping credential handling, child ownership, scanner behavior, deadlines, +case population and the passing runtime/realm controls are unchanged. Fresh +source reviews and full qualification follow this correction; a failed-only +rerun does not substitute for them. + +**Additional scope checks.** Native proof cancellation can report no diagnostic +line, or an unobserved input-stage deadline, when no later IPC stage is received; +it remains a failed proof with actual cleanup requirements. The current harness +qualifies cleanup and closed observations, not complete stage progress on every +stall. The shipping launcher correctly restores TLS verification and passes its +BFF opt-in into POST admission; primitive disabled-mode controls and enabled +native flows do not independently kill every launcher-wiring mutation. These +are disclosed witness limits, not an observed TLS bypass or public POST grant. +The controlled-browser fake provider deliberately does not prove full OIDC; +exact wire/store controls and the separate real-provider chain own that proof. +Historical timing ratios alone do not establish a timeout defect or justify +raising ceilings; the full corrected local frontend run completes its web +population in 47.18 seconds; the failed hosted web population takes 151.30 +seconds. Its observed failure is exit 9, not a timeout. Neither measurement +promises future timing fit. + + + +### P02b-7 observer correction review round 1 (2026-10-24) + +Exact source: `806d6ba9..3dedda8d`. Two new read-only agents independently +approve the narrow argument/canary correction without a verified finding. + +- `/root/observer_option_r1_contract` checks the actual owned child/scanner path, + independently reproduces Node 22.23.1 exit 9 without the terminator and exact + payload/exit 0 with it on both streams, and runs the ten-case file successfully. + Confirms production randomness, ownership, privacy, deadlines and population + are unchanged. It does not rerun hosted/build/service proofs. +- `/root/observer_option_r1_evidence` independently reproduces the same argv + distinction, passes all ten file cases and documentation gates (2,255 fragments, + structure and indexes), and confirms pending source-bound qualification. + GitHub job metadata independently confirms the frontend failure during Vitest; + its log access does not attest the recorded counts/timing. The original CI + input remains unretained. No services or edits are performed. + +The root's complete frontend guard also passes 2,376 cases in two packages with +zero skips/todos after the deterministic fix. Second-round review and current +source qualification remain pending; neither agent's scope is promoted into a +new full-runtime or hosted execution claim. + + + +### P02b-7 observer correction review round 2 and source freeze (2026-10-24) + +Exact cumulative source: `806d6ba9..5748dbbc` (argument correction `3dedda8d`, +first-round receipt `5748dbbc`). Two different fresh agents approve read-only, +without a verified finding. + +- `/root/observer_option_r2_protocol` independently verifies Node 22.23.1's + exit-9/terminator distinction and exact 43-byte positional payload on both + streams; all ten support cases pass. In-memory controls fail when the privacy + guard is suppressed, scanners are disconnected or the terminator is removed. + Late cookie registration, clean/leaking twins, ownership, deadlines and case + population are unchanged. It runs no full workspace, build, service or hosted + assignment. +- `/root/observer_option_r2_receipt` independently verifies the same argv and + actual owner/scanner path, passes all ten cases, diff check and docs gates + (2,256 fragments, structure and indexes). It does not attest hosted logs, + full-suite results or recovery of the original private CI cookie. + +The root's post-fix full frontend guard passes 2,376 cases in two packages, +zero skips/todos. Implementation and both fresh review rounds for the runtime +correction and its hosted observer follow-up are complete. The frozen source now +requires full current-source A/B; no earlier failure, older passing source or +selected check substitutes for either complete assignment. + + + +### P02b-7 review correction qualification (2026-10-24) + +The runtime/proof correction `cac786dd` and observer argument correction +`3dedda8d` each complete both fresh independent review rounds recorded above. +The final qualified implementation/review source is +`0bde36f00335141a3ec1e21d6c9ca2f30fae8e00`, tree +`3a0923b8664c64f91ea475db3d9fd18c843e854c`. +[Run 40120289765](https://github.com/HodeTech/LearnStack/actions/runs/40120289765) +passes complete attempts 1/A and 2/B, both on the identical merge checkout +`c0d08887240a94030879541679dc3fbc6a889def` and unchanged base +`935d8da66878286d6a1695f3d41d985ebce4e791`. Original A packages, job metadata and +logs are retained before rerunning all jobs for B; B originals are retained +separately. No mixed or failed-only union supplies this result. + +| Observation | A / attempt 1 | B / attempt 2 | +|---|---|---| +| Successful jobs / required contexts | 27 / 7 | 27 / 7 | +| Natural producer ownership/cleanup | 20 / 20 | 20 / 20 | +| Distinct Passed .NET cases | 5,931 | 5,931 | +| Non-Docker / Docker | 3,352 / 2,579 | 3,352 / 2,579 | +| Ordinary / native Docker | 2,548 / 31 | 2,548 / 31 | +| Frontend cases, zero skips/todos | 2,376 | 2,376 | +| Web / SDK cases in 71 files | 2,273 / 103 | 2,273 / 103 | +| BFF / recurrence / causal cases | 6 / 2 / 3 | 6 / 2 / 3 | +| Maximum producer whole-job seconds | 997 | 900 | +| Backend / integration aggregate seconds | 15 / 34 | 14 / 35 | +| Demo navigations / Lighthouse reports | 9 / 27 | 9 / 27 | +| Natural demo cleanup milliseconds | 12,053 | 10,587 | + +Independent verification recomputes actual selection and assignment from full +current discovery and the committed plan, requires exact method/case ownership, +checks every retained artifact digest and complete Passed unions, then verifies +actual natural-cleanup evidence. Execution/aggregate ceilings stay 1,200/300 +seconds. No missing, skipped, censored or forced-cleanup result becomes success. + +Actual selector co-membership changes: Architecture moves 220 methods / 371 +cases; ordinary moves 1,074 / 2,256. SharedSchema specifically moves 901 / 1,874, +using the independently re-derived 77-class set. This is a different process +composition, not a renumbering or a claim to prove every possible test order. +The controlled-browser case itself moves from ordinary-9 to ordinary-11. + +Both variants capture the same .NET SDK `10.0.402`, console +`18.7.0-release-26505-111` and console/dependency/adapter hashes. Demo captures +Node `v22.23.1`, Playwright `1.64.0`, Chromium revision `1248`, Lighthouse +`13.5.0` and identical configuration digest. The controlled-browser producer's +separate stock-browser preflight reports `155.0.8059.39` in each variant; this +observation is separate from the pinned managed demo and a full browser matrix. + +The three causal observations pass in each assignment. Unforced runs retain +1 reconciliation / 1 finalization / 2 ledger rows; the forced subrun retains +1 / 2 / 3, with no observed elapsed/renewal lease loss. These observations supplement the actual Passed results; held-Prepare and +dirty-adapter counters remain. They do not replace actual runtime completion +authority. + +- Manifest digest: + `0386c8b99e2a94fda613670ec0d347aaf3bd04b6ff8b4f42c193701a682c3a70`. +- Plan digest: + `69bbbf02314096983765b4fda1ded636c3698903d4b714393b4280c5e504016b`. +- A assignment digest: + `2efb41bb3b00ec0f994ad7c247594b33abf22a3e63c2b6d3a6c610be2a4917d1`. +- B assignment digest: + `71c91e7aa02ff4f60e21e062ac8d101052bd4bcc30bd70d671182932a9a7f1ce`. +- Independent complete-pair audit SHA-256: + `8e363868ba47a42b37a17034c56a9caf4d9fdd8a3abfd06504c424236600bf6f`. +- A/B supplemental audit SHA-256: + `c17febd814137dfe9f16d73f6d71107ad8ae9e9e2f838107dd0a96ca7d7e06c1` / + `0ce804bb5307974104adc951d669c0569ae20703524174dbe517333abe80e417`. + +The failed source `806d6ba9` remains failed: 26 jobs and all 5,931 .NET cases +pass with natural producer cleanup, but the frontend child-observer failure +prevents qualification. The deterministic argument correction does not recover +its deliberately unretained cookie. Earlier failures and all stated observer, +launcher-wiring and browser-scope limits remain historical and explicit. + +This closeout changes only the delivery receipt/generated index and phase +Status. No executable, fixture lifetime, plan, population or toolchain changes. +Under [ADR-0063](../decisions/0063-complete-bounded-ci-test-shards.md), this normal +documentation push still requires one complete current-head assignment before +PR readiness. The A/B receipt belongs to the implementation source above, not +that later documentation-only commit. PR #36 remains the maintainer review/merge +gate; no merge is performed here. + + ## Status archive The Status block the phase document carried until 2026-10-22, kept verbatim. Its diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index fb09b52a..12d5ac39 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1,11 +1,18 @@ # Phase 02b: Events, Background Jobs, Identity, and Session -> **Status (2026-10-22).** Phase 02b is **in progress**. P02b-0 through P02b-5 are +> **Status (2026-10-24).** Phase 02b is **in progress**. P02b-0 through P02b-5 are > complete, and P02b-1–5 are merged through PRs #29–#33. P02b-6's implementation is > merged through [PR #34](https://github.com/HodeTech/LearnStack/pull/34); its -> post-merge CI corrections continue on `development`, and their records own the -> remaining qualification. P02b-7 and P02b-8 are pending; G13's session part, G16 -> and G18's erasure inventory stay Open. +> post-merge CI corrections are merged through +> [PR #35](https://github.com/HodeTech/LearnStack/pull/35). P02b-7's earlier +> implementation and source-bound A/B qualification remain recorded. The current +> runtime corrections and observer-test follow-up have completed local validation, +> both fresh independent review rounds and final-source hosted A/B qualification. +> The failed earlier source remains recorded; current-head required checks and +> maintainer review/merge remain the PR gate. +> [ADR-0071](../decisions/0071-uncertain-account-site-refusals.md) is Accepted and +> implemented. The packet row below owns correction state; maintainer review and +> merge remain separate. P02b-8's exit enforcement remains pending. > > This document is the plan. Every dated decision package, step record, review > round, correction and merge closeout lives in the [delivery log](phase-02b-delivery-log.md), split out @@ -22,8 +29,8 @@ > | P02b-3 | Consumer | ✅ complete and merged — PR #31, 2026-10-15; [merge closeout](phase-02b-delivery-log.md#p02b-3-merge-and-closeout-2026-10-15) | > | P02b-4 | Background jobs | ✅ complete and merged — PR #32, 2026-10-19; [merge closeout](phase-02b-delivery-log.md#p02b-4-merge-and-closeout-2026-10-19) | > | P02b-5 | API authentication | ✅ complete and merged — PR #33, 2026-10-20; [merge closeout](phase-02b-delivery-log.md#p02b-5-merge-and-closeout-2026-10-20) | -> | P02b-6 | Realm and seed | Implementation merged — PR #34, 2026-10-22; post-merge CI corrections in progress; [records](phase-02b-delivery-log.md#p02b-6--realm-and-seed) | -> | P02b-7 | BFF session | Pending — G13's session part and P02b-6 | +> | P02b-6 | Realm and seed | ✅ complete and merged — PR #34 and correction PR #35, 2026-10-23; [correction closeout](phase-02b-delivery-log.md#p02b-6-correction-merge-and-closeout-2026-10-23) | +> | P02b-7 | BFF session | [Review corrections and final-source A/B qualified](phase-02b-delivery-log.md#p02b-7-review-correction-qualification-2026-10-24); [PR #36](https://github.com/HodeTech/LearnStack/pull/36) pending maintainer review/merge | > | P02b-8 | Exit enforcement | Pending — G16 and G18's erasure inventory | ## Goal @@ -238,7 +245,7 @@ Accepted, and its catalogue rows registered, before its first line of code. | **P02b-4** | Background jobs: accepted H1 transactional/external profiles, confined storage/wakeups, tenant capacity and recurrence/fan-out; no production job/recurrence definition | G9 Accepted 2026-10-15; P02b-3 complete | | **P02b-5** | API authentication: closed JWT profile, ordered reconciliation and attributed rejection recorder, additional subject quota and one physically read-only authenticated endpoint | G10, G11, G12 (the BFF's client), G14, G20 Accepted | | **P02b-6** | Realm and seed: shared client scope/mappers, protected user-profile UUIDs agreeing with `SeedData`, safe warm reconciliation, narrow account self-service and real Keycloak/API proofs | G12, G13 (the access-token lifetime's authority) Accepted; P02b-5 | -| **P02b-7** | BFF session: Auth.js, the cookie session, single-flight refresh, CSRF, logout, the studio guard, the SDK's bearer attachment | G13 Accepted; P02b-6 | +| **P02b-7** | BFF session: native custody, opaque cookies, single-flight refresh, CSRF, logout, studio guard and native caller bearer attachment to the injected SDK | G13 Accepted; P02b-6 | | **P02b-8** | `LS0002`, the carrier reconciliations this phase owes, the re-verification that the behavioural check is still required under the name it reports, and the exit checks | G16, G18 (the erasure inventory) Accepted | The events packets precede the identity packets because the Hub's `P02c-3` waits on this @@ -274,7 +281,7 @@ premise a row cites is re-verified at that pass rather than trusted. | G10 | Does the active tenant travel as a scalar `tenant_id` claim, a `memberships` array, or both — and what value does `UserId` hold? | The scalar claim, whose 02b source is an admin-set user attribute; a second UUID-valued `user_id` attribute for the actor, with `sub` staying Keycloak's subject | Phase-doc: [ADR-0036 § What is out of scope](../decisions/0036-tenant-resolution-trusted-inputs.md#what-is-out-of-scope-and-what-is-not) hands the shape to this phase by name. Reconcile `architecture/13`'s array sketch in the same change | P02b-5 | Accepted — 2026-10-19; [P02b-5 package](phase-02b-delivery-log.md#p02b-5-decision-package-2026-10-19) | | G11 | Which [ADR-0036 matrix](../decisions/0036-tenant-resolution-trusted-inputs.md#the-reconciliation-matrix) rows resolve in this phase, given that row 10 consults a reader that denies everyone — and what row covers a signature-valid token carrying no `tenant_id` claim on a tenant host? | Issue a matching organization claim for row 9. Rows 7/10/14 remain membership-dependent refusals until Phase 03; a claimless principal retains public-only HostOnly authority. Row 14 records no tenant audit without a confirmed host tenant, as bounded by ADR-0064 | A dated **ADR-0036** amendment plus errata beside § Staging across packets and the 2026-09-02 erratum, both of which assign row 10 to this phase without noting that its `M covers (T, O)` term is denied until Phase 03 — which groups it with rows 7 and 14 rather than with 6 and 9 | P02b-5 | Accepted — 2026-10-19; [P02b-5 package](phase-02b-delivery-log.md#p02b-5-decision-package-2026-10-19) | | G12 | Which Keycloak client does the BFF exchange the code with, where does sign-in happen across two tenant hosts, and how do the client scope, the mappers and the seed users reach a Keycloak database that has already consumed the import? Native HTTPS on retained `*.learnstack.local` hosts, with manual hosts/trust setup, is delivered by Phase 02d and re-verified by P02b-0 | A confidential BFF client with one redirect URI per seed host, retaining native HTTPS and `Secure` cookies; and an idempotent reconciler shared by `make seed` and `make demo` — a mapper added only to the import reaches no existing workstation | Phase-doc + the realm JSON + `scripts/seed.sh` + `infra/keycloak/README.md`; a new ADR only if the reconciler becomes ADR-0004's `IIdentityProvider` | P02b-0 re-verifies [Phase 02d G32](phase-02d-walking-skeleton.md#the-decision-register), ADR-0053/0054 and ADR-0055; P02b-5 (which client the BFF redeems the code with: the API pins `azp` against it), P02b-6 | Accepted — BFF-client choice, 2026-10-19; realm/reconciliation/callback choices, [2026-10-21 package](phase-02b-delivery-log.md#p02b-6-decision-package-2026-10-20) | -| G13 | What is the BFF session — its custody of the refresh token, its store, its idle and absolute lifetimes, its refresh serialisation under concurrent requests, its terminal `invalid_grant` behaviour, its CSRF control and its logout — and which document owns the access-token lifetime? | The `HttpOnly` cookie Security Standards already describes, encrypted by the session adapter, read only by server code, with single-flight refresh and one authority for the TTL | Phase-doc + one reconciled [Security Standards § Authentication](../standards/11-security.md#authentication); [ADR-0067](../decisions/0067-development-tenant-access-token-issuance.md) is the dedicated development-issuance authority; a new session ADR is required for a server-side store | P02b-6 (which document owns the access-token lifetime: the realm export and its reconciler write it), P02b-7 | Accepted — issuance-TTL part, [2026-10-21 package](phase-02b-delivery-log.md#p02b-6-decision-package-2026-10-20) and separate ADR-0067 acceptance on 2026-10-21; session part Open for P02b-7 | +| G13 | What is the BFF session — its custody of the refresh token, its store, its idle and absolute lifetimes, its refresh serialisation under concurrent requests, its terminal `invalid_grant` behaviour, its CSRF control and its logout — and which document owns the access-token lifetime? | The `HttpOnly` cookie Security Standards already describes, encrypted by the session adapter, read only by server code, with single-flight refresh and one authority for the TTL | Phase-doc + one reconciled [Security Standards § Authentication](../standards/11-security.md#authentication); [ADR-0067](../decisions/0067-development-tenant-access-token-issuance.md) is the dedicated development-issuance authority; a new session ADR is required for a server-side store | P02b-6 (which document owns the access-token lifetime: the realm export and its reconciler write it), P02b-7 | Accepted — issuance-TTL part, [2026-10-21 package](phase-02b-delivery-log.md#p02b-6-decision-package-2026-10-20) and separate ADR-0067 acceptance on 2026-10-21; session part Accepted — [ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md) and [revised package](phase-02b-delivery-log.md#p02b-7-revised-decision-package-2026-10-23), [acceptance](phase-02b-delivery-log.md#p02b-7-acceptance-and-implementation-2026-10-23), 2026-10-23 | | G14 | What bounds the request types reachable over HTTP while `AuthorizationBehavior` is `return next()`, and what is the shape of the token-keyed rate-limit stage behind a shared BFF connection? | An enumerated read-only routable set held by a catalogue-registered rule, and a limiter stage after `UseAuthentication` keyed on the validated subject. The early anonymous stage already runs before authentication: P02d-5 delivers 60/min per canonical direct or authenticated-hop visitor IP plus 600/min peer acquisitions/allocations under ADR-0053 and ADR-0054. Exhausted known visitors refuse before peer debit; unknown visitor allocation remains peer-gated. These remain IP/peer quotas, not token quotas; a request they reject never reaches the token stage. Re-verify the [P5 remediation decision](phase-02d-delivery-log.md#p02d-5-external-review-remediation-2026-10-09) before choosing authenticated accounting, preserving pre-classification protection against unknown-host abuse | Phase-doc + catalogue registration; a dated ADR-0032 Amendment for a deny-by-default step instead | P02b-5 (the token-keyed stage and the early stage's treatment of validated callers); G34's anonymous-hop policy and ADR-0054's bounded accounting replacement are Accepted and delivered in P02d-5 | Accepted — 2026-10-19; [P02b-5 package](phase-02b-delivery-log.md#p02b-5-decision-package-2026-10-19) | | G15 | What initiates a replay in this phase? | Targeted internal subscriber replay in P02b-3; producer/operator reset and fencing require a separate Phase 03 contract | Phase-doc; the URL space is its own decision record, per ADR-0036 | — (answered in § The consumer side; recorded so the question travels with the deferral) | Accepted — internal subscriber replay implemented by P02b-3 Step 4; operator contract Phase 03 | | G16 | What is `LS0002`'s rule name, which project trees does it run over, what does it inspect, and what escalates it to Error? | The name and scope recorded the way Amendment 1 recorded `LS0001`, with the scope reaching the assemblies that actually handle tokens | A dated **ADR-0032** amendment + a catalogue entry + an `AnalyzerReleases` row | P02b-8 | Open | @@ -630,50 +637,26 @@ export that loses the mapper fails it. #### The BFF session -The session is the first thing in the repository that holds a user credential, and its -properties are required rather than inherited from an adapter's defaults. **G13** -settles it; what the phase ships either way: - -- **OIDC Authorization Code flow with PKCE between the browser, the BFF and Keycloak.** - The BFF is the code-exchange client; the API is a resource server that validates a - bearer token and is not a party to the flow. -- **Custody.** Security Standards puts refresh tokens in `HttpOnly`, `Secure`, - `SameSite=Lax` cookies and that document owns the rule; what this phase adds is that - only server code reads them, and that no response body, RSC payload, session JSON or - log line carries one. -- **Lifetimes and refresh.** One authority for the access-token TTL — four documents - currently give four numbers, and this phase links the authority rather than adding a - fifth. Concurrent requests arriving with a near-expiry token produce exactly one token - request and one logical replacement session version, including any cookie chunks; - a terminal `invalid_grant` clears the session and the - visitor gets the public page anonymously rather than an error. -- **Cookie scoping and CSRF.** Host-only cookies with no `Domain`, so two tenant hosts - do not share a session, plus an `Origin` check and a session-bound token on every - mutating route. `SameSite=Lax` does not separate hosts under one registrable domain, - which is exactly the SaaS subdomain shape, and no CSRF rule exists in the catalogue - today. -- **Login topology**, under **G12**: login is served on the visitor's tenant host, - requiring one registered redirect URI per seed host. Phase 02d retains - `demo-english.learnstack.local` and `demo-yoga.learnstack.local` under the native - HTTPS launcher; hosts entries and certificate trust remain manual prerequisites. - `P02b-0` re-verifies [G32](phase-02d-walking-skeleton.md#the-decision-register) - and the ADR-0053/0054/0055 ingress, visitor/peer and single-bootstrap boundaries. - G12 still settles the BFF client, callback and post-logout URLs; it preserves - `Secure`. The login transaction's `state`, `nonce` and `code_verifier` live in a - host-only transaction cookie, and return URLs are validated. -- **Logout**, which - [Identity and Auth § Logout](../architecture/13-identity-and-auth.md#logout) describes - and Phase 03 expects: the session cleared, the end-session endpoint called, and - back-navigation not restoring an authenticated view. -- **Something that consumes the session.** One authenticated `/api/v1/*` read and one - guarded page, both in the OpenAPI document and the regenerated SDK. Without them "a - login completes" cannot be demonstrated, and - `Backend_RequiresJwt_OnAllAuthenticatedRoutes` sweeps an empty route set. -- **Auth.js and `auth()` placed in `apps/web`**, the `(studio)` / `(portal)` redirect, - the server SDK's bearer attachment, and server-only environment keys. The - `add-frontend-route` and `add-feature-gated-ui` skills contain future auth - illustrations rather than shipped helpers; P02b-7 replaces them with the real - session API when it lands. +[ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md) and the +[Accepted five-step package](phase-02b-delivery-log.md#p02b-7-revised-decision-package-2026-10-23) +settle G13. [Security 11](../standards/11-security.md#tenant-bff-sessions) owns +ongoing custody/lifetime/admission rules. This packet delivers: + +- Library-validated OIDC code/S256/state/nonce on the two exact tenant callbacks, + with server-only credentials and per-transaction browser/host proof. +- One native process session owner, bounded source/subject/global admission, + on-demand/single-flight refresh, effective expiry and actual provider extension. +- Host-only opaque cookies and exact Origin/CSRF checks; local revocation before + provider logout, fenced late work and real history/cookie isolation proof. +- Closed authenticated AccountSite transport and guarded Studio/Portal shells, + with a read-only app-local `auth()` facade and localized recovery/timeout UI. + No permission, membership, Server Action or general proxy is introduced. +- Durable selected development provider events, qualified warm retention, + preservation/readback/zero-write controls and explicit local-only audit gaps. +- Real warm-provider/BFF/API lifecycle plus both-host browser cookie/history + proof; existing anonymous sites remain independent of stopped Keycloak. +- The route/feature-gate skills use the shipped facade when delivered in Step 4; + they must not infer future Phase 03 permissions from an authenticated actor. ### Useful provisioning flow @@ -959,7 +942,7 @@ command. The phase retains zero skips, per assembly. `make seed`, preserving existing accounts and the `learnstack-hub` realm. - OIDC PKCE login through the BFF on the visitor's own tenant host, a cookie session with host-only scoping and single-flight refresh, an `Origin` plus session-bound-token - CSRF control, RP-initiated logout, and Auth.js wired in `apps/web` with the two + CSRF control, RP-initiated logout, and the ADR-0070 RP/owner wired in `apps/web` with the two skills' illustrative `auth` references replaced with the shipped helper. - The first authenticated `/api/v1/*` read and the first guarded page, both in the OpenAPI document and the regenerated SDK. @@ -1216,16 +1199,27 @@ CI and merge verification; its dated local receipts retain their original scope. request through the retained native HTTPS launcher and manual hosts/trust setup that `P02b-0` re-verifies. - Two concurrent requests with a near-expiry token produce exactly one token request at - the issuer and one logical session version — counted as versions, not as `Set-Cookie` - headers, because a session adapter may split one cookie across several and a correct - single update can then emit more than one. Whatever the split, the chunks grow, shrink - and are all cleared on logout. A terminal `invalid_grant` clears the session and + the issuer and one logical server session revision, independent of cookie response + count. The fixed-size opaque format rejects malformed/duplicate/oversized cookie + families; unrelated transaction cookies survive failed callbacks. A terminal + `invalid_grant` revokes the session and serves the public page anonymously with a 200; logout ends the session at Keycloak and back-navigation does not restore an authenticated view. - A cross-origin POST between the two demo hosts is refused, and so is a POST with a correct `Origin` and no session-bound token. -- An unauthenticated request to `(studio)` is redirected to sign-in and `(public)` is - not. +- With BFF explicitly enabled, an unauthenticated exact `/studio` or `/portal` + request redirects to sign-in and public pages do not. Disabled mode returns + the bounded sign-in-unavailable 503 without provider work. +- Source/subject/global admission and actual-settlement ownership satisfy + [ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md) independently; canceled + callers and abandoned/closing work cannot manufacture reusable capacity. +- A valid ordinary AccountSite 401 withholds checked content without changing + idle expiry; later matching recovery reuses the owner. Initial and refreshed + revision publication still require a positive witness under + [ADR-0071](../decisions/0071-uncertain-account-site-refusals.md). +- Qualify the actual warm-provider/BFF/app-role API chain separately from the + controlled-grant/API browser cookie/history proof. Neither stands in for + Phase 06's full browser-driven OIDC journeys. - **Both [Phase 02d](phase-02d-walking-skeleton.md) sites still render anonymously with Keycloak stopped** — the session is additive to the anonymous path, never in front of it. @@ -1321,6 +1315,17 @@ CI and merge verification; its dated local receipts retain their original scope. PostgreSQL, so that is inherited rather than mitigation. What this phase controls is that an anonymous page load depends on no identity provider, which the Phase 02d regression criterion asserts. +- **Provider events are mistaken for a durable session ledger.** Selected + development events have finite retention and asynchronous expiry, not a row/ + disk capacity bound or atomic application-audit guarantee. They cannot witness + local expiry or logout during a provider outage. Phase 03 owns the authenticated + identity-event ingress/mirror; Phase 11 owns production retention and capacity. +- **A local BFF is mistaken for distributed continuity or outage immunity.** + Restart loses sessions and shared global/source/subject caps can refuse new + sign-ins across tenants. Refresh/publication still needs positive API/provider + authority. ADR-0071 retains an uncertain ordinary 401 only until the existing + refresh/deadline rules apply; masked 403/404 can still revoke. Phase 11's + distributed adapter/proxy qualification precedes multiple web processes. - **Keycloak claims leak into authorization decisions.** Keycloak authenticates; LearnStack authorizes. The realm currently ships three tenant-shaped realm roles with full scope on both clients, so the mitigation is the realm-role resolution above plus diff --git a/docs/roadmap/phase-03-identity-admin.md b/docs/roadmap/phase-03-identity-admin.md index f13aee76..69c9cfc0 100644 --- a/docs/roadmap/phase-03-identity-admin.md +++ b/docs/roadmap/phase-03-identity-admin.md @@ -145,7 +145,8 @@ the table it lives in and who may write it. | Attribute class | Owner | Storage | Writable by | |---|---|---|---| -| Credentials, MFA factors, token material | Keycloak | Keycloak realm `learnstack` | Keycloak only | +| Provider credentials, MFA factors, token issuance | Keycloak | Keycloak realm `learnstack` | Keycloak only | +| BFF token custody | Native web session owner | Process-local store under [ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md) | The closed BFF owner only | | `sub`, canonical email, email-verified flag | Keycloak | mirrored onto `users` | The mirror handler only | | Global display name, avatar, preferred UI locale, global account status | The person | `users` | The person; platform admin under an audited `EnterPlatformAdminScope(reason)` | | Roles and permissions inside a tenant | Tenant | `memberships` | Tenant / organization admin of that tenant | @@ -258,13 +259,26 @@ what may be written into it invites exactly the leak this phase closes. ### Authentication Integration +P02b-7 implements [ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md). +Before immediate account-disable/sign-out-everywhere is promised, this phase +owns authenticated OIDC back-channel logout ingress, signature/audience/time/ +replay validation and provider-session indexing into `BffSessionStore`. +It also owns authenticated provider event ingress/mirroring through the existing +identity-event/audit path. Earlier development provider storage is not a durable +LearnStack session ledger; local-only expiry/outage acts retain that limitation. +If the API needs a distinct indeterminate-authentication outcome or trusted +refusal witness, this phase owns that separate contract decision and its BFF +consumer proofs. Until then, preserve +[ADR-0071](../decisions/0071-uncertain-account-site-refusals.md)'s status table; +provider errors or challenge text cannot manufacture a trusted distinction. + Keycloak owns credentials, password reset, email verification, and MFA. This phase wires LearnStack into that flow: -- OIDC token validation against Keycloak's JWKS - ([Phase 02b](phase-02b-events-auth.md) configured the middleware). -- BFF session handling for the Next.js Studio: HTTP-only cookies, silent refresh, - end-session at Keycloak on logout. +- Consume the existing strict API token validation and native BFF session owner + from [Phase 02b](phase-02b-events-auth.md), including opaque HttpOnly cookies, + on-demand refresh and local-first provider logout. Extend its membership and + permission integration here rather than implementing session custody again. - Post-login membership lookup: resolve memberships for the authenticated user and surface the active tenant via a host + claim cross-check. - Tenant-specific Keycloak federation surface in Admin Studio (configure SAML / OIDC @@ -272,7 +286,9 @@ wires LearnStack into that flow: - Mapping of Keycloak `sub` to LearnStack `UserId`; idempotent on first login. LearnStack does **not** implement password hashing, password reset email rendering, -refresh token storage, or brute-force protection — those are Keycloak responsibilities. +or provider brute-force protection; Keycloak owns those capabilities and token +issuance/rotation. The native BFF owns custody of the issued access, refresh and +ID tokens under ADR-0070; no application identity table stores them. ### Authorization @@ -508,6 +524,9 @@ find the result row already written rather than record a second one. - Invitation flow end to end, tenant and organization bound. - Identity events wired through the central audit pipeline; Keycloak webhook → `IEventBus` → Audit consumer working end to end. +- Authenticated OIDC back-channel logout ingress and provider-session lookup into + `BffSessionStore`, with signature/audience/time/replay validation and fenced + local revocation, before immediate disable/sign-out-everywhere is promised. - Corrected APISIX route table: explicit priorities on every route, and no Hub HMAC reference anywhere in gateway configuration. - `LS0001` and `LS0002` escalated to Error. @@ -543,6 +562,13 @@ find the result row already written rather than record a second one. alone cannot make either negative control pass. - Invitation flow is covered by integration tests, including email mismatch, revoked, expired, and cross-organization cases. +- Real signed back-channel logout revokes only the intended provider/session + owners. Invalid signatures, issuer/audience/time claims and replay are refused; + unrelated users/sessions and newer logins outside the validated target remain + unaffected. Shared provider SSO is not mistaken for independent host sessions. + A held callback/refresh cannot republish after revocation, and unsettled work + retains its ownership until actual completion. Provider ingress/mirrored audit + evidence is distinguished from local-only session events. - MUST-class identity audit entries commit in the same transaction as the write they describe, with before / after snapshots where applicable; no Identity-owned `audit_*` table exists. diff --git a/docs/roadmap/phase-06-renderer-admin-studio.md b/docs/roadmap/phase-06-renderer-admin-studio.md index a8c0ed40..83ecdb65 100644 --- a/docs/roadmap/phase-06-renderer-admin-studio.md +++ b/docs/roadmap/phase-06-renderer-admin-studio.md @@ -243,6 +243,11 @@ already separates public/authenticated generated GET ownership. P02b-7 supplies session transport and its guarded page; this phase expands that foundation to the full authenticated browser/Studio/Portal journeys. It does not reintroduce token storage in browser JavaScript or make AccountSite an anonymous public wrapper. +The authenticated browser matrix covers Chromium, Firefox and WebKit against +the real provider and API, extending P02b-7's separate controlled-browser and +real-provider proofs. It includes cold API discovery failure under +[ADR-0071](../decisions/0071-uncertain-account-site-refusals.md), not only a warm +cached-key success path. ### End-to-end test stack — a carried dependency @@ -277,6 +282,9 @@ browser suite against a mis-provisioned stack is worse evidence than no suite. - Shared UI package and the typed SDK over the full API surface. - The full Playwright Test/axe end-to-end suite running against the fixed ephemeral stack, extending the narrow P02d-7 public browser/audit proof. +- Real-provider sign-in/AccountSite/extension/sign-out and protected navigation + journeys in Chromium, Firefox and WebKit, including both tenant hosts, + cookie/history isolation and controlled dependency refusals. ## Completion Criteria @@ -305,6 +313,13 @@ browser suite against a mis-provisioned stack is worse evidence than no suite. - 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). +- Chromium, Firefox and WebKit complete the real-provider authenticated journey + on both hosts. Raw response attributes and actual cookie storage/send agree; + logout and browser history cannot restore protected content. Cold API discovery + failure with a fresh session withholds checked content without an ordinary-read + idle touch; callback and post-refresh refusal still follow ADR-0071's stricter + publication rules. Timeout warnings, truthful extension/recovery and no-JavaScript + alternatives have the required keyboard and assistive-technology observations. - 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 e362a221..a0a49386 100644 --- a/docs/roadmap/phase-11-production-hardening.md +++ b/docs/roadmap/phase-11-production-hardening.md @@ -43,6 +43,14 @@ Two consequences follow, and both are stated plainly rather than implied: ### Demand-gated building blocks +**Distributed BFF sessions** — trigger: multiple native web processes must serve +one browser session. P02b-7's app-local `BffSessionStore` uses the volatile +`InMemoryBffSessionStore` under +[ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md) and ADR-0035's dated +additive inventory. This phase supplies/qualifies the distributed adapter, +credential security/retention, atomic refresh/revocation/interruption and +production provider-event storage/capacity. Sticky routing is not continuity. + Most items below have a port in `LearnStack.SharedKernel` and a default implementation from [Phase 02a Packet 5](phase-02a-kernel-tenancy.md), with a trigger condition recorded in [ADR-0035 § The gated set](../decisions/0035-demand-gated-infrastructure.md), which is @@ -321,6 +329,28 @@ not a request to add an unused module, adapter or port. owns the current same-issuer constraint. A split metadata origin needs a separate accepted design and containment proofs before deployment; do not substitute an internal issuer merely to make the network request succeed. +- **BFF host and proxy activation.** Register each newly activated tenant host's + exact HTTPS callback and root post-logout URI on the confidential web client + before enabling sign-in there; domain verification alone grants no redirect + registration. Qualify provider-event retention/storage and secret provisioning, + including replacement of published development credentials. The current native + listener counts its verified TCP peer as a source; proxy-derived visitor trust, + IPv6 prefix fairness and multi-process continuity need explicit deployment + decisions and proofs before promising those properties. P02b-7's two seed-host + and controlled-issuer evidence supplies none of them. +- **BFF operational signals.** Before production activation, qualify closed + lifecycle/failure visibility, session/pending/source/subject capacity pressure, + alert thresholds, and a recovery runbook for the native owner. + The current failure warnings are not capacity telemetry or a durable session + ledger. Decide bounded signals and safe deployment attribution without token, + actor, session-handle or arbitrary host labels; test actual exported privacy + and volume before relying on dashboards or alerts. +- **Native BFF resource and clock qualification.** Qualify the deployment's + listener/header/body/connection limits, trusted outbound TLS/proxy path and + suspend/resume behavior before production activation. Current monotonic + lifetimes and finite Node/form defaults do not promise suspended-host wall-time + continuity or a production denial-of-service bound. Changing those semantics + requires explicit review of the existing lifetime and transport contract. - **Outage and key rotation.** Exercise cold start without metadata, a warmed known signing key during an outage, a new key while refresh fails, recovery, and rejection of a withdrawn unexpired key after the replacement set has been observed. Current @@ -635,6 +665,10 @@ same `ILiveClassProvider`. with the `DeploymentMode` composition root resolving each per mode. - Integration suites for `Dedicated`, `SelfHostedOnline` and `SelfHostedAirGapped`, and the support claim updated from two modes to five. +- When multiple native web processes must serve one browser session, a distributed + adapter behind `BffSessionStore` with qualified credential custody/retention and + atomic refresh, revocation and interruption behavior before that topology is + activated. A sticky route does not satisfy this trigger. - `audit_log` converted to monthly range partitions — the parent created, the Packet 9 table attached, indexes and policy recreated on it — with the partition-management and retention-purge jobs registered and monitored, and @@ -689,6 +723,12 @@ same `ILiveClassProvider`. - A second application instance can be started without a correctness regression: distributed cache, cross-instance invalidation, and outbox dispatch under two dispatchers all behave. +- Before multi-process web sessions are activated, two real web owners exercise + the distributed BFF adapter: one refresh revision is published, revocation + fences held/late work on either owner, interrupted grants are never blindly + replayed, and actual settlement governs cleanup. Restart/failover, credentials + and provider-event retention have explicit evidence; sticky routing or the + existing volatile store alone cannot establish continuity. - An integration event crosses a process boundary through Dapr and is delivered **at-least-once** to a subscriber in another process, with tenant context restored on the consumer side — and a forced redelivery of the same envelope produces exactly one diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index 908d2d3b..5cc40c05 100644 --- a/docs/standards/06-testing.md +++ b/docs/standards/06-testing.md @@ -1,7 +1,8 @@ # 06 — Testing Standards **Status:** Active -**Derives from:** [ADR 0003 — Tenant Isolation Defense in Depth](../decisions/0003-tenant-isolation-defense-in-depth.md), [ADR 0010 — Cross-Module Communication](../decisions/0010-cross-module-communication.md). +**Derives from:** [ADR-0070 Owned Tenant BFF Sessions](../decisions/0070-owned-tenant-bff-sessions.md), +[ADR 0003 — Tenant Isolation Defense in Depth](../decisions/0003-tenant-isolation-defense-in-depth.md), [ADR 0010 — Cross-Module Communication](../decisions/0010-cross-module-communication.md). 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) @@ -15,6 +16,23 @@ and the [Accepted P02d-6 package](../roadmap/phase-02d-delivery-log.md#p02d-6-de Test pyramid, conventions, and what every change must cover. +Tenant BFF proofs follow [ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md): +unit/actual-native route ownership and planted negatives, real warm Keycloak → +BFF → app-role AccountSite lifecycle/event evidence, and a separate native HTTPS +browser proof of both hosts' cookie storage/send/logout/history. Realm issuance +alone proves no BFF session; Phase 06 retains the full browser OIDC journey. +`TenancyIdentity/TenantBffSessionTests.cs` owns the real chain and invokes +`verify-bff-session.mjs` through a bounded private pipe. Its separate stock-browser +case invokes `verify-bff-browser.mjs` against native HTTPS with fake grants/API; +natural BFCache observations and the explicit persisted-event counter remain +distinct. Every fixture compiles the copied current native/Next sources, joins +owned processes/listeners and exports no raw tokens, cookies, callback/logout +URLs, provider text or HTML. Ordinary CI compiles native web/authenticated SDK +entries before test discovery/execution within its existing whole-job budget. +BFF success also requires unforced group settlement and native exit 0 without a +terminating signal. Parent failure closes the private lifetime pipe first and +verifies its own temporary directory is empty; forced/incomplete cleanup fails. + ## Test Pyramid ```mermaid @@ -204,11 +222,15 @@ gaps and scoped resource observations accompany every successful execution. Uninstrumented fixture/container measurements remain unknown. Use **Re-run all jobs**, not failed-job or single-job reruns. All producer -siblings must belong to the current run attempt. Odd/even attempts select the -two deterministic layouts; final qualification requires genuinely distinct -co-membership, including SharedSchema methods, on the same source, plan, -toolchain and complete case population. Plan, algorithm or fixture-lifetime -changes require both layouts. Complete this qualification before merging. +siblings must belong to the current run attempt. Before rerunning, retain the +completed attempt's original producer packages, aggregate receipts and workflow +metadata: a rerun can replace its Actions artifacts. Recovered job-log rows are +labelled recovery evidence, never original artifact packages. +Odd/even attempts select the two deterministic layouts; final qualification +requires genuinely distinct co-membership, including SharedSchema methods, on +the same source, plan, toolchain and complete case population. Plan, algorithm +or fixture-lifetime changes require both layouts. Complete this qualification +before merging. GitHub can omit a merged same-repository PR from a run's REST projection; the aggregate refuses that missing identity even after a complete rerun. Further PR qualification uses an open follow-up PR, without weakening identity checks diff --git a/docs/standards/07-frontend-architecture.md b/docs/standards/07-frontend-architecture.md index 73032496..8375ec8b 100644 --- a/docs/standards/07-frontend-architecture.md +++ b/docs/standards/07-frontend-architecture.md @@ -1,7 +1,8 @@ # 07 — Frontend Architecture Standards **Status:** Active -**Derives from:** [ADR-0009 Frontend Single App First](../decisions/0009-frontend-single-app-first.md), +**Derives from:** [ADR-0070 Owned Tenant BFF Sessions](../decisions/0070-owned-tenant-bff-sessions.md), +[ADR-0009 Frontend Single App First](../decisions/0009-frontend-single-app-first.md), [ADR-0004 Authentication Strategy](../decisions/0004-authentication-strategy.md) (Amendment 1: `learnstack-hub` realm for the separate operator portal), [ADR-0019 LearnStack Hub](../decisions/0019-learnstack-hub.md) (the operator portal @@ -36,13 +37,16 @@ frontend/ src/ app/ (public)/ # tenant public site - [...slug]/ + [locale]/courses/[slug]/lessons/[lessonSlug]/ + [locale]/status/not-found/ layout.tsx (studio)/ # admin studio + studio/ # account scaffold only layout.tsx (portal)/ # learner + instructor + portal/ # account scaffold only layout.tsx - api/ # thin BFF route handlers + api/healthz/ # infrastructure GET; auth is native-owned layout.tsx # root layout middleware.ts # ingress verification and live locale/path admission components/ @@ -51,14 +55,16 @@ frontend/ request.ts messages/ en/public.json + en/auth.json tr/public.json - lib/ + tr/auth.json + server/auth/ # native BFF owner and fixed RP protocol + client/bff-session.ts # closed safe session projection/extension packages/ ui/ # design system primitives (extracted only when duplication is real) sdk/ # generated API client + types config/ # eslint, tsconfig, tailwind shared configs - auth/ # OIDC client config + BFF helpers ``` The operator portal `operator-portal` is a **separate Next.js application in the @@ -75,7 +81,9 @@ Migration to multiple apps within this repo (e.g. extracting `(studio)` into - **Server Components (RSC)** are the default for every page and component. - Add `"use client"` only for interactivity, hooks, browser APIs, third-party client-only libraries. -- RSCs fetch through the SDK directly; they access server env vars and cookies. +- RSCs use the admitted request-local loaders and passive safe session lookup. + Private transport configuration, credential cookies and bearer attachment + remain in the native response owner and configured callers. - Pass typed primitives across the RSC → Client boundary; never pass class instances or closures. ## Tenant Resolution @@ -117,10 +125,12 @@ flowchart TD Remediation Step 2 implements these controls. Concrete execution and review evidence belongs to the [delivery record](../roadmap/phase-02d-delivery-log.md#remediation-step-2--native-ingress-and-url-boundary). -- Admit GET/HEAD on every HTTP path reaching the native listener callback before +- Admit GET/HEAD on HTTP paths reaching the native listener callback before Next dispatch, including matcher-exempt health/assets and exact scaffolds. - Other methods receive masked `404`, `Cache-Control: no-store`, without bootstrap - or Next method conversion. HEAD is bodyless; Node owns malformed protocol input. + ADR-0070 additionally admits POST only on `/api/auth/signin/keycloak`, + `/api/auth/signout` and `/api/auth/extend`, with their closed proof/body + rules. All other methods receive masked no-store 404 without bootstrap or + Next method conversion. HEAD is bodyless; Node owns malformed protocol input. - Close every production WebSocket upgrade. Development admits only the validated GET HMR upgrade path and closes failed/unhandled delegation. Health/asset exemptions grant no method, provenance or tenant authority. @@ -128,7 +138,7 @@ evidence belongs to the [delivery record](../roadmap/phase-02d-delivery-log.md#r 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.27's explicit full-URL RSC/`_rsc` projection; do + only against pinned Next 15.5.28'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 @@ -204,7 +214,10 @@ server caller in `apps/web/src/server/configured-public-client.ts`. Production served/snapshot equality and required frontend regeneration/drift checks are part of P02d-4; generator output remains outside formatter rewriting. - The root exports generated types and the exact authenticated path/operation - contract, with no authenticated transport. `/server` supplies four thin public GET + contract. `/authenticated-server` supplies only the injected, no-store + `getAccountSite` GET; its native configured caller owns credentials and the + independently verified host/current visitor. It exposes no credential/header/host + selector to UI consumers. `/server` supplies four thin public GET wrappers using generated operation types and an injected Fetch-compatible transport that resolves relative URLs; no global-fetch default. - The unused client factory/export is removed. No tenant-ID option, authority-header @@ -235,10 +248,15 @@ owns the source, pin, diff policy, bootstrap exception and required-check rollou ## Auth -- Auth.js for session management. +- Tenant BFF session ownership derives from + [ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md); implementation is + delivered by P02b-7. Use the pinned openid-client RP and native session owner. - OIDC provider: Keycloak. -- Sessions in `HttpOnly`, `Secure`, `SameSite=Lax` cookies. -- Server Components and Server Actions read the session via `auth()`; never read tokens in Client Components. +- Credentials stay in the server owner; the browser carries only host-bound + opaque cookies. [Security 11](11-security.md#tenant-bff-sessions) owns the rules. +- Server Components use the app-local read-only `auth()` projection. Response + owners perform refresh/idle/cookie updates before streaming. Never expose + credentials to Client Components; no Server Action is admitted by P02b-7. ## Routing @@ -319,7 +337,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.27 SWC +source edges: TypeScript verbatim emission retains them; pinned Next 15.5.28 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. @@ -408,7 +426,8 @@ P02d-6 admission must complete before the initial shell flushes, per - Strict CSP with nonces. - No `dangerouslySetInnerHTML` outside a sanitization wrapper. -- Form CSRF: Server Actions verify the Auth.js session; explicit CSRF tokens for non-Action mutating routes. +- Auth form CSRF follows [Security 11](11-security.md#csrf); neither a cookie nor + implicit framework/Server Action behavior substitutes for the explicit checks. - Outbound URLs validated against an allow-list before rendering. ## Tooling diff --git a/docs/standards/08-localization.md b/docs/standards/08-localization.md index 6863930e..5d117372 100644 --- a/docs/standards/08-localization.md +++ b/docs/standards/08-localization.md @@ -5,6 +5,7 @@ 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). +Session UI follows [ADR-0070](../decisions/0070-owned-tenant-bff-sessions.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. @@ -233,6 +234,10 @@ applying the fallback chain. 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. +- Authentication/session copy uses the same app-local catalogue home in + `messages/{en,tr}/auth.json`, selected by `src/i18n/auth-catalogues.ts` from the + admitted site's locale. Its English/Turkish key, ICU and callsite controls + share the public UI catalogue guard; provider error text is never UI copy. - Backend: localized strings live in resource files under each module. - Strings are referenced by key, never duplicated: @@ -270,8 +275,9 @@ planted failure controls, per ADR-0027 and the canonical 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. +fallback substitutes Pattern A titles, summaries or bodies. The current account +scaffolds use the auth catalogue's selected language/direction. Phase 06 extends +that foundation to the full Studio/Portal UI. ## Locale Codes diff --git a/docs/standards/10-observability.md b/docs/standards/10-observability.md index 9820a434..ef362ea6 100644 --- a/docs/standards/10-observability.md +++ b/docs/standards/10-observability.md @@ -1,7 +1,8 @@ # 10 — Observability Standards **Status:** Active -**Derives from:** [ADR 0002 — Initial Architecture](../decisions/0002-initial-architecture.md), [ADR 0006 — Events and Outbox](../decisions/0006-events-and-outbox.md) (outbox + tenant-context propagation across async boundaries), [ADR 0032 — Exception Handling, Logging, and Observability Architecture](../decisions/0032-exception-handling-logging-and-observability.md) (Sentry / OTel boundary, Serilog wiring, span attribute propagation, deployment-mode branching). +**Derives from:** [ADR-0070 Owned Tenant BFF Sessions](../decisions/0070-owned-tenant-bff-sessions.md), +[ADR 0002 — Initial Architecture](../decisions/0002-initial-architecture.md), [ADR 0006 — Events and Outbox](../decisions/0006-events-and-outbox.md) (outbox + tenant-context propagation across async boundaries), [ADR 0032 — Exception Handling, Logging, and Observability Architecture](../decisions/0032-exception-handling-logging-and-observability.md) (Sentry / OTel boundary, Serilog wiring, span attribute propagation, deployment-mode branching). Producer additions derive from [ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). Delivery/recovery additions derive from @@ -32,6 +33,11 @@ seam, correlation across async boundaries) lives in [33-cross-cutting-concerns.md](../architecture/33-cross-cutting-concerns.md). This standard contains the day-to-day rules. +Tenant BFF diagnostics use only closed lifecycle reasons, with no tokens, +handles, raw provider errors, profiles or redirect URLs. Metrics carry no host, +issuer, subject, actor or correlation identity labels. Provider user-event records +stay in Keycloak, not raw artifacts; local diagnostics are not durable audit. + ## Stack - **OpenTelemetry** SDK for traces + metrics. diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index bdefe49c..c13f4637 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -8,6 +8,10 @@ model, and session-variable placement**), (Amendment 1: `learnstack-hub` realm), [ADR-0067 Development Tenant Access-Token Issuance](../decisions/0067-development-tenant-access-token-issuance.md) (the configured development issuance lifespan and provider timestamp semantics), +[ADR-0070 Owned Tenant BFF Sessions](../decisions/0070-owned-tenant-bff-sessions.md) +(native custody, browser transactions and development provider event storage), +[ADR-0071 Uncertain AccountSite Refusals](../decisions/0071-uncertain-account-site-refusals.md) +(ordinary-read status mapping and positive revision publication), [ADR-0068 Development Warm-Realm Qualification](../decisions/0068-development-warm-realm-qualification.md) (the bounded preparation and stock-console assignment authority), [ADR-0015 API Gateway: APISIX](../decisions/0015-api-gateway-apisix.md), @@ -148,8 +152,8 @@ LearnStack runs **two Keycloak realms** per General rules: - OIDC. No handwritten password code; no handwritten token rotation. -- Refresh tokens stored as `HttpOnly`, `Secure`, `SameSite=Lax` cookies; never - accessible to JS. +- Refresh/access/ID tokens stay in the native BFF session owner. Browser cookies + contain only opaque handles/proofs under the tenant BFF rules below. - Access tokens short-lived (≤ 1 hour) and refreshed silently by the BFF. - MFA enrollment supported for tenant-admin roles; required for platform-admin **and** for every Hub operator account. @@ -188,8 +192,7 @@ after both independent reviews; its owns local proof/review closure and source-bound qualification receipts. The required CI checks qualify their exact source and assignment. ADR-0064's one-hour API acceptance ceiling and 30-second skew remain separate from issuance. -P02b-7 owns BFF idle/absolute lifetimes and refresh custody; that part of G13 -remains Open. +ADR-0070 owns BFF custody/lifetimes; P02b-7 delivers that Accepted contract. The pinned provider also copies the access-token expiration into generated ID tokens, including shorter session-clamped issuance. ID tokens remain identity @@ -206,6 +209,91 @@ session-clamping coverage for shorter late-session issuance and independent mutants with wrong expiration or configuration. Existing tokens retain their API acceptance bound; a shorter issuance setting does not revoke them immediately. +### Tenant BFF sessions + +[ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md), Accepted 2026-10-23, +selects openid-client and native single-process custody behind `BffSessionStore`. +P02b-7's five-step package owns implementation; acceptance alone enables no route. +Use 256-bit random opaque `__Host-` session cookies: HttpOnly, Secure, +SameSite=Lax, Path=/, no Domain. Credentials and raw profiles enter no browser +projection. Verified host, browser binding, epoch and server state are required. +The delivered opaque session/browser cookie format uses an eight-hour Max-Age; +the standalone form-CSRF and login/logout proof cookies use 300 seconds, with +Max-Age zero for an authorized deletion. These browser retention bounds never +extend server idle, provider or absolute authority and do not make restart +survivable. The real cookie controls compare emitted headers, not browser defaults. + +- Idle is 30 minutes, absolute eight hours; effective expiry also respects the + provider refresh deadline. Response owners refresh expired/near-expiry access, + touch qualifying activity and publish one revision. `auth()` is read-only. + Explicit extension refreshes the provider; a timestamp alone does not extend it. + The safe projection includes effective expiry and whether the known absolute + deadline limits it. UI never promises extension at that maximum or when the + provider returns an unchanged deadline. Full document navigation and the + protected-document history veil require fresh network admission on return. +- Retain 1,024 total owners, including unsettled work; 128 pending for five minutes, + 16 pending per verified visitor IP, eight session/logout-return owners per + validated issuer/subject. Same-browser replacement settles old work first. + No live other-device eviction or capacity bypass; source/subject refusal is + 429 and global capacity refusal is 503. Full expiry scans precede global refusal. + AccountSite also remains subject to the API's shared peer/visitor limits under + ADR-0064; neither session capacity nor a subject allowance grants independent + end-to-end throughput to a host or browser. +- Revocation/epochs fence late callbacks and refresh. Unknown/mismatched state + changes no cookies; verified failure clears only its own transaction cookie. + Logout revokes locally first, discards tokens after URL construction and keeps + only its bounded return proof/accounting. Late cookie delivery can force sign-in. +- [ADR-0071's status table](../decisions/0071-uncertain-account-site-refusals.md#decision) + distinguishes ordinary checks from new revision publication. An uncertain + ordinary 401 or other availability failure retains a live owner without idle + touch and denies checked authenticated content. 403/404 and actor/scope + conflicts revoke; a masked host/hop configuration refusal can therefore close + a session too. Callback publication always requires positive API authority; + every failed post-refresh check revokes. A valid API 401 produces BFF POST + 503/`provider_refused` for callback/extension, not a retryable-grant promise. + Public pages remain anonymous; unavailable protected checked reads return 503. + Passive projections describe local state only. Genuine 401 revocation can be + delayed; retention does not extend the existing refresh threshold or deadline. + The [route failure classification](../decisions/0070-owned-tenant-bff-sessions.md#amendment-3--implementation-controls-and-diagnostic-scope-2026-10-24) + separates malformed input from unavailable upstream/server work; it does not + change grant-publication or revocation rules. +- Return destinations are the closed local set `/`, `/studio` and `/portal`. + No browser URL, unverified Host/forwarded header, provider error or callback + query chooses the issuer, API origin, provider endpoint or post-logout authority. + Callback/logout origins use only the independently verified native host/port. + Fixed discovery/token/JWKS purposes use the private issuer, no redirects, one + operation-wide deadline through body consumption and a decoded-byte bound. +- Only the native AccountSite caller attaches its owned bearer to the fixed + authenticated endpoint. The public caller stays credential-free; neither + forwards browser Authorization or tenant/organization selectors. Source + admission uses the verified socket provenance, not a browser-supplied address. + Proxy deployment requires Phase 11's provenance qualification first. +- BFF diagnostics emit only their closed lifecycle/failure categories. Do not + expose raw provider exceptions, responses, URLs, subjects, handles or tokens. + A category such as `provider_refused` is an observation, not a causal verdict, + proof of settled cleanup or a durable application session-audit record. +- Explicit BFF opt-in validates issuer/secret together. Disabled mode keeps public + requests independent of Keycloak; auth paths are 404 and protected shells 503. + Restart signs sessions out. Multiple web processes require Phase 11's distributed + adapter and actual refresh/revocation/interruption qualification first. +- Before importing Next, the native process closes its raw development URL trace + sink; Next configuration also disables automatic request/fetch logging. This + suppresses framework performance events for public development requests too, + while preserving separate build traces and API/business telemetry. A real + enabled-development callback/logout canary scans console and trace artifacts; + removing the trace guard must fail even with console logging disabled. + +Tenant **development** provider events use explicit LOGIN/LOGIN_ERROR, +LOGOUT/LOGOUT_ERROR, CODE_TO_TOKEN/CODE_TO_TOKEN_ERROR and +REFRESH_TOKEN/REFRESH_TOKEN_ERROR storage. Merge the existing effective selection; +preserve listeners/admin policy. Fresh/default-empty retention is seven days. +Warm adoption requires bounded empty-history proof; preserve finite retention +of at least seven days, refusing shorter/indefinite/ambiguous custom policy before +any write. Separate readback and zero-write rerun are mandatory. Provider records +contain personal data, stay out of raw artifacts and are not an atomic BFF ledger. +[Audit 18](18-audit-coverage.md#operation-types) owns that narrow audit boundary; +Phase 11 owns production retention/capacity/security qualification. + ## Ingress / Gateway (APISIX) All tenant-facing traffic enters through **APISIX** in standalone mode per @@ -619,8 +707,10 @@ and redirect/query rules. These are the ongoing security obligations: P02d-5 delivers this boundary and ADR-0054's accounting, native admission and URL controls. The [remediation record](../roadmap/phase-02d-delivery-log.md#p02d-5-external-review-remediation-2026-10-09) owns execution, independent reviews and PR closeout evidence. -Every native HTTP path, including matcher exemptions, admits GET/HEAD only; -production upgrades close and development retains only validated GET HMR. See +Native paths, including matcher exemptions, admit GET/HEAD; ADR-0070 adds only +the three exact proof-bearing POST actions named in Frontend Standards. +Other methods remain masked. Production upgrades close and development retains +only validated GET HMR. See [Frontend Standards § Native Admission and URL Identity](07-frontend-architecture.md#native-admission-and-url-identity) for the native controls and their delivery/review boundary. @@ -656,8 +746,20 @@ for the native controls and their delivery/review boundary. ## CSRF - Cookie sessions use `SameSite=Lax`. -- Server Actions verify the Auth.js session implicitly. -- Mutating fetches from JS include a CSRF token bound to the session. +- BFF sign-in POST requires exact Origin and a random host-only double-submit + proof; authenticated sign-out/extension requires exact Origin and a random + constant-time checked session-bound token. Apply admission before allocation. +- Sign-in HTML uses `same-origin` referrer policy so native forms retain that + Origin proof. Provider redirects and the ID-token-hint logout exception retain + `no-referrer`. Missing or `null` Origin remains a refusal. +- The cross-origin provider callback instead requires its single-use state, + nonce, PKCE verifier and host/browser/epoch-bound transaction cookie. +- Auth cookie families and decoded forms each have a 16 KiB ceiling. Native + auth POST admission requires a bounded Content-Length and no transfer encoding + before Next can clone a body; the form parser independently bounds actual bytes, + UTF-8, declared scalar fields and duplicates. +- Only the exact auth POST set is open. SameSite and implicit framework checks + authorize neither sibling origins nor Server Actions. - Webhook endpoints verify signatures, not CSRF tokens. ## CORS diff --git a/docs/standards/12-infrastructure.md b/docs/standards/12-infrastructure.md index f0838d36..c2dc2687 100644 --- a/docs/standards/12-infrastructure.md +++ b/docs/standards/12-infrastructure.md @@ -255,8 +255,8 @@ See [10-observability.md](10-observability.md). either case; direct `VaultClient` usage is forbidden. - Local: `.env` (not committed) — sufficient for `Development` mode. - P02b-6's development realm uses `KEYCLOAK_WEB_CLIENT_SECRET` as a private - import/reconciliation source; P02b-7's BFF is its later runtime consumer. - Never pass it or Keycloak admin credentials to the API, public renderer or + import/reconciliation source and the enabled P02b-7 native BFF runtime source. + Never pass it or Keycloak admin credentials to the API, public-only process or .NET Seeder. Existing confidential secrets are verified, never silently rotated. The [accepted package](../roadmap/phase-02b-delivery-log.md#p02b-6-decision-package-2026-10-20) owns bounded tooling, selected-target validation and the no-credential-output diff --git a/docs/standards/16-accessibility.md b/docs/standards/16-accessibility.md index 84d840ef..f464fb10 100644 --- a/docs/standards/16-accessibility.md +++ b/docs/standards/16-accessibility.md @@ -4,6 +4,8 @@ **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-delivery-log.md#p02d-6-decision-package-2026-10-09). +Session-warning behavior follows +[ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md#effective-lifetime-activity-and-refresh). LearnStack targets **WCAG 2.2 AA** across all user-facing surfaces. @@ -92,6 +94,19 @@ LearnStack is an education platform; learners with disabilities are a first-clas - Auto-logout warnings appear with sufficient lead time and an option to extend. - Long-form learning content does not impose hard time limits unless pedagogically required. +- The current BFF shows its two-minute warning against effective expiry and + offers explicit extension through the response owner. Announce the actual + result; an unchanged provider deadline or absolute maximum is not a successful + extension. Passive polling never keeps the session alive. +- Announce warning/progress/recovery without stealing external focus. At expiry + or failed verification, withhold protected content while preserving sign-out + and a closed reauthentication destination. Interactive retry must not restore + protected content before a positive result. Without JavaScript, show truthful + extension guidance and retain working sign-in/sign-out navigation/forms. +- [WCAG 2.2.1 Timing Adjustable](https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html) + governs time-limit evaluation. A warning and extension control alone do not + establish conformance: provider/absolute limits, assistive technology and the + complete authenticated journey require Phase 06's browser/manual qualification. ## Tooling diff --git a/docs/standards/18-audit-coverage.md b/docs/standards/18-audit-coverage.md index 9b03f6f8..1e4015b6 100644 --- a/docs/standards/18-audit-coverage.md +++ b/docs/standards/18-audit-coverage.md @@ -1,7 +1,8 @@ # 18 — Audit Coverage Standards **Status:** Active -**Derives from:** [ADR-0033 Audit Durability Model](../decisions/0033-audit-durability-model.md) +**Derives from:** [ADR-0070 Owned Tenant BFF Sessions](../decisions/0070-owned-tenant-bff-sessions.md), +[ADR-0033 Audit Durability Model](../decisions/0033-audit-durability-model.md) (supersedes [ADR-0016](../decisions/0016-audit-log-subsystem.md), which remains the subsystem's context), [ADR-0044 The Audit Write Path](../decisions/0044-audit-write-path.md), @@ -53,6 +54,17 @@ document is the rule that prevents the audit story from drifting. ## Operation Types +**Tenant BFF exception — ADR-0070, Accepted 2026-10-23.** Provider authentication +events are stored by the tenant development Keycloak realm under +[Security 11](11-security.md#tenant-bff-sessions), not written through a new +LearnStack session-audit API. Local expiry, unreceived refresh and local logout +during provider outage have only closed lifecycle diagnostics until Phase 03's +authenticated identity-event ingress/mirror. This explicitly scopes the generic +login/token-revocation examples below; no durable local BFF ledger or atomic +provider/BFF commit is promised. Existing LearnStack MUST operations, baseline +coverage and tenant override limits remain unchanged. Seven-day development +provider retention is not the application's security-event retention. + This standard uses **OperationType** to mean *what kind of operation produced the audit row* — distinct from `OperationClass` in [ADR-0016](../decisions/0016-audit-log-subsystem.md), which carries the MUST/SHOULD/MAY audit-coverage tier. Both fields live on diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index 1be94a10..126ebaea 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -1,7 +1,8 @@ # 20 — Infrastructure Stack Standards **Status:** Active -**Derives from:** [ADR-0038 Cross-Cutting Port and Event Contracts](../decisions/0038-cross-cutting-port-and-event-contracts.md), +**Derives from:** [ADR-0070 Owned Tenant BFF Sessions](../decisions/0070-owned-tenant-bff-sessions.md), +[ADR-0038 Cross-Cutting Port and Event Contracts](../decisions/0038-cross-cutting-port-and-event-contracts.md), [ADR-0015 API Gateway: APISIX](../decisions/0015-api-gateway-apisix.md), [ADR-0019 LearnStack Hub](../decisions/0019-learnstack-hub.md), [ADR-0020 Triple Deployment + Hybrid License](../decisions/0020-triple-deployment-hybrid-license.md), @@ -50,6 +51,12 @@ root. The broader operational rules (containers, CI/CD, DB ops, observability) l [12-infrastructure.md](12-infrastructure.md); the two standards are complementary, not overlapping. +The tenant BFF uses app-local `BffSessionStore` with `InMemoryBffSessionStore` +(P02b-7) under [ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md). +Phase 11 owns a distributed adapter before multiple native web processes must +serve one session; atomic refresh/revocation/interruption proofs gate that swap. +This credential owner is separate from `ICacheService` and the public request holder. + ## Demand-Gated Building Blocks **Read this section before the rest of the document.** Most of what follows describes diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 00a5a668..bfd7a943 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -1,7 +1,8 @@ # 21 — Architecture Tests + Analyzers Catalogue **Status:** Active -**Derives from:** [ADR-0032 Exception Handling, Logging, and Observability Architecture](../decisions/0032-exception-handling-logging-and-observability.md) +**Derives from:** [ADR-0070 Owned Tenant BFF Sessions](../decisions/0070-owned-tenant-bff-sessions.md), +[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), @@ -187,8 +188,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. -**231 rules in this catalogue are Implemented, and 133 of them are in that assembly.** -The other 98 are no less binding, and most could not live there. The table says where +**235 rules in this catalogue are Implemented, and 133 of them are in that assembly.** +The other 102 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. @@ -4551,6 +4552,13 @@ structural test proves — and what it does not. Existing Implemented status and rule counts remain unchanged. - **Phase:** P02b-6 Steps 1/4. +- **ADR-0070 Accepted extension — 2026-10-23, implemented:** Actual + fresh development issuance includes the exact selected login/logout/code/ + refresh success/error event set. `TenantRealmExportTests` validates the + committed configuration; `TenantBffSessionTests` exercises stored events from + actual grants, refresh and logout. The development retention limit is not an + application-audit guarantee. Existing Implemented subjects/counts remain. + #### `Development_Realm_Reconciliation_Preserves_Accounts` - **Asserts:** the actual shared development reconciler converges fresh and @@ -4616,6 +4624,14 @@ structural test proves — and what it does not. Existing Implemented status and rule counts remain unchanged. - **Phase:** P02b-6 Steps 2–4. +- **ADR-0070 Accepted extension — 2026-10-23, implemented:** Actual + warm event reconciliation preserves effective prior selection, listeners/admin + policy and qualified retention. `scripts/development-realm-events.test.mjs` + independently requires all eight types and refuses missing readback; actual + `TenantBffSessionTests` exercises warm convergence and the zero-write rerun. + Conflicts refuse before preparation/ordinary mutation. Existing Implemented + subjects/counts remain unchanged. + #### `Api_Registers_Only_The_Tenant_Realm_Authority` - **Asserts:** the composition root registers exactly one tenant bearer scheme, @@ -5286,14 +5302,18 @@ owns execution, review and manual evidence. 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. + ADR-0070 extends the same catalogue/ICU/callsite checks to the app-local `auth` + namespace; only translated labels, never complete catalogues, cross to clients. +- **Source:** ADR-0027; Accepted P02d-6 G39; ADR-0070. - **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). + `PublicServerRenderingTests` production build, P02d-6. The ADR-0070 extension + executes `frontend/apps/web/src/i18n/auth-catalogues.test.ts` and the same + checked helpers, P02b-7 Step 4. +- **Phase:** 02d (P02d-6); 02b (P02b-7 extension). ### `Public_Ui_Locale_Does_Not_Change_Content_Admission` @@ -5561,6 +5581,97 @@ C# architecture-test methods or a full WCAG/production-performance claim. - **Status:** **Implemented** — named Node proof and planted controls above. - **Phase:** 02d (P02d-7 Steps 1, 3 and 4). + +## P02b-7 tenant BFF obligations + +The maintainer accepted [ADR-0070](../decisions/0070-owned-tenant-bff-sessions.md) +and the five-step package on 2026-10-23. Step 5 delivers the complete local +controls below, including actual warm-provider/API and separate HTTPS browser +evidence, failure cleanup and nonzero-shutdown counters, and the stopped-provider +public demo. The phase Status and dated correction records own current review +and exact-source hosted qualification; local implementation alone claims no +hosted timing fit. Prior receipts do not qualify subsequent source changes. + +#### `Bff_Credentials_And_Projections_Are_Confined` + +- **Asserts:** only the native owner/RP adapter holds credentials; public wrappers, + browser bodies/Flight/session projections, diagnostics and non-web environment + projections contain none. Exact AccountSite validates actor/scope independently; + native observation exposes scalar logical occupancy only, never credential + values, snapshots or a heap-zeroization claim. Browser-storage controls cover + IndexedDB and Cookie Store; closed response-hook census includes not-found and + instrumentation entries. +- **Source:** ADR-0070/0071; Security 11 § Tenant BFF sessions. +- **Type:** frontend source/production canaries. **Kind:** structural. +- **Test owner:** `frontend/apps/web/src/test/bff-boundaries.test.ts` and existing + public boundary controls. +- **Status:** **Implemented** — frontend boundary, native protocol/canary and + fixture-output controls named above. +- **Phase:** P02b-7 Steps 1/3–5. + +#### `Bff_Session_Refresh_And_Revocation_Have_One_Owner` + +- **Asserts:** one shared native store owns global/source/subject credits, + transactions/epochs, single-flight refresh, effective expiry, true extension, + replacement/revocation and actual settlement. Expiry/stop/late work never + resurrect a session or release an unsettled owner's credit. Logout-return + owners retain subject credit; full expired-state scans reclaim settled slots. + Independent clocks/provider windows bind lifetime and activity. Ordinary + uncertain 401 retains without idle touch, while callback and failed refreshed + package publication still refuse/revoke under ADR-0071. Independent long-provider + windows isolate every availability category's no-idle-touch rule; publication + clocks, caller cancellation and once-only local expiry/revocation diagnostics + have separate controls. Session CSRF and logout state/proof remain independently + random, rather than aliases of another capability. +- **Source:** ADR-0070/0071; Security 11 § Tenant BFF sessions. +- **Type:** unit and actual response-owning routes. **Kind:** behavioural. +- **Test owner:** `frontend/apps/web/src/server/auth/session-store.test.ts` and + `frontend/apps/web/src/test/bff-session-routes.test.ts`, with composed caller/ + middleware controls in `src/server/auth/account-site-policy.test.ts` and real + protocol cancellation in `src/server/auth/session-http-lifetime.test.ts`. +- **Status:** **Implemented** — actual owner/route controls above and + `TenantBffSessionTests` real refresh/maximum and shutdown/failure controls. +- **Phase:** P02b-7 Steps 2–5. + +#### `Bff_Auth_Admission_And_Csrf_Are_Closed` + +- **Asserts:** only the approved method/path/field set reaches fixed-provider + dispatch after native provenance, bounded input and the appropriate Origin/ + transaction/session-CSRF proof. Unknown hosts/state and disabled mode allocate + no session/provider work. Read-only RSC, HEAD, assets/prefetch/passive reads + perform no idle/refresh/cookie writes. Independent negatives reach real guards. + The exact matcher/config/framework entry set, Origin for every POST and raw + logout body suppression have independent dirty controls, including implicit + write/end and a foreign origin with the same logout path. Fixed native port, + POST return paths and exact provider/PKCE/logout wire parameters have isolated + controls; fake-transport assertions run outside production exception conversion. +- **Source:** ADR-0070; ADR-0054 dated auth extension; Security 11 § CSRF. +- **Type:** native listener and actual Next route controls. **Kind:** behavioural. +- **Test owner:** `frontend/apps/web/src/test/bff-auth-admission.test.ts` and + `frontend/apps/web/src/test/bff-session-routes.test.ts`, with source census in + `public-boundaries.test.ts` and raw TCP observation in + `src/server/auth/logout-response.test.ts`, plus `src/server/ingress.test.ts` + and `src/server/auth/provider.test.ts`. +- **Status:** **Implemented** — native input/route controls above and the actual browser + form/Origin and no-network startup cases in `TenantBffSessionTests`. +- **Phase:** P02b-7 Steps 3–5. + +#### `Bff_Host_Cookies_And_History_Are_Isolated` + +- **Asserts:** both retained HTTPS seed hosts actually store/send their own + opaque cookies and cannot reuse another host's session/transaction; logout and + browser history do not restore protected display. A separate real warm + provider/BFF/API chain proves sign-in/AccountSite/refresh/extension/logout and + stored provider events. Stopped-provider public rendering remains independent. +- **Source:** ADR-0070; Security 11 § Tenant BFF sessions. +- **Type:** native HTTPS browser and Docker integration. **Kind:** behavioural. +- **Test owner:** `frontend/apps/web/scripts/verify-bff-session.mjs`, + `frontend/apps/web/scripts/verify-bff-browser.mjs` and backend + `TenancyIdentity/TenantBffSessionTests.cs` using the app-role API/database. +- **Status:** **Implemented** — `TenantBffSessionTests`, its real-chain/browser scripts + and the stopped-provider public-demo lifecycle. +- **Phase:** P02b-7 Step 5; Phase 06 retains full browser OIDC end-to-end expansion. + ## P02b-4 background-job obligations Source: [Accepted ADR-0047](../decisions/0047-transactional-background-job-runtime.md#architecture-tests) diff --git a/docs/standards/README.md b/docs/standards/README.md index 2b42db5f..0c729109 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -114,7 +114,7 @@ included. | 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** | P02b-5 delivers tenant bearer authentication and the closed AccountSite boundary; its [merge closeout](../roadmap/phase-02b-delivery-log.md#p02b-5-merge-and-closeout-2026-10-20) owns final verification. The isolation half 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 — 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. BFF sessions remain P02b-7; application membership and permission enforcement belong to [Phase 03](../roadmap/phase-03-identity-admin.md). Three sections retain the document's enforcement carve-out: § Transport, § HTTP Headers and § CORS — HSTS, `nosniff`, CSP and the production origin policy remain [Phase 11 § Secure headers](../roadmap/phase-11-production-hardening.md) work, at APISIX and in the ASP.NET layer beside it. | +| 11 | [Security](11-security.md) | **Active** | P02b-5 delivers tenant bearer authentication and the closed AccountSite boundary; its [merge closeout](../roadmap/phase-02b-delivery-log.md#p02b-5-merge-and-closeout-2026-10-20) owns final verification. The isolation half 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 — 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. [Tenant BFF sessions](11-security.md#tenant-bff-sessions) have their own custody and admission boundary; application membership and permission enforcement belong to [Phase 03](../roadmap/phase-03-identity-admin.md). Three sections retain the document's enforcement carve-out: § Transport, § HTTP Headers and § CORS — HSTS, `nosniff`, CSP and the production origin policy remain [Phase 11 § Secure headers](../roadmap/phase-11-production-hardening.md) work, at APISIX and in the ASP.NET layer beside it. | | 12 | [Infrastructure](12-infrastructure.md) | **Active** | Compose stack, `Makefile`, CI workflow, pre-commit hooks and secret scanning all live since Phase 01. | | 13 | [Documentation](13-documentation.md) | **Active** | Governs this corpus; the CI link audit walks changed Markdown. | | 14 | [Git Workflow](14-git-workflow.md) | **Active** | Conventional Commits, hooks and required checks are live. Two branch-protection settings — `Require approvals` and `Do not allow bypassing` — are **deferred by maintainer decision (2026-08-10)** while the repository has one active contributor; the trigger and what activating them involves are recorded in [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md). P02d-1 repaired and re-read the live required-check list on 2026-09-14: all five active jobs, including the current meta name and Docker integration, are required with GitHub Actions attribution and strict up-to-date checking. P02d-4 activates the sixth `openapi diff` context after a successful real job and verifies every other setting unchanged. P02d-7 activates the seventh `public demo + lighthouse` context after complete real PR success and verifies all six prior contexts, strict mode and every unrelated setting preserved; its [CI and exit closeout](../roadmap/phase-02d-delivery-log.md#p02d-7-ci-and-phase-exit-closeout-2026-10-11) records the receipts. [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) owns the current list; the [P02d-4 delivery record](../roadmap/phase-02d-delivery-log.md#p02d-4-step-4-contract-sdk-and-ci) records activation verification. | diff --git a/frontend/README.md b/frontend/README.md index f41e845e..8037f4f9 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -38,6 +38,9 @@ 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. +Optional tenant sign-in and its private opt-in are documented in the +[web setup](apps/web/README.md#optional-tenant-sign-in). Public-only operation is +the default; realm configuration alone does not activate a browser session. Direct stock `next dev/start` is unsupported. P6 implements localized public catalog/course/lesson pages; its [closeout](../docs/roadmap/phase-02d-delivery-log.md#p02d-6-step-4-product-proof-and-accessibility-closeout) records completed reviews and passing manual accessibility. P6 is complete and diff --git a/frontend/apps/web/.env.local.example b/frontend/apps/web/.env.local.example index 1313717a..8493f708 100644 --- a/frontend/apps/web/.env.local.example +++ b/frontend/apps/web/.env.local.example @@ -9,4 +9,10 @@ LEARNSTACK_PUBLIC_HOP_SECRET= LEARNSTACK_PUBLIC_TLS_CERT=.data/tls/public.pem LEARNSTACK_PUBLIC_TLS_KEY=.data/tls/public-key.pem -# OIDC/session and LiveKit client configuration remain Phase 02b / Phase 08c. +# Optional BFF projection: omit this entire group for the default public-only mode. +# When enabling it, match root/process values, including the existing client +# secret, and protect both files with chmod 600. Never use NEXT_PUBLIC_ keys. +# LEARNSTACK_BFF_ENABLED=true +# LEARNSTACK_BFF_ISSUER=http://localhost:8080/realms/learnstack +# KEYCLOAK_WEB_CLIENT_SECRET= +# LiveKit client configuration remains Phase 08c. diff --git a/frontend/apps/web/README.md b/frontend/apps/web/README.md index e317ee0f..54917377 100644 --- a/frontend/apps/web/README.md +++ b/frontend/apps/web/README.md @@ -12,13 +12,93 @@ 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)/[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+ | +| 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/healthz` | Exact infrastructure health route | 02a | + +The closed `/api/auth/*` protocol runs in native middleware under ADR-0070; +there is no general `/api/*` proxy or browser-to-backend bearer forwarding. + +## Optional tenant sign-in + +First complete the root [HTTPS Quickstart](../../../README.md#quickstart), start +the selected infrastructure with `make dev`, and converge it with `make seed`. +Keep the API running through `make public-api`. On the default development +stack, the BFF issuer is `http://localhost:8080/realms/learnstack`; it must exactly +match the tenant API's configured issuer, including scheme, port and path. A +different selected provider port needs the same explicit change in both owners. + +[ADR-0070](../../../docs/decisions/0070-owned-tenant-bff-sessions.md) selects a +native openid-client session owner. Set `LEARNSTACK_BFF_ENABLED=true`, a canonical +`LEARNSTACK_BFF_ISSUER` in `/realms/learnstack`, and the existing confidential +`KEYCLOAK_WEB_CLIENT_SECRET`. Root `.env`, optional web `.env.local` and process +values must agree. To enable sign-in, change the root example's existing `false` +to `true` and make every supplied process/web projection match; a web value does +not override the root source. The optional web `.env.local` is unnecessary and +can be omitted. Enabled mode requires regular, single-link, owner-private +source files (`chmod 600`); startup never repairs permissions or rotates secrets. +Existing source files must always be regular, nonsymlink, valid bounded files, +including in public-only mode; unsafe input is refused rather than ignored. +Next may load `.env.local` values into its server process environment. That file +does not hide a secret from server code; never use a `NEXT_PUBLIC_` alias or pass +credentials into client configuration. +`make public-web` permits a loopback HTTP development issuer. Production web +requires HTTPS and normal certificate verification. Both modes discover the +provider lazily, without a startup network dependency. + +The native process suppresses Next's automatic request/fetch logs and development +performance trace events before importing the framework. This covers public +requests too: raw callback/logout query values must not enter `.next/trace` or +console output. The separately invoked production build keeps its build traces; +API/business telemetry is unchanged. Requalify the pinned threshold seam and its +real development canary/counter-control whenever Next changes. + +Absent/false opt-in keeps the anonymous renderer independent of Keycloak. Auth +paths then return 404; `/studio` and `/portal` return a localized unavailable +document with 503. Enabled mode exposes the closed `/api/auth` actions in native +middleware and requires a session for those two shells. The shells show the +read-only AccountSite projection, not future authoring or role permissions. + +The browser receives only host-only opaque cookies and a safe session projection. +The warning uses the actual effective expiry; an explicit extension refreshes the +provider and reports an unchanged deadline honestly. Logout revokes locally first; +other institution tabs sharing provider SSO may need to sign in again. Ordinary +document anchors and a pre-navigation history veil require fresh admission when +returning to a protected document. `TenantBffSessionTests` owns the real warm +Keycloak/native BFF/application-role API journey and the separate actual HTTPS +browser cookie/history proof. The browser case uses fake grants/API, observes +natural persisted restores separately and kills a copied history-reload removal +with an explicit persisted-event control. It does not claim a full browser OIDC +journey, a universal browser cache or a new assistive-technology result. + +The guarded frontend test entry compiles current native web and authenticated SDK +sources first. Docker execution uses the same explicit preparation, then builds +the copied current native and Next sources in its owned temporary directory. +Fixture IPC carries private configuration and responses only between owning test +processes; retained results contain closed outcomes, counts and timings. No +application test route, provider transport override or system trust installation +is added. + +To return to public-only operation, set `LEARNSTACK_BFF_ENABLED=false` in every +supplied configuration source and restart the native web process. This removes +the volatile sessions; it does not undo development provider-event storage or +other reconciled realm settings. + +Restart signs users out. One native process owns the session store; deploying +multiple web instances requires the distributed adapter and qualification owned +by Phase 11. [Security 11](../../../docs/standards/11-security.md#tenant-bff-sessions) +owns limits, custody and ongoing rules. + +The qualified environment uses the private native listener and pinned tenant +development realm. Accepting an HTTPS issuer in a production web build is a +transport requirement, not a production-readiness claim; Phase 11 owns that +qualification. Pending sign-in limits count canonical verified visitor IPs, +not IPv6 prefixes or people. Different addresses can fill the global allowance; +a reverse proxy sharing one socket peer can instead share one source allowance. 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). @@ -57,7 +137,9 @@ primitives or ask LearnStack to add a new composite. ```bash # from the repository root, after the documented hosts/certificate preparation make demo # production API + native web; Ctrl+C joins owned processes -# Optional separate development/watch terminals: make public-api / make public-web +# For the default HTTP development issuer, use separate terminals: +# make public-api +# make public-web ``` Follow the root [HTTPS Quickstart](../../../README.md#quickstart) for dependencies, diff --git a/frontend/apps/web/next.config.ts b/frontend/apps/web/next.config.ts index 6ce8758d..50939f21 100644 --- a/frontend/apps/web/next.config.ts +++ b/frontend/apps/web/next.config.ts @@ -4,11 +4,22 @@ import createNextIntlPlugin from 'next-intl/plugin'; const nextConfig: NextConfig = { reactStrictMode: true, poweredByHeader: false, + // Callback/logout queries must never enter automatic request/fetch logs. + // The native launcher separately closes the pinned development trace sink. + logging: false, images: { unoptimized: true }, transpilePackages: ['@learnstack/ui', '@learnstack/sdk'], skipMiddlewareUrlNormalize: true, skipTrailingSlashRedirect: true, experimental: { serverComponentsHmrCache: false }, + webpack(configuration) { + // Shared native/Next modules use NodeNext .js specifiers for emitted files. + configuration.resolve.extensionAlias = { + ...configuration.resolve.extensionAlias, + '.js': ['.ts', '.tsx', '.js'], + }; + return configuration; + }, }; export default createNextIntlPlugin('./src/i18n/request.ts')(nextConfig); diff --git a/frontend/apps/web/package.json b/frontend/apps/web/package.json index 86a4ffe8..146e8f3e 100644 --- a/frontend/apps/web/package.json +++ b/frontend/apps/web/package.json @@ -6,19 +6,20 @@ "scripts": { "postinstall": "node scripts/ensure-routes-stub.mjs", "dev": "pnpm build:ingress && node scripts/public-server.mjs --dev", - "build:ingress": "tsc --project tsconfig.server.json", + "build:ingress": "pnpm --filter @learnstack/sdk build:native && tsc --project tsconfig.server.json", "build": "pnpm build:ingress && next build", "start": "pnpm build:ingress && node scripts/public-server.mjs", "lint": "pnpm build:ingress && node scripts/lint-web.mjs", - "typecheck": "tsc --noEmit", - "test": "vitest run", + "typecheck": "pnpm --filter @learnstack/sdk build:native && tsc --noEmit", + "test": "pnpm build:ingress && vitest run", "lint:staged": "pnpm build:ingress && node scripts/lint-web.mjs --fix" }, "dependencies": { "@learnstack/sdk": "workspace:*", "@learnstack/ui": "workspace:*", - "next": "15.5.27", + "next": "15.5.28", "next-intl": "4.14.9", + "openid-client": "6.8.8", "react": "19.0.0", "react-dom": "19.0.0", "server-only": "0.0.1" @@ -34,12 +35,13 @@ "@vitejs/plugin-react": "^4.7.0", "autoprefixer": "^10.4.20", "eslint": "^9.16.0", - "eslint-config-next": "15.5.27", + "eslint-config-next": "15.5.28", + "jose": "6.2.13", "jsdom": "^26.1.0", "postcss": "^8.4.49", "tailwindcss": "^3.4.15", "typescript": "5.6.3", - "vite": "6.4.3", - "vitest": "4.1.11" + "vite": "6.4.4", + "vitest": "4.1.12" } } diff --git a/frontend/apps/web/scripts/admission-browser.d.mts b/frontend/apps/web/scripts/admission-browser.d.mts index 0c7ff07f..2c6111c0 100644 --- a/frontend/apps/web/scripts/admission-browser.d.mts +++ b/frontend/apps/web/scripts/admission-browser.d.mts @@ -40,10 +40,15 @@ export type BrowserPhase = | 'browser-shutdown'; export function browserFailureDetail(error: unknown): string; export function atBrowserPhase(phase: BrowserPhase, operation: () => T | Promise): Promise; +export type BrowserPortObservation = + | { readonly kind: 'missing' | 'empty' | 'malformed' | 'unreadable' } + | { readonly kind: 'ready'; readonly port: number }; +export function readBrowserDevToolsPort(path: string): BrowserPortObservation; export function waitForBrowserDevTools(options: { readonly assertActive: () => void; readonly isLive: () => boolean; - readonly readPort: () => number | null; + readonly readPort: () => BrowserPortObservation; + readonly timeout?: number; }): Promise; export function waitForBrowserPage(options: { readonly assertActive: () => void; diff --git a/frontend/apps/web/scripts/admission-browser.mjs b/frontend/apps/web/scripts/admission-browser.mjs index 7861e31f..b5b72dba 100644 --- a/frontend/apps/web/scripts/admission-browser.mjs +++ b/frontend/apps/web/scripts/admission-browser.mjs @@ -25,10 +25,30 @@ const browserPhases = new Set([ 'browser-shutdown', ]); const sanitizedFailures = new WeakSet(); +const devToolsFailures = new WeakMap(); +const devToolsKinds = new Set([ + 'owner-inactive', + 'port-unreadable', + 'deadline-missing', + 'deadline-empty', + 'deadline-malformed', +]); /** Only closed phase names may cross the private fixture's diagnostic boundary. */ export function browserFailureDetail(error) { - return browserPhases.has(error?.admissionStage) ? ' ' + error.admissionStage : ''; + if (!sanitizedFailures.has(error) || !browserPhases.has(error?.admissionStage)) return ''; + const kind = + error.admissionStage === 'browser-devtools' ? devToolsFailures.get(error) : undefined; + return ' ' + error.admissionStage + (devToolsKinds.has(kind) ? ' ' + kind : ''); +} + +function devToolsFailure(kind) { + assert.ok(devToolsKinds.has(kind), 'Browser readiness diagnostic is registered'); + const failure = new Error('Public admission browser proof failed'); + failure.admissionStage = 'browser-devtools'; + sanitizedFailures.add(failure); + devToolsFailures.set(failure, kind); + return failure; } /** Never propagate raw browser errors, URLs, output or private fixture values. */ @@ -109,17 +129,59 @@ async function until(predicate, message, timeout = 20_000) { throw new Error(message); } -/** Keep browser death distinct from a live browser's debugging readiness deadline. */ -export function waitForBrowserDevTools({ assertActive, isLive, readPort }) { - return atBrowserPhase('browser-devtools', () => - until(async () => { - assertActive(); +/** One read avoids a check/read race; neither bytes nor filesystem errors escape. */ +export function readBrowserDevToolsPort(path) { + let value; + try { + value = readFileSync(path, 'utf8'); + } catch (error) { + return { kind: error?.code === 'ENOENT' ? 'missing' : 'unreadable' }; + } + const first = value.split('\n', 1)[0]; + if (first.trim() === '') return { kind: 'empty' }; + if (!/^[1-9][0-9]{0,4}$/.test(first) || Number(first) > 65535) return { kind: 'malformed' }; + return { kind: 'ready', port: Number(first) }; +} + +/** Keep browser death and closed readiness observations distinct, with no extra wait. */ +export function waitForBrowserDevTools({ assertActive, isLive, readPort, timeout = 20_000 }) { + return atBrowserPhase('browser-devtools', async () => { + assert.ok(Number.isSafeInteger(timeout) && timeout > 0 && timeout <= 20_000); + const deadline = Date.now() + timeout; + let last = 'missing'; + const active = async () => { + try { + assertActive(); + } catch { + throw devToolsFailure('owner-inactive'); + } await atBrowserPhase('browser-launch', () => { assert.equal(isLive(), true, 'The owned browser remains live'); }); - return readPort(); - }, 'Owned browser debugging deadline'), - ); + }; + while (Date.now() < deadline) { + await active(); + let observation; + try { + observation = readPort(); + } catch { + throw devToolsFailure('port-unreadable'); + } + if ( + observation?.kind === 'ready' && + Number.isInteger(observation.port) && + observation.port > 0 && + observation.port < 65536 + ) + return observation.port; + if (!['missing', 'empty', 'malformed'].includes(observation?.kind)) + throw devToolsFailure('port-unreadable'); + last = observation.kind; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + await active(); + throw devToolsFailure('deadline-' + last); + }); } /** Empty target lists may precede the page; all discovery shares the original 5 s. */ @@ -262,11 +324,7 @@ async function runBrowserFallback( const port = await waitForBrowserDevTools({ assertActive: () => owner.assertActive(), isLive: () => !failed && child.exitCode === null && child.signalCode === null, - readPort: () => { - if (!existsSync(portFile)) return null; - const value = Number(readFileSync(portFile, 'utf8').split('\n')[0]); - return Number.isInteger(value) && value > 0 && value < 65536 ? value : null; - }, + readPort: () => readBrowserDevToolsPort(portFile), }); phase.value = 'browser-discovery'; const endpoint = await waitForBrowserPage({ diff --git a/frontend/apps/web/scripts/admission-hmr.d.mts b/frontend/apps/web/scripts/admission-hmr.d.mts index 6289c565..dac87ea1 100644 --- a/frontend/apps/web/scripts/admission-hmr.d.mts +++ b/frontend/apps/web/scripts/admission-hmr.d.mts @@ -1,7 +1,10 @@ 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 configuration: { + readonly locale: string; + readonly tenants: readonly { readonly name: string }[]; + }; readonly checkpoint: (name: string) => Promise; readonly startNative: (dev: boolean) => Promise; readonly stopNative: (child: NativeChild) => Promise; diff --git a/frontend/apps/web/scripts/admission-hmr.mjs b/frontend/apps/web/scripts/admission-hmr.mjs index f7dc953c..c222677a 100644 --- a/frontend/apps/web/scripts/admission-hmr.mjs +++ b/frontend/apps/web/scripts/admission-hmr.mjs @@ -18,7 +18,7 @@ export async function verifyHmrAdmission({ 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 path = join(app, 'src/app/(public)/[locale]/courses/page.tsx'); const original = readFileSync(path, 'utf8'); const page = ( marker, @@ -41,7 +41,7 @@ export default async function HmrAdmissionProbe() { const pid = child.pid; await checkpoint('hmr-before'); stage = 'hmr-initial-request'; - const before = await call('/studio'); + const before = await call('/' + configuration.locale + '/courses'); stage = 'hmr-initial-status'; assert.equal(before.status, 200, 'Development admission positive control'); stage = 'hmr-initial-source'; @@ -58,7 +58,7 @@ export default async function HmrAdmissionProbe() { stage = 'hmr-source-barrier'; await watcher.afterWrite(() => writeFileSync(path, page('admission-hmr-after'))); stage = 'hmr-refresh-request'; - const after = await call('/studio', { + const after = await call('/' + configuration.locale + '/courses', { headers: { RSC: '1', 'Next-Hmr-Refresh': '1' }, }); stage = 'hmr-refresh-status'; diff --git a/frontend/apps/web/scripts/bff-proof-host.mjs b/frontend/apps/web/scripts/bff-proof-host.mjs new file mode 100644 index 00000000..b2aff747 --- /dev/null +++ b/frontend/apps/web/scripts/bff-proof-host.mjs @@ -0,0 +1,472 @@ +// Shared test-owned native Next fixture. External mode retains real RP/API dependencies. +import assert from 'node:assert/strict'; +import { spawn, execFileSync } from 'node:child_process'; +import { cpSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { request } from 'node:https'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { createBffCookieScanner, rememberBffCookies } from './bff-proof-support.mjs'; +import { createPrivateScanner, fixtureEnvironment } from './fixture-support.mjs'; +const source = fileURLToPath(new URL('../', import.meta.url)); +function file(path, value) { + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, value); +} +function replace(source, needle, value) { + assert.equal(source.split(needle).length, 2, 'unique instrumentation anchor'); + return source.replace(needle, value); +} + +/** One real child/output path shared by the native proof and its observer control. */ +export function ownBffProofChild({ + owner, + app, + args, + env, + ipc = false, + privateValues, + cookieCanaries, + scanners, +}) { + owner.assertActive(); + const process = owner.ownChild( + spawn(globalThis.process.execPath, args, { + cwd: app, + env, + detached: globalThis.process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe', ...(ipc ? ['ipc'] : [])], + }), + ); + const scanner = createPrivateScanner(privateValues); + const cookieScanner = createBffCookieScanner(cookieCanaries); + scanners.push(scanner, cookieScanner); + for (const stream of [process.stdout, process.stderr]) { + stream.on('data', (chunk) => { + scanner.push(chunk); + cookieScanner.push(chunk); + }); + stream.once('end', () => { + scanner.finish(); + cookieScanner.finish(); + }); + } + process.once('error', () => {}); + return { + process, + scanner, + completed: new Promise((resolve) => + process.once('close', (code, signal) => resolve({ code, signal })), + ), + }; +} +export async function startBffProof({ + owner, + root, + privateValues, + secret, + marker, + devMode = false, + uiMode = false, + mutation, + clockBase = 1800000000000, + external, + fakeIssuer = 'https://identity.example.test/realms/learnstack', + fakeLogout = fakeIssuer + '/protocol/openid-connect/logout', + mark = () => {}, + sites = [ + { host: 'institution.example.test:3000', displayName: 'Institution', locale: 'en' }, + { host: 'other.example.test:3000', displayName: 'Diğer kurum', locale: 'tr' }, + ], +}) { + const app = join(root, 'frontend/apps/web'); + const scanners = []; + // CSRF cookies may appear in their intended HTML/JSON response. They remain + // forbidden in child/browser diagnostics, independently of body projections. + const cookieCanaries = []; + function child(args, env, ipc = false) { + return ownBffProofChild({ + owner, + app, + args, + env, + ipc, + privateValues, + cookieCanaries, + scanners, + }); + } + function get(path, cookie, extra = {}) { + return new Promise((resolve, reject) => { + const call = request( + { + hostname: '127.0.0.1', + port: 3000, + method: extra.method ?? 'GET', + path, + ca: readFileSync(join(root, 'cert.pem')), + servername: 'localhost', + agent: false, + headers: { + host: sites[0].host, + ...(cookie ? { cookie } : {}), + ...extra.headers, + }, + }, + (response) => { + let body = ''; + let bytes = 0; + response.setEncoding('utf8'); + response.on('data', (chunk) => { + bytes += Buffer.byteLength(chunk); + if (bytes > 2 * 1024 * 1024) { + call.destroy(new Error('owned response body limit')); + response.destroy(); + return; + } + body += chunk; + }); + response.once('error', reject); + response.once('aborted', () => reject(new Error('owned response aborted'))); + response.once('end', () => { + try { + rememberBffCookies(response.headers['set-cookie'], cookieCanaries); + resolve({ status: response.statusCode, headers: response.headers, body }); + } catch (error) { + reject(error); + } + }); + }, + ); + // A real callback can traverse three separate ten-second owners: bootstrap, + // provider and AccountSite. This test-client ceiling changes none of them. + call.setTimeout(external ? 35_000 : 10_000, () => + call.destroy(new Error('owned request deadline')), + ); + call.once('error', reject); + call.end(extra.body); + }); + } + mkdirSync(join(app, 'src/server/auth'), { recursive: true }); + mkdirSync(join(app, 'scripts'), { recursive: true }); + for (const name of [ + 'ingress.ts', + 'public-api-limits.ts', + 'public-entry.ts', + 'configured-public-client.ts', + 'public-admission.ts', + 'public-admission-runtime.ts', + ]) + cpSync(join(source, 'src/server', name), join(app, 'src/server', name)); + // This minimal app qualifies the protocol, not the separate product UI. + for (const name of [ + 'account-client', + 'auth', + 'configuration', + 'cookies', + 'http-admission', + 'http-input', + 'http-paths', + 'http-protocol', + 'logout-response', + 'provider', + 'provider-core', + 'provider-error', + 'provider-transport', + 'request-owner', + 'request-runtime', + 'session-contract', + 'session-projection', + 'session-owner', + 'session-runtime', + 'session-store', + ]) + cpSync( + join(source, 'src/server/auth', name + '.ts'), + join(app, 'src/server/auth', name + '.ts'), + ); + if (mutation === 'activity') { + const path = join(app, 'src/server/auth/http-admission.ts'); + file( + path, + replace( + readFileSync(path, 'utf8'), + 'protocol.project(input, bffRequestOwner().activity(context))', + 'protocol.project(input)', + ), + ); + } + symlinkSync(join(source, 'node_modules'), join(app, 'node_modules'), 'dir'); + cpSync(join(source, 'scripts/development-hmr.mjs'), join(app, 'scripts/development-hmr.mjs')); + file( + join(app, 'package.json'), + JSON.stringify({ name: 'learnstack-owned-bff-proof', private: true, type: 'module' }), + ); + file( + join(app, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'ES2022', + lib: ['dom', 'dom.iterable', 'esnext'], + strict: true, + noEmit: true, + module: 'esnext', + moduleResolution: 'bundler', + jsx: 'preserve', + esModuleInterop: true, + skipLibCheck: true, + resolveJsonModule: true, + isolatedModules: true, + plugins: [{ name: 'next' }], + paths: { '@/*': ['./src/*'] }, + }, + include: ['next-env.d.ts', 'src/**/*.ts', 'src/**/*.tsx', '.next/types/**/*.ts'], + exclude: ['node_modules'], + }), + ); + file( + join(app, 'next-env.d.ts'), + '/// \n/// \n', + ); + let nextConfiguration = readFileSync(join(source, 'next.config.ts'), 'utf8'); + if (!uiMode) + nextConfiguration = replace( + nextConfiguration, + "import createNextIntlPlugin from 'next-intl/plugin';", + '', + ); + if (!uiMode) + nextConfiguration = replace( + nextConfiguration, + "export default createNextIntlPlugin('./src/i18n/request.ts')(nextConfig);", + 'export default nextConfig;', + ); + nextConfiguration = replace( + nextConfiguration, + 'serverComponentsHmrCache: false', + 'serverComponentsHmrCache: false, cpus: 2', + ); + file(join(app, 'next.config.ts'), nextConfiguration); + file( + join(app, 'src/app/layout.tsx'), + 'export default function Layout({children}: {children: React.ReactNode}) { return {children}; }', + ); + file( + join(app, 'src/app/studio/page.tsx'), + `import { auth } from '@/server/auth/auth'; +export const dynamic = 'force-dynamic'; export const revalidate = 0; +export default async function Page() { const a = await auth(); const b = await auth(); if (a !== b) throw new Error('request snapshot identity'); return

{a?.state ?? 'anonymous'}

{a?.displayName}

; }`, + ); + file( + join(app, 'src/app/page.tsx'), + 'export default function Page() { return
Public
; }', + ); + file( + join(app, 'src/middleware.ts'), + `import { NextResponse, type NextRequest } from 'next/server'; +import { createConfiguredPublicClient } from '@/server/configured-public-client'; +import { INGRESS_HEADER, verifyNextProvenance } from '@/server/ingress'; +import { beginPublicAdmission } from '@/server/public-admission'; +import { admitBffHttp } from '@/server/auth/http-admission'; +export async function middleware(request: NextRequest) { + const url = new URL(request.url); const envelope = request.headers.get(INGRESS_HEADER); + const context = verifyNextProvenance(envelope, process.env.LEARNSTACK_PUBLIC_HOP_SECRET, { method: request.method, target: url.pathname + url.search }); + if (!context) return new NextResponse('Not found', { status: 404 }); + const admission = beginPublicAdmission(context); const client = createConfiguredPublicClient(envelope, { signal: admission.signal }); + const site = await client!.getSite(); if (site.kind !== 'success') { admission.refuse(); return new NextResponse('Not found', { status: 404 }); } + admission.publish(site.data); + const result = await admitBffHttp(request, context, admission.signal); + if (result.response) return result.response; + const headers = new Headers({ [INGRESS_HEADER]: envelope! }); + const response = NextResponse.next({ request: { headers } }); response.headers.set('cache-control', 'no-store'); return response; +} +export const config = { runtime: 'nodejs', matcher: ['/((?!_next/|favicon\\.ico$).*)'] };`, + ); + if (uiMode) { + rmSync(join(app, 'src/app/studio'), { recursive: true }); + rmSync(join(app, 'src/app/page.tsx')); + cpSync(join(source, 'src'), join(app, 'src'), { + recursive: true, + filter: (path) => !/(?:\.(?:test|spec)\.tsx?$|[/]test(?:[/]|$))/.test(path), + }); + for (const name of ['tailwind.config.ts', 'postcss.config.cjs']) + cpSync(join(source, name), join(app, name)); + } + if (mutation === 'history') { + const history = join(app, 'src/components/auth/history.ts'); + file(history, replace(readFileSync(history, 'utf8'), 'location.reload()', 'void 0')); + } + let launcher = readFileSync(join(source, 'scripts/public-server.mjs'), 'utf8'); + if (mutation === 'diagnostics') + launcher = replace( + launcher, + " process.env.NEXT_TRACE_SPAN_THRESHOLD_MS = '9007199254740991';", + '', + ); + if (mutation === 'shutdown') + launcher = replace(launcher, 'process.exit(failed ? 1 : 0)', 'process.exit(1)'); + if (!external) { + launcher = replace( + launcher, + "import { BffConfigurationError, bffConfiguration } from '../.server/auth/configuration.js';", + "import { BffConfigurationError, bffConfiguration } from '../.server/auth/configuration.js';\nimport { randomBytes } from 'node:crypto';", + ); + launcher = replace( + launcher, + ` const dependencies = { + provider: createTenantProvider(bff, globalThis.fetch), + account: createAccountSiteCaller(configuration, globalThis.fetch), + diagnostic: (reason) => console.warn('BFF session lifecycle', { reason }), + };`, + ` let now = 0; let refreshes = 0; let accounts = 0; + process.on('message', async value => { if (value && value.kind === 'advance' && [0, 60000, 901000].includes(value.milliseconds)) { now += value.milliseconds; await (await import('next/dist/trace/index.js')).flushAllTraces(); process.send?.({ kind: 'snapshot', now, refreshes, accounts }); } }); + const credentials = () => ({ accessToken: '${marker}', refreshToken: '${marker}', idToken: '${marker}', identity: { issuer: ${JSON.stringify(fakeIssuer)}, subject: 'owned-subject', actorId: '019a154b-0000-7000-8000-000000000011', tenantId: '10000000-0000-4000-8000-000000000001', organizationId: null }, accessExpiresAt: now + 900000, refreshExpiresAt: now + 3600000 }); + const dependencies = { provider: { begin: async callbackUri => ({ transaction: { callbackUri, state: randomBytes(32).toString('base64url'), nonce: randomBytes(32).toString('base64url'), verifier: randomBytes(32).toString('base64url') }, redirectUrl: ${JSON.stringify(fakeIssuer + '/protocol/openid-connect/auth')} }), redeem: async () => credentials(), refresh: async () => { ++refreshes; return credentials(); }, logoutUrl: () => ${JSON.stringify(fakeLogout)} }, account: async (_token, binding) => { ++accounts; return ({ actorId: '019a154b-0000-7000-8000-000000000011', tenantId: '10000000-0000-4000-8000-000000000001', organizationId: null, displayName: (${JSON.stringify(sites)}).find(site => site.host === binding.host).displayName }); }, clock: { monotonic: () => now, utc: () => ${clockBase} + now } };`, + ); + } + file(join(app, 'scripts/public-server.mjs'), launcher); + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + join(root, 'key.pem'), + '-out', + join(root, 'cert.pem'), + '-days', + '1', + '-subj', + '/CN=localhost', + '-addext', + 'subjectAltName=DNS:localhost,' + + sites.map((site) => 'DNS:' + site.host.split(':')[0]).join(','), + ], + { timeout: 10_000, stdio: 'ignore' }, + ); + let backend; + if (!external) { + backend = owner.ownServer( + createServer((request, response) => { + assert.equal(request.url, '/api/v1/public/site'); + assert.equal(request.headers.authorization, undefined); + if (!sites.some((site) => site.host === request.headers['x-learnstack-host'])) { + response.writeHead(404, { 'content-type': 'application/problem+json' }); + response.end( + JSON.stringify({ + status: 404, + code: 'not_found', + messageKey: 'lockey_not_found', + correlationId: '00000000-0000-4000-8000-000000000001', + type: 'https://fixture.invalid/not-found', + title: 'Not found', + instance: '/api/v1/public/site', + }), + ); + return; + } + const site = sites.find((site) => site.host === request.headers['x-learnstack-host']); + response.writeHead(200, { + 'content-type': 'application/json', + 'cache-control': 'no-store', + }); + response.end( + JSON.stringify({ + displayName: site.displayName, + enabledLocales: [site.locale], + defaultLocale: site.locale, + theme: null, + showPlatformAttribution: true, + }), + ); + }), + ); + await new Promise((resolve) => backend.listen(0, '127.0.0.1', resolve)); + } + const env = fixtureEnvironment({ + root, + bffValues: { + LEARNSTACK_BFF_ENABLED: 'true', + LEARNSTACK_BFF_ISSUER: external?.issuer ?? fakeIssuer, + KEYCLOAK_WEB_CLIENT_SECRET: external?.clientSecret ?? marker, + }, + privateValues: { + LEARNSTACK_PUBLIC_API_ORIGIN: + external?.apiOrigin ?? 'http://127.0.0.1:' + backend.address().port, + LEARNSTACK_PUBLIC_HOP_SECRET: secret, + LEARNSTACK_PUBLIC_TLS_CERT: join(root, 'cert.pem'), + LEARNSTACK_PUBLIC_TLS_KEY: join(root, 'key.pem'), + }, + }); + if (external) { + file(join(root, 'provider-ca.pem'), external.authority); + env.NODE_EXTRA_CA_CERTS = join(root, 'provider-ca.pem'); + } + // Compile the copied current sources: never let stale workstation output + // qualify the native side while Next compiles different bundled sources. + cpSync(join(source, 'tsconfig.server.json'), join(app, 'tsconfig.server.json')); + mark('compile'); + // The authenticated SDK subpath resolves to dist through the workspace link. + // Qualify standalone proof invocations too, not only callers that built it first. + const sdk = child([join(source, '../../packages/sdk/scripts/build-native.mjs')], env); + assert.equal((await sdk.completed).code, 0, 'owned current-source SDK compilation'); + const native = child( + [join(source, 'node_modules/typescript/bin/tsc'), '--project', 'tsconfig.server.json'], + env, + ); + assert.equal((await native.completed).code, 0, 'owned current-source native compilation'); + mark('build'); + const build = child( + mutation === 'held-build' + ? ['-e', 'setInterval(() => {}, 1000)'] + : [join(source, 'node_modules/next/dist/bin/next'), 'build'], + env, + ); + if (mutation === 'held-build') mark('held-build'); // The owned child exists before the parent may close input. + const buildResult = await build.completed; + if (buildResult.code !== 0 && !external) { + const diagnostics = build.scanner.tail + .split('\n') + .filter((line) => /error|warning|cannot|failed/i.test(line)) + .join('\n') + .replaceAll(secret, '[private]') + .replaceAll(marker, '[private]'); + process.stderr.write(diagnostics.slice(0, 2000) + '\n'); + } + assert.equal(buildResult.code, 0, 'owned Next build'); + const buildTrace = devMode ? readFileSync(join(app, '.next/trace'), 'utf8') : ''; + if (devMode) + assert.ok(buildTrace.length > 0, 'separate build leaves a nonempty real collector control'); + mark('start'); + const server = child( + ['scripts/public-server.mjs', ...(devMode ? ['--dev'] : [])], + devMode ? { ...env, NODE_ENV: 'development' } : env, + true, + ); + const advance = (milliseconds) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('owned IPC deadline')), 5000); + server.process.once('message', (snapshot) => { + clearTimeout(timer); + if (snapshot?.kind !== 'snapshot') reject(new Error('owned IPC shape')); + else resolve(snapshot); + }); + server.process.send({ kind: 'advance', milliseconds }); + }); + const until = Date.now() + 20_000; + while (!server.scanner.tail.includes('Public HTTPS listener ready on 127.0.0.1:3000')) { + assert.equal(server.process.exitCode, null, 'native listener alive'); + assert.ok(Date.now() < until, 'listener startup deadline'); + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return { app, server, advance, get, scanners, cookieCanaries }; +} +export const jar = (response) => + (response.headers['set-cookie'] ?? []) + .map((value) => value.split(';', 1)[0]) + .filter((value) => !value.endsWith('=')) + .join('; '); diff --git a/frontend/apps/web/scripts/bff-proof-support.d.mts b/frontend/apps/web/scripts/bff-proof-support.d.mts new file mode 100644 index 00000000..739b190a --- /dev/null +++ b/frontend/apps/web/scripts/bff-proof-support.d.mts @@ -0,0 +1,19 @@ +export function bffDevToolsFailureStage(error: unknown): string; +export function bffProofCopy(locale: string, key: string): string; +export function assertBffCookieAttributes(headers: readonly string[] | undefined): void; +export function assertBffPrivateOutput(scanners: readonly { readonly leaked: boolean }[]): void; +export function createBffCookieScanner(canaries: string[]): { + push(chunk: string | Buffer): void; + contains(value: string): boolean; + finish(): void; + readonly leaked: boolean; + readonly tail: string; +}; +export function withProofWaiters( + waiters: { readonly [K in keyof T]: Promise }, + action: () => unknown | Promise, +): Promise; +export function rememberBffCookies( + headers: readonly string[] | undefined, + canaries: string[], +): void; diff --git a/frontend/apps/web/scripts/bff-proof-support.mjs b/frontend/apps/web/scripts/bff-proof-support.mjs new file mode 100644 index 00000000..60b043f5 --- /dev/null +++ b/frontend/apps/web/scripts/bff-proof-support.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +import { browserFailureDetail } from './admission-browser.mjs'; +import { createPrivateScanner } from './fixture-support.mjs'; + +/** Preserve only the readiness helper's branded, finite observations. */ +export function bffDevToolsFailureStage(error) { + return ( + new Map([ + [' browser-launch', 'browser-not-live'], + [' browser-devtools owner-inactive', 'browser-devtools-inactive'], + [' browser-devtools port-unreadable', 'browser-devtools-unreadable'], + [' browser-devtools deadline-missing', 'browser-devtools-missing'], + [' browser-devtools deadline-empty', 'browser-devtools-empty'], + [' browser-devtools deadline-malformed', 'browser-devtools-malformed'], + ]).get(browserFailureDetail(error)) ?? 'browser-devtools' + ); +} + +const catalogues = { + en: JSON.parse( + readFileSync(new URL('../src/i18n/messages/en/auth.json', import.meta.url), 'utf8'), + ), + tr: JSON.parse( + readFileSync(new URL('../src/i18n/messages/tr/auth.json', import.meta.url), 'utf8'), + ), +}; + +/** Expectations come from the selected committed catalogue, independently of rendered HTML. */ +export function bffProofCopy(locale, key) { + assert.ok(['en', 'tr', 'tr-TR'].includes(locale), 'closed proof locale'); + assert.ok( + [ + 'signin.continue', + 'shell.studio', + 'shell.account', + 'shell.portal', + 'session.extended', + ].includes(key), + 'closed proof copy', + ); + const [group, name] = key.split('.'); + const value = catalogues[locale === 'en' ? 'en' : 'tr'][group][name]; + assert.ok(typeof value === 'string' && value.length > 0, 'nonempty proof copy'); + return value; +} + +/** Browser cookie jars default absent SameSite to Lax; inspect the actual header too. */ +export function assertBffCookieAttributes(headers) { + for (const header of headers ?? []) { + if (!header.startsWith('__Host-ls-')) continue; + assert.ok(/;\s*Path=\/(?:;|$)/i.test(header), 'owned cookie root path'); + assert.ok(/;\s*HttpOnly(?:;|$)/i.test(header), 'owned cookie HttpOnly'); + assert.ok(/;\s*Secure(?:;|$)/i.test(header), 'owned cookie Secure'); + assert.ok(/;\s*SameSite=Lax(?:;|$)/i.test(header), 'owned cookie explicit SameSite'); + assert.ok(!/;\s*Domain=/i.test(header), 'owned cookie no Domain'); + } +} + +export function assertBffPrivateOutput(scanners) { + assert.equal( + scanners.some((scanner) => scanner.leaked), + false, + 'BFF proof private output', + ); +} + +// Cookie values become known only after a response. Retain the complete bounded +// output until then; overflow refuses the proof instead of losing earlier bytes. +const maximumCookieOutputBytes = 256 * 1024; +export function createBffCookieScanner(canaries) { + const scanner = createPrivateScanner(canaries); + const chunks = []; + let bytes = 0; + let leaked = false; + const observed = () => Buffer.concat(chunks, bytes).toString('utf8'); + const check = () => { + leaked ||= scanner.contains(observed()); + }; + return { + contains: scanner.contains, + push(chunk) { + const copy = Buffer.from(chunk); + if (bytes + copy.byteLength > maximumCookieOutputBytes) { + leaked = true; + return; + } + chunks.push(copy); + bytes += copy.byteLength; + }, + finish: check, + get leaked() { + check(); + return leaked; + }, + get tail() { + return observed(); + }, + }; +} + +/** Attach every rejection handler before starting the action that may fail. */ +export async function withProofWaiters(waiters, action) { + const results = await Promise.all([...waiters, Promise.resolve().then(action)]); + return results.slice(0, waiters.length); +} + +/** Learn only the proof's closed opaque cookie family, never diagnostic text. */ +export function rememberBffCookies(headers, canaries) { + for (const header of headers ?? []) { + const pair = header.split(';', 1)[0]; + const equal = pair.indexOf('='); + const name = pair.slice(0, equal); + const value = pair.slice(equal + 1); + if (!name.startsWith('__Host-ls-') || !value) continue; + assert.match(value, /^[A-Za-z0-9_-]{43}$/); + const transaction = /^(?:__Host-ls-login-|__Host-ls-logout-)([A-Za-z0-9_-]{43})$/.exec(name); + assert.ok( + transaction || ['__Host-ls-session', '__Host-ls-browser', '__Host-ls-csrf'].includes(name), + ); + for (const secret of transaction ? [transaction[1], value] : [value]) { + if (canaries.includes(secret)) continue; + assert.ok(canaries.length < 1024, 'bounded proof cookie canaries'); + canaries.push(secret); + } + } +} diff --git a/frontend/apps/web/scripts/fixture-support.d.mts b/frontend/apps/web/scripts/fixture-support.d.mts index 4859bcbb..39d22c57 100644 --- a/frontend/apps/web/scripts/fixture-support.d.mts +++ b/frontend/apps/web/scripts/fixture-support.d.mts @@ -14,6 +14,7 @@ export function fixtureEnvironment(options: { nodeEnv?: string; nodePath?: string; privateValues?: Record; + bffValues?: Record; debug?: string; }): NodeJS.ProcessEnv; export function createFixtureOwner(options?: { exit?: (code: number) => void }): { @@ -23,5 +24,5 @@ export function createFixtureOwner(options?: { exit?: (code: number) => void }): assertActive(): void; dispose(): Promise; cancel(): Promise; - install(options?: { control?: EventEmitter; event?: 'close' | 'disconnect' }): void; + install(options?: { control?: EventEmitter; event?: 'close' | 'disconnect' | 'end' }): void; }; diff --git a/frontend/apps/web/scripts/fixture-support.mjs b/frontend/apps/web/scripts/fixture-support.mjs index 2bfb19b3..86f8eb52 100644 --- a/frontend/apps/web/scripts/fixture-support.mjs +++ b/frontend/apps/web/scripts/fixture-support.mjs @@ -5,6 +5,10 @@ import { stopTestChild } from './stop-test-child.mjs'; const OUTPUT_BOUND = 65536; const PRIVATE_TOKEN = /(? /x-learnstack-/i.test(value) || + CREDENTIAL_TOKEN.test(value) || PRIVATE_TOKEN.test(value) || secrets.some((secret) => value.includes(secret)); return { @@ -33,6 +38,7 @@ export function createPrivateScanner(secrets = []) { // MAC or add forbidden padding. Exact secrets/carriers need no boundary. leaked ||= /x-learnstack-/i.test(combined) || + CREDENTIAL_TOKEN.test(combined) || secrets.some((secret) => combined.includes(secret)) || PRIVATE_TOKEN.test(combined + '_'); tail = combined.slice(-OUTPUT_BOUND); @@ -54,6 +60,7 @@ export function fixtureEnvironment({ nodeEnv = 'production', nodePath, privateValues = {}, + bffValues, debug, }) { const env = { @@ -74,6 +81,20 @@ export function fixtureEnvironment({ throw new Error('Unsupported fixture environment key or value'); env[key] = value; } + if (bffValues !== undefined) { + const expected = [ + 'KEYCLOAK_WEB_CLIENT_SECRET', + 'LEARNSTACK_BFF_ENABLED', + 'LEARNSTACK_BFF_ISSUER', + ]; + if ( + Object.keys(bffValues).sort().join(',') !== expected.join(',') || + bffValues.LEARNSTACK_BFF_ENABLED !== 'true' || + expected.some((key) => typeof bffValues[key] !== 'string' || !bffValues[key]) + ) + throw new Error('Incomplete fixture BFF environment'); + Object.assign(env, bffValues); + } return env; } diff --git a/frontend/apps/web/scripts/ingress-readiness.mjs b/frontend/apps/web/scripts/ingress-readiness.mjs index 4810f167..56f879f0 100644 --- a/frontend/apps/web/scripts/ingress-readiness.mjs +++ b/frontend/apps/web/scripts/ingress-readiness.mjs @@ -40,7 +40,7 @@ function probeKind(error) { } } -/** Bound only Next 15.5.27's one-argument informational lookup in this fixture. */ +/** Bound only Next 15.5.28's one-argument informational lookup in this fixture. */ export function boundNextVersionFetch(delegate, notify) { return function (...args) { if (args.length !== 1 || args[0] !== 'https://registry.npmjs.org/-/package/next/dist-tags') diff --git a/frontend/apps/web/scripts/public-server.mjs b/frontend/apps/web/scripts/public-server.mjs index 06e9f7cf..6b37afc5 100644 --- a/frontend/apps/web/scripts/public-server.mjs +++ b/frontend/apps/web/scripts/public-server.mjs @@ -4,6 +4,8 @@ import { createServer } from 'node:https'; import { fileURLToPath } from 'node:url'; import { delegateDevelopmentHmr } from './development-hmr.mjs'; +import { BffConfigurationError, bffConfiguration } from '../.server/auth/configuration.js'; +import { protectBffLogoutResponse } from '../.server/auth/logout-response.js'; import { admitIncomingRequest, loadLocalEnvironment, @@ -18,16 +20,49 @@ import { installPublicAdmissionRuntime } from '../.server/public-admission-runti const root = fileURLToPath(new URL('../../../../', import.meta.url)); try { + // The native provider/API transports never inherit an ambient TLS bypass. + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1'; // A nonempty exclusion survives debug initialization and prevents later dotenv // loading from restoring diagnostics. Apply it before importing any Next code. process.env.DEBUG = '-*'; + // Pinned Next records raw development request URLs in .next/trace even for + // middleware responses. Every accepted span duration is <= MAX_SAFE_INTEGER + // microseconds; this millisecond threshold suppresses that reporter before + // Next loads. The separately invoked build keeps its non-request traces. + process.env.NEXT_TRACE_SPAN_THRESHOLD_MS = '9007199254740991'; const { default: next } = await import('next'); const values = loadLocalEnvironment(root, process.env); const configuration = publicServerConfiguration(values, root); - const admission = installPublicAdmissionRuntime(); + const dev = process.argv.includes('--dev'); + const bff = bffConfiguration(root, { + ...process.env, + NODE_ENV: dev ? 'development' : 'production', + }); + const admission = installPublicAdmissionRuntime(bff.enabled); + let bffRequests; + let bffSessions; + if (bff.enabled) { + const [ + { createTenantProvider }, + { createAccountSiteCaller }, + { installBffSessionRuntime }, + { installBffRequestRuntime }, + ] = await Promise.all([ + import('../.server/auth/provider-core.js'), + import('../.server/auth/account-client.js'), + import('../.server/auth/session-runtime.js'), + import('../.server/auth/request-runtime.js'), + ]); + const dependencies = { + provider: createTenantProvider(bff, globalThis.fetch), + account: createAccountSiteCaller(configuration, globalThis.fetch), + diagnostic: (reason) => console.warn('BFF session lifecycle', { reason }), + }; + bffRequests = installBffRequestRuntime(); + bffSessions = installBffSessionRuntime(dependencies); + } // 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'); // 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(); @@ -47,12 +82,21 @@ try { minVersion: 'TLSv1.2', }, (request, response) => { - if (!admitIncomingRequest(request, configuration.secret)) return refuseIngress(response); + if (!admitIncomingRequest(request, configuration.secret, bff.enabled)) + return refuseIngress(response); const binding = verifyProvenance(request.headers[INGRESS_HEADER], configuration.secret); if (!binding) return refuseIngress(response); + if (bff.enabled && binding.method === 'POST' && binding.target === '/api/auth/signout') + protectBffLogoutResponse(response, bff.issuer, binding.host); // No request, credential or provider-error values enter launcher diagnostics. Promise.resolve() - .then(() => admission.run(binding, request, response, () => handle(request, response))) + .then(() => + admission.run(binding, request, response, () => + bffRequests + ? bffRequests.run(binding, request, response, () => handle(request, response)) + : handle(request, response), + ), + ) .catch(() => { if (!response.headersSent) { response.writeHead(503, { 'cache-control': 'no-store' }); @@ -70,6 +114,8 @@ try { server.on('connect', (_request, socket) => socket.destroy()); server.on('error', () => { admission.shutdown(); + bffRequests?.stop(); + void bffSessions?.stop(); console.error('Public HTTPS listener failed'); process.exit(1); }); @@ -78,6 +124,8 @@ try { if (stopping) return; stopping = true; admission.shutdown(); + bffRequests?.stop(); + const sessionsStopped = bffSessions?.stop(); // 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(); @@ -92,7 +140,7 @@ try { ); server.closeAllConnections(); upgradeSink.close(); - const results = await Promise.allSettled([closed, app.close()]); + const results = await Promise.allSettled([closed, app.close(), sessionsStopped]); clearTimeout(deadline); const failed = results.some((result) => result.status === 'rejected'); if (failed) console.error('Public server shutdown failed'); @@ -103,7 +151,9 @@ try { 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'); +} catch (error) { + if (error instanceof BffConfigurationError) + console.error(`Public server BFF configuration refused: ${error.issue}`); + else console.error('Public server configuration or startup failed'); process.exit(1); } diff --git a/frontend/apps/web/scripts/stop-test-child.d.mts b/frontend/apps/web/scripts/stop-test-child.d.mts index 73e0c62a..2acfa4eb 100644 --- a/frontend/apps/web/scripts/stop-test-child.d.mts +++ b/frontend/apps/web/scripts/stop-test-child.d.mts @@ -1,3 +1,3 @@ import type { ChildProcess } from 'node:child_process'; -export function stopTestChild(child: ChildProcess): Promise; +export function stopTestChild(child: ChildProcess): Promise>; diff --git a/frontend/apps/web/scripts/stop-test-child.mjs b/frontend/apps/web/scripts/stop-test-child.mjs index d7e1a3c3..6cc679f1 100644 --- a/frontend/apps/web/scripts/stop-test-child.mjs +++ b/frontend/apps/web/scripts/stop-test-child.mjs @@ -12,7 +12,7 @@ export function stopTestChild(child) { } async function stopOwnedChild(child) { - if (child.pid === undefined) return; // A failed spawn owns no process. + if (child.pid === undefined) return Object.freeze({ forced: false }); // Failed spawn owns no process. const group = process.platform !== 'win32'; let retired = false; const signal = (name) => { @@ -43,7 +43,7 @@ async function stopOwnedChild(child) { throw error; } }; - if (!alive()) return; + if (!alive()) return Object.freeze({ forced: false }); signal('SIGTERM'); const started = Date.now(); let forced = false; @@ -56,4 +56,5 @@ async function stopOwnedChild(child) { } await new Promise((resolve) => setTimeout(resolve, 20)); } + return Object.freeze({ forced }); } diff --git a/frontend/apps/web/scripts/verify-bff-browser.mjs b/frontend/apps/web/scripts/verify-bff-browser.mjs new file mode 100644 index 00000000..7ae89bad --- /dev/null +++ b/frontend/apps/web/scripts/verify-bff-browser.mjs @@ -0,0 +1,623 @@ +// Narrow actual HTTPS browser proof with fake grants/API. Real OIDC/API has a separate owner. +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createHash, randomBytes, X509Certificate } from 'node:crypto'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync } from 'node:fs'; +import { createServer } from 'node:https'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { readBrowserDevToolsPort, waitForBrowserDevTools } from './admission-browser.mjs'; +import { startBffProof } from './bff-proof-host.mjs'; +import { + assertBffCookieAttributes, + assertBffPrivateOutput, + bffDevToolsFailureStage, + bffProofCopy, + createBffCookieScanner, + rememberBffCookies, + withProofWaiters, +} from './bff-proof-support.mjs'; +import { + createFixtureOwner, + createPrivateScanner, + fixtureEnvironment, +} from './fixture-support.mjs'; +import { stopTestChild } from './stop-test-child.mjs'; + +const owner = createFixtureOwner(); +owner.install({ control: process.stdin, event: 'end' }); +const root = owner.ownRoot(mkdtempSync(join(tmpdir(), 'learnstack-bff-browser-'))); +const source = fileURLToPath(new URL('../', import.meta.url)); +const secret = randomBytes(32).toString('base64url'); +const marker = randomBytes(32).toString('base64url'); +const privateValues = [secret, marker]; +const counter = process.argv[2] === '--without-history'; +let stage = 'input'; +let browser; +let browserProcess; +let nativeServer; +let version; +let naturalRestores = 0; +let restoresVeiled = true; +let scanners = []; +let counterProbe; +let counterWitness; +let authStatus = 0; +let callbackStatus = 0; +let authorizeCalls = 0; +let logoutRoutes = 0; +const deadline = setTimeout(() => { + void owner.cancel(); +}, 180_000); +deadline.unref(); +async function cleanup() { + const failures = []; + for (const operation of [ + () => browser?.close(), + async () => { + for (const child of [browserProcess, nativeServer?.process].filter(Boolean)) { + const stopped = await stopTestChild(child); + if (stopped.forced) failures.push(true); + } + }, + () => owner.dispose(), + ]) + try { + await operation(); + } catch { + failures.push(true); + } + assert.equal(failures.length, 0, 'actual browser/native cleanup'); + if (nativeServer) { + const stopped = await nativeServer.completed; + assert.equal(stopped.code, 0); + assert.equal(stopped.signal, null); + } +} +// A single bounded frame initializes the proof. Keep the pipe actively draining +// afterwards: EOF is the parent's private lifetime signal, not input framing. +function configuration() { + return new Promise((resolve, reject) => { + let buffered = ''; + let accepted = false; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { + if (accepted) { + void owner.cancel(); + return; + } + buffered += chunk; + try { + assert.ok(Buffer.byteLength(buffered) <= 65536); + const end = buffered.indexOf('\n'); + if (end === -1) return; + assert.equal(end, buffered.length - 1); + const value = JSON.parse(buffered.slice(0, end)); + accepted = true; + buffered = ''; + resolve(value); + } catch { + accepted = true; + reject(new Error('Invalid private browser input')); + } + }); + process.stdin.once('error', () => { + reject(new Error('Private browser input failed')); + void owner.cancel(); + }); + process.stdin.once('end', () => { + if (!accepted) reject(new Error('Private browser input incomplete')); + }); + }); +} +try { + assert.ok(process.argv.length === 2 || (process.argv.length === 3 && counter)); + const input = await configuration(); + owner.assertActive(); + assert.equal(Object.keys(input).sort().join(','), 'protocol,sites'); + assert.equal(input.protocol, 1); + assert.ok(Array.isArray(input.sites) && input.sites.length === 2); + for (const site of input.sites) { + assert.equal(Object.keys(site).sort().join(','), 'displayName,host,locale'); + assert.match(site.host, /^[a-z0-9-]+(?:\.[a-z0-9-]+)+:3000$/); + assert.ok( + typeof site.displayName === 'string' && + site.displayName.length > 0 && + site.displayName.length < 256, + ); + assert.ok(['en', 'tr', 'tr-TR'].includes(site.locale)); + } + assert.notEqual(input.sites[0].host, input.sites[1].host); + // Chromium forwards redirect-chain requests without a second Playwright route + // interception. Both fake provider endpoints therefore use this actual owned + // HTTPS listener, including the authorization target after the native POST 303. + let authorize; + const authorizePath = '/realms/learnstack/protocol/openid-connect/auth'; + const logoutPath = '/realms/learnstack/protocol/openid-connect/logout'; + const logoutServer = owner.ownServer( + createServer((request, response) => { + if (request.method === 'GET' && request.url === authorizePath && authorize) { + void authorize(response); + return; + } + if (request.method === 'GET' && request.url === logoutPath) { + ++logoutRoutes; + response.writeHead(200, { 'content-type': 'text/html', 'cache-control': 'no-store' }); + response.end('Owned provider sentinel'); + return; + } + response.writeHead(404, { 'cache-control': 'no-store' }); + response.end(); + }), + ); + await new Promise((resolve) => logoutServer.listen(0, '127.0.0.1', resolve)); + const fakeIssuer = 'https://localhost:' + logoutServer.address().port + '/realms/learnstack'; + const logoutTarget = fakeIssuer + '/protocol/openid-connect/logout'; + const host = await startBffProof({ + owner, + root, + privateValues, + secret, + marker, + uiMode: true, + sites: input.sites, + fakeIssuer, + fakeLogout: logoutTarget, + clockBase: Date.now(), + mutation: counter ? 'history' : undefined, + mark: (value) => { + stage = value; + }, + }); + scanners = host.scanners; + nativeServer = host.server; + logoutServer.setSecureContext({ + key: readFileSync(join(root, 'key.pem')), + cert: readFileSync(join(root, 'cert.pem')), + }); + stage = 'browser-executable'; + 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 executablePath = candidates.find(existsSync); + assert.ok(executablePath, 'stock Chromium is required, never skipped'); + const require = createRequire(join(source, 'package.json')); + process.env.DEBUG = '-*'; + process.env.PWDEBUG = '0'; + const { chromium } = require('playwright'); + const fingerprint = createHash('sha256') + .update( + new X509Certificate(readFileSync(join(root, 'cert.pem'))).publicKey.export({ + type: 'spki', + format: 'der', + }), + ) + .digest('base64'); + const profile = join(root, 'chromium-profile'); + mkdirSync(profile, { mode: 0o700 }); + owner.assertActive(); + stage = 'browser-spawn'; + browserProcess = owner.ownChild( + spawn( + executablePath, + [ + '--headless=new', + '--remote-debugging-address=127.0.0.1', + '--remote-debugging-port=0', + '--user-data-dir=' + profile, + '--no-first-run', + '--no-default-browser-check', + '--disable-background-networking', + '--disable-component-update', + '--disable-sync', + '--disable-breakpad', + '--no-proxy-server', + '--host-resolver-rules=' + + input.sites.map((site) => 'MAP ' + site.host.split(':')[0] + ' 127.0.0.1').join(','), + '--ignore-certificate-errors-spki-list=' + fingerprint, + 'about:blank', + ], + { + env: fixtureEnvironment({ root }), + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }, + ), + ); + const browserOutput = createPrivateScanner(privateValues); + const browserCookies = createBffCookieScanner(host.cookieCanaries); + scanners.push(browserOutput, browserCookies); + for (const stream of [browserProcess.stdout, browserProcess.stderr]) { + stream.on('data', (chunk) => { + browserOutput.push(chunk); + browserCookies.push(chunk); + }); + stream.once('end', () => { + browserOutput.finish(); + browserCookies.finish(); + }); + } + let browserFailed = false; + browserProcess.once('error', () => { + browserFailed = true; + }); + const portFile = join(profile, 'DevToolsActivePort'); + stage = 'browser-devtools'; + const port = await waitForBrowserDevTools({ + assertActive: () => owner.assertActive(), + isLive: () => + !browserFailed && browserProcess.exitCode === null && browserProcess.signalCode === null, + readPort: () => readBrowserDevToolsPort(portFile), + timeout: 10_000, + }); + stage = 'browser-connect'; + browser = await chromium.connectOverCDP('http://127.0.0.1:' + port, { timeout: 10000 }); + version = browser.version(); + assert.match(version, /^\d+(?:\.\d+){1,3}$/); + stage = 'browser-context'; + const context = await browser.newContext(); // No ignoreHTTPSErrors or manual production-cookie injection. + const documents = []; + const headerChecks = []; + const cookiesSeen = []; + const origin = (site) => 'https://' + site.host; + context.on('request', (request) => { + if (request.resourceType() === 'document') documents.push(request.url()); + }); + context.on('response', (response) => { + if (input.sites.some((site) => new URL(response.url()).origin === origin(site))) + headerChecks.push( + (async () => { + try { + const cookies = (await response.headersArray()).filter( + (header) => header.name.toLowerCase() === 'set-cookie', + ); + rememberBffCookies( + cookies.map((cookie) => cookie.value), + host.cookieCanaries, + ); + assertBffCookieAttributes(cookies.map((cookie) => cookie.value)); + return ( + cookies.every((cookie) => !/;\s*domain=/i.test(cookie.value)) && + (await response.request().allHeaders()).authorization === undefined + ); + } catch { + return false; + } + })(), + ); + const path = new URL(response.url()).pathname; + if (path === '/api/auth/signin/keycloak') authStatus = response.status(); + if (path === '/api/auth/callback/keycloak') callbackStatus = response.status(); + }); + await context.exposeBinding('ownedRestored', (_source, value) => { + restoresVeiled &&= value === 'hidden'; + ++naturalRestores; + }); + await context.addInitScript(() => { + window.addEventListener('pageshow', (event) => { + if (event.isTrusted && event.persisted && document.querySelector('[data-bff-protected]')) + void window.ownedRestored(document.documentElement.style.visibility); + }); + }); + let currentSite; + let wrongHostChecked = false; + // Only provider grant/account operations are fake. The browser fetches both + // provider documents over owned HTTPS; native callbacks and cookies remain real. + let routeFailed = false; + authorize = async (response) => { + try { + ++authorizeCalls; + const cookies = await context.cookies(origin(currentSite)); + const transaction = cookies.find((cookie) => cookie.name.startsWith('__Host-ls-login-')); + assert.ok(transaction); + const state = transaction.name.slice('__Host-ls-login-'.length); + const code = randomBytes(32).toString('base64url'); + if (!wrongHostChecked) { + wrongHostChecked = true; + const other = input.sites.find((site) => site.host !== currentSite.host); + const negative = await context.newPage(); + const refusal = await negative.goto( + origin(other) + '/api/auth/callback/keycloak?state=' + state + '&code=' + code, + ); + assert.equal(refusal.status(), 400); + assert.equal( + (await context.cookies(origin(currentSite))).some( + (cookie) => cookie.name === transaction.name, + ), + true, + ); + assert.ok( + (await Promise.all(headerChecks)).every(Boolean), + 'headers captured before closing the negative page', + ); + await negative.close(); + } + response.writeHead(302, { + location: + origin(currentSite) + '/api/auth/callback/keycloak?state=' + state + '&code=' + code, + 'cache-control': 'no-store', + }); + response.end(); + } catch { + routeFailed = true; + response.destroy(); + } + }; + const pages = []; + const documentCount = (site) => + documents.filter((value) => { + const url = new URL(value); + return url.origin === origin(site) && url.pathname === '/studio'; + }).length; + for (const site of input.sites) { + stage = 'browser-anonymous'; + const page = await context.newPage(); + pages.push(page); + const anonymous = await page.goto(origin(site) + '/api/auth/session'); + assert.equal(anonymous.status(), 200); + assert.equal((await anonymous.json()).state, 'anonymous', 'other host cookies are not sent'); + currentSite = site; + stage = 'browser-signin-document'; + await page.goto(origin(site) + '/api/auth/signin?returnTo=%2Fstudio'); + stage = 'browser-submit'; + await page.getByRole('button').focus(); + const [posted] = await withProofWaiters( + [ + page.waitForRequest( + (request) => + request.method() === 'POST' && + new URL(request.url()).pathname === '/api/auth/signin/keycloak', + ), + ], + () => page.keyboard.press('Enter'), + ); + const submittedOrigin = await posted.headerValue('origin'); + if (submittedOrigin !== origin(site)) { + stage = + submittedOrigin === 'null' + ? 'browser-origin-null' + : submittedOrigin === null + ? 'browser-origin-absent' + : 'browser-origin-other'; + throw new Error('Browser form must retain its exact same-origin proof'); + } + stage = 'browser-callback'; + await page.waitForURL(origin(site) + '/studio'); + stage = 'browser-storage'; + await page.locator('[data-bff-protected]').waitFor(); + await page.waitForLoadState('networkidle'); + assert.ok((await page.textContent('body')).includes(site.displayName)); + const cookies = await context.cookies(origin(site)); + const owned = cookies.filter((cookie) => cookie.name.startsWith('__Host-ls-')); + assert.ok(owned.some((cookie) => cookie.name === '__Host-ls-session')); + for (const cookie of owned) { + assert.equal(cookie.domain, site.host.split(':')[0]); + assert.equal(cookie.path, '/'); + assert.equal(cookie.httpOnly, true); + assert.equal(cookie.secure, true); + assert.equal(cookie.sameSite, 'Lax'); + } + assert.equal((await page.evaluate(() => document.cookie)).includes('__Host-ls-'), false); + const content = await page.content(); + assert.equal(content.includes(marker), false); + assert.equal(content.includes(secret), false); + for (const cookie of owned.filter((cookie) => cookie.name !== '__Host-ls-csrf')) + assert.equal(content.includes(cookie.value), false, 'opaque cookie stays out of HTML'); + cookiesSeen.push(owned.find((cookie) => cookie.name === '__Host-ls-session').value); + // Actual keyboard activation reaches the existing focusable main target. + await page.keyboard.press('Tab'); + assert.equal( + await page.evaluate(() => document.activeElement?.getAttribute('href')), + '#main-content', + ); + await page.keyboard.press('Enter'); + assert.equal(await page.evaluate(() => document.activeElement?.id), 'main-content'); + stage = 'browser-extension'; + await host.advance(60000); + const before = (await host.advance(0)).refreshes; + const extend = page.locator('button[type=button]'); + await extend.focus(); + await page.keyboard.press('Enter'); + await page.getByText(bffProofCopy(site.locale, 'session.extended')).waitFor(); + assert.equal((await host.advance(0)).refreshes, before + 1); + } + assert.notEqual( + cookiesSeen[0], + cookiesSeen[1], + 'two real host jars contain independent opaque handles', + ); + const [page, otherPage] = pages; + stage = 'browser-logout-submit'; + const first = input.sites[0]; + await page.locator('form[action="/api/auth/signout"] button').focus(); + const [outgoingLogout, logoutResponse] = await withProofWaiters( + [ + page.waitForRequest( + (request) => + request.method() === 'POST' && new URL(request.url()).pathname === '/api/auth/signout', + ), + page.waitForResponse( + (response) => + response.request().method() === 'POST' && + new URL(response.url()).pathname === '/api/auth/signout', + ), + ], + () => page.keyboard.press('Enter'), + ); + stage = 'browser-logout-origin'; + const logoutOrigin = await outgoingLogout.headerValue('origin'); + if (logoutOrigin !== origin(first)) { + stage = logoutOrigin === 'null' ? 'browser-logout-origin-null' : 'browser-logout-origin-other'; + throw new Error('Logout form must retain its exact same-origin proof'); + } + stage = 'browser-logout-response'; + const observedLogout = logoutResponse; + const logoutStatus = observedLogout.status(); + if (logoutStatus !== 303) { + stage = + 'browser-logout-status-' + + (new Set([400, 403, 404, 429, 503]).has(logoutStatus) ? logoutStatus : 'other'); + throw new Error('Logout form must revoke and navigate'); + } + stage = 'browser-logout-location'; + assert.equal(await observedLogout.headerValue('location'), logoutTarget); + stage = 'browser-logout-navigation'; + await page.waitForURL( + (url) => + url.origin === new URL(logoutTarget).origin && + url.pathname === logoutPath && + url.search === '', + ); + assert.ok(logoutRoutes > 0, 'the browser actually fetched the owned provider logout document'); + stage = 'browser-logout-cookie-header'; + const deletion = (await observedLogout.headersArray()).find( + (header) => + header.name.toLowerCase() === 'set-cookie' && header.value.startsWith('__Host-ls-session=;'), + ); + assert.ok(deletion && /max-age=0/i.test(deletion.value)); + assert.equal(await observedLogout.headerValue('referrer-policy'), 'no-referrer'); + stage = 'browser-logout-cookie'; + assert.equal( + (await context.cookies(origin(first))).some((cookie) => cookie.name === '__Host-ls-session'), + false, + ); + const beforeBack = documentCount(first); + counterWitness = 'natural_back'; + counterProbe = async () => + documentCount(first) === beforeBack && + (await page.evaluate( + () => + location.pathname === '/studio' && + document.documentElement.style.visibility === 'hidden' && + !!document.querySelector('[data-bff-protected]'), + )); + stage = 'browser-back-revalidation'; + await page.goBack(); + await page.waitForURL( + (url) => url.origin === origin(first) && url.pathname === '/api/auth/signin', + { timeout: 10_000 }, + ); + assert.ok( + documentCount(first) > beforeBack, + 'actual browser back navigation obtains fresh native admission after logout', + ); + assert.equal(await page.locator('[data-bff-protected]').count(), 0); + // Keep a second already-rendered document alive while another tab logs it out. + // An explicit persisted PageTransitionEvent reaches the restoration branch even + // when this stock browser invalidates no-store BFCache entries after cookie changes. + stage = 'browser-history-setup'; + const second = input.sites[1]; + const control = await context.newPage(); + await control.goto(origin(second) + '/portal'); + await control.locator('form[action="/api/auth/signout"] button').focus(); + await control.keyboard.press('Enter'); + await control.waitForURL( + (url) => url.origin === new URL(logoutTarget).origin && url.pathname === logoutPath, + ); + assert.equal(await otherPage.locator('[data-bff-protected]').count(), 1); + assert.equal( + (await context.cookies(origin(second))).some((cookie) => cookie.name === '__Host-ls-session'), + false, + ); + const beforeRestore = documentCount(second); + counterWitness = 'persisted_event'; + counterProbe = async () => + documentCount(second) === beforeRestore && + (await otherPage.evaluate( + () => + location.pathname === '/studio' && + document.documentElement.style.visibility === 'hidden' && + !!document.querySelector('[data-bff-protected]'), + )); + stage = 'browser-history-revalidation'; + await otherPage.evaluate(() => + window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })), + ); + await otherPage.waitForURL( + (url) => url.origin === origin(second) && url.pathname === '/api/auth/signin', + { timeout: 10_000 }, + ); + assert.equal(await otherPage.locator('[data-bff-protected]').count(), 0); + stage = 'browser-headers'; + assert.equal(restoresVeiled, true, 'naturally restored protected documents are already veiled'); + assert.equal(routeFailed, false, 'fake authorization control completed'); + assert.equal( + authorizeCalls, + input.sites.length, + 'both logins fetch the owned HTTPS authorization endpoint', + ); + assert.ok(headerChecks.length > 0 && (await Promise.all(headerChecks)).every(Boolean)); + stage = 'cleanup'; + await cleanup(); + stage = 'privacy'; + assertBffPrivateOutput(scanners); + clearTimeout(deadline); + process.stdin.destroy(); + process.stdout.write( + JSON.stringify({ + protocol: 1, + phase: 'browser_result', + outcome: 'passed', + hosts: 2, + version, + naturalRestores, + }) + '\n', + ); +} catch (error) { + if (stage === 'browser-devtools') stage = bffDevToolsFailureStage(error); + let expectedCounter = false; + if ( + counter && + ['browser-history-revalidation', 'browser-back-revalidation'].includes(stage) && + counterProbe + ) + try { + expectedCounter = await counterProbe(); + } catch { + /* unrelated failure is not the mutant witness */ + } + let clean = true; + try { + await cleanup(); + } catch { + clean = false; + } + clearTimeout(deadline); + process.stdin.destroy(); + if (expectedCounter && clean && !scanners.some((scanner) => scanner.leaked)) { + process.stdout.write( + JSON.stringify({ + protocol: 1, + phase: 'browser_result', + outcome: 'rejected_history', + witness: counterWitness, + hosts: 2, + version, + }) + '\n', + ); + } else { + if (stage === 'browser-callback') { + const codes = new Set([0, 200, 303, 400, 403, 404, 429, 503]); + const status = authorizeCalls ? callbackStatus : authStatus; + stage = + (authorizeCalls ? 'browser-callback-' : 'browser-authorize-') + + (codes.has(status) ? status : 'other'); + } + // Preserve the original closed stage even if later actual cleanup fails. + process.stderr.write('BFF browser fixture failed: ' + stage + '\n'); + if (!clean) process.stderr.write('BFF browser cleanup failed.\n'); + } + process.exitCode = 1; +} diff --git a/frontend/apps/web/scripts/verify-bff-protocol.mjs b/frontend/apps/web/scripts/verify-bff-protocol.mjs new file mode 100644 index 00000000..962fd006 --- /dev/null +++ b/frontend/apps/web/scripts/verify-bff-protocol.mjs @@ -0,0 +1,329 @@ +// Minimal production-mode Next protocol harness. --ui uses current product middleware/UI. +// Fake grant/account capabilities; the real provider/API chain has a separate owner. +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { startBffProof, jar } from './bff-proof-host.mjs'; +import { assertBffPrivateOutput, bffProofCopy } from './bff-proof-support.mjs'; +import { createFixtureOwner } from './fixture-support.mjs'; +import { stopTestChild } from './stop-test-child.mjs'; +const owner = createFixtureOwner(); +owner.install(); +const root = owner.ownRoot(mkdtempSync(join(tmpdir(), 'learnstack-bff-protocol-'))); +const secret = randomBytes(32).toString('base64url'); +const marker = randomBytes(32).toString('base64url'); +const callbackCode = randomBytes(32).toString('base64url'); +const errorCanary = randomBytes(32).toString('base64url'); +const privateValues = [secret, marker, callbackCode, errorCanary]; +const deadline = setTimeout(() => { + void owner.cancel(); +}, 120_000); +deadline.unref(); +const devMode = ['--dev', '--without-native-diagnostics'].includes(process.argv[2]); +const uiMode = devMode || process.argv[2] === '--ui'; +let stage = 'setup'; +try { + assert.ok( + process.argv.length === 2 || + (process.argv.length === 3 && + [ + '--without-native-activity', + '--ui', + '--dev', + '--without-native-diagnostics', + '--without-successful-native-stop', + ].includes(process.argv[2])), + 'closed proof mode', + ); + const { app, server, advance, get, scanners, cookieCanaries } = await startBffProof({ + owner, + root, + privateValues, + secret, + marker, + fakeLogout: + 'https://identity.example.test/realms/learnstack/protocol/openid-connect/logout?id_token_hint=' + + marker, + devMode, + uiMode, + mutation: + process.argv[2] === '--without-native-activity' + ? 'activity' + : process.argv[2] === '--without-native-diagnostics' + ? 'diagnostics' + : process.argv[2] === '--without-successful-native-stop' + ? 'shutdown' + : undefined, + mark: (value) => { + stage = value; + }, + }); + if (uiMode) { + stage = 'ui-signin'; + for (const [host, locale] of [ + ['institution.example.test:3000', 'en'], + ['other.example.test:3000', 'tr'], + ]) { + const page = await get('/api/auth/signin?returnTo=%2Fstudio', undefined, { + headers: { host, Accept: 'text/html' }, + }); + stage = host.startsWith('other.') ? 'ui-signin-tr-status' : 'ui-signin-en-status'; + if (page.status !== 200) process.stderr.write(JSON.stringify({ status: page.status }) + '\n'); + assert.equal(page.status, 200); + stage = host.startsWith('other.') ? 'ui-signin-tr-render' : 'ui-signin-en-render'; + assert.ok(page.body.includes(bffProofCopy(locale, 'signin.continue'))); + assert.ok(page.body.includes(' + value.startsWith('__Host-ls-login-'), + ); + const state = transaction.slice('__Host-ls-login-'.length).split('=')[0]; + privateValues.push(state); + stage = 'callback'; + const callback = await get( + '/api/auth/callback/keycloak?state=' + state + '&code=' + callbackCode, + jar(signIn), + ); + if (callback.status !== 303) { + let reason = 'unknown'; + try { + const code = JSON.parse(callback.body).code; + if ( + [ + 'configuration', + 'cancelled', + 'revoked', + 'response', + 'identity', + 'transaction', + 'expired', + 'invalid_request', + ].includes(code) + ) + reason = code; + } catch { + /* A framework error is not a closed BFF response. */ + } + process.stderr.write(JSON.stringify({ status: callback.status, reason }) + '\n'); + } + assert.equal(callback.status, 303); + const active = jar(callback); + stage = 'rsc'; + const document = await get('/studio', active); + assert.equal(document.status, 200); + if (uiMode) { + assert.ok(document.body.includes('

' + bffProofCopy('en', 'shell.studio') + '

')); + assert.ok(document.body.includes(bffProofCopy('en', 'shell.account'))); + assert.ok(document.body.includes('bff-history')); + } else assert.match(document.body, /

authenticated<\/h1>/); + assert.equal(document.body.includes(marker), false); + const before = await advance(901000); + const prior = JSON.parse((await get('/api/auth/session', active)).body); + const flight = await get('/studio?_rsc=probe', active, { + headers: { RSC: '1', Accept: '*/*', 'Sec-Fetch-Dest': 'empty' }, + }); + assert.equal(flight.status, 200); + assert.match(flight.headers['content-type'], /text\/x-component/); + assert.equal(flight.body.includes(marker), false); + const after = await advance(0); + assert.equal(after.refreshes, before.refreshes + 1, 'actual RSC refreshes expired access'); + assert.equal(after.accounts, before.accounts + 1, 'actual RSC validates AccountSite'); + const session = await get('/api/auth/session', active); + const projection = JSON.parse(session.body); + assert.equal(projection.expiresAt, prior.expiresAt + 901000, 'RSC advances effective idle'); + assert.equal(projection.state, 'authenticated'); + assert.equal(session.body.includes(marker), false); + stage = 'passive'; + await advance(60000); + const prefetched = await get('/studio?_rsc=prefetch', active, { + headers: { RSC: '1', 'Next-Router-Prefetch': '1', Accept: '*/*', 'Sec-Fetch-Dest': 'empty' }, + }); + assert.equal(prefetched.status, 200); + const head = await get('/studio', active, { method: 'HEAD', headers: { Accept: 'text/html' } }); + assert.equal(head.status, 200); + assert.equal(head.body, ''); + assert.equal( + JSON.parse((await get('/api/auth/session', active)).body).expiresAt, + projection.expiresAt, + 'prefetch/HEAD/poll never touch idle', + ); + const passive = await advance(0); + assert.equal(passive.accounts, after.accounts); + assert.equal(passive.refreshes, after.refreshes); + if (uiMode) { + stage = 'ui-host-fence'; + const crossHost = await get('/portal', active, { + headers: { host: 'other.example.test:3000' }, + }); + assert.equal(crossHost.status, 303); + assert.ok( + crossHost.headers.location.startsWith('https://other.example.test:3000/api/auth/signin'), + ); + const csrfB = await get('/api/auth/csrf', undefined, { + headers: { host: 'other.example.test:3000' }, + }); + const startB = await get('/api/auth/signin/keycloak', jar(csrfB), { + method: 'POST', + headers: { + host: 'other.example.test:3000', + origin: 'https://other.example.test:3000', + 'content-type': 'application/x-www-form-urlencoded', + }, + body: 'csrfToken=' + JSON.parse(csrfB.body).csrfToken + '&returnTo=%2Fportal', + }); + assert.equal(startB.status, 303); + const stateB = startB.headers['set-cookie'] + .find((value) => value.startsWith('__Host-ls-login-')) + .slice('__Host-ls-login-'.length) + .split('=')[0]; + const callbackB = await get( + '/api/auth/callback/keycloak?state=' + stateB + '&code=owned', + jar(startB), + { headers: { host: 'other.example.test:3000' } }, + ); + assert.equal(callbackB.status, 303); + const portal = await get('/portal', jar(callbackB), { + headers: { host: 'other.example.test:3000' }, + }); + assert.equal(portal.status, 200); + assert.match(portal.body, /anonymous<\/h1>/); + if (devMode) { + stage = 'dev-logout-return'; + const logoutState = logout.headers['set-cookie'] + .find((value) => value.startsWith('__Host-ls-logout-')) + .slice('__Host-ls-logout-'.length) + .split('=')[0]; + privateValues.push(logoutState); + const returned = await get('/?state=' + logoutState, active + '; ' + jar(logout)); + assert.equal(returned.status, 303); + const failedCallback = await get( + '/api/auth/callback/keycloak?state=' + + randomBytes(32).toString('base64url') + + '&error=access_denied&error_description=' + + errorCanary, + active, + ); + assert.equal(failedCallback.status, 400); + assert.equal(failedCallback.body.includes(errorCanary), false); + await advance(0); // Flush the pinned real reporter; no raw artifact leaves this owned root. + const settlement = await stopTestChild(server.process); + const stopped = await server.completed; + assert.equal(settlement.forced, false); + assert.equal(stopped.code, 0, 'development owner exits naturally before artifact inspection'); + assert.equal(stopped.signal, null); + stage = 'dev-diagnostics'; + let trace = ''; + try { + trace = readFileSync(join(app, '.next/trace'), 'utf8'); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + for (const canary of [callbackCode, errorCanary, state, logoutState, ...cookieCanaries]) { + stage = 'dev-trace'; + assert.equal(trace.includes(canary), false, 'no auth URL value in persisted trace'); + stage = 'dev-console'; + assert.equal( + scanners.some((scanner) => scanner.tail.includes(canary)), + false, + 'no auth URL value in console output', + ); + } + } + stage = 'native-stop'; + const settlement = await stopTestChild(server.process); + const stopped = await server.completed; + assert.equal(settlement.forced, false); + assert.equal(stopped.code, 0); + assert.equal(stopped.signal, null); + stage = 'cleanup'; + await owner.dispose(); + stage = 'privacy'; + assertBffPrivateOutput(scanners); + clearTimeout(deadline); + process.stdout.write( + devMode + ? 'BFF development diagnostics: passed\n' + : uiMode + ? 'BFF production UI: passed\n' + : 'BFF native Next protocol: passed\n', + ); +} catch { + let clean = true; + try { + await owner.dispose(); + } catch { + clean = false; + } + clearTimeout(deadline); + process.stderr.write('BFF native Next protocol failed: ' + stage + '\n'); + if (!clean) process.stderr.write('BFF protocol cleanup failed.\n'); + process.exitCode = 1; +} diff --git a/frontend/apps/web/scripts/verify-bff-session.mjs b/frontend/apps/web/scripts/verify-bff-session.mjs new file mode 100644 index 00000000..6caa9a0d --- /dev/null +++ b/frontend/apps/web/scripts/verify-bff-session.mjs @@ -0,0 +1,292 @@ +// Private fixture IPC, never an application endpoint or a retained raw artifact. +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { startBffProof } from './bff-proof-host.mjs'; +import { createFixtureOwner, createPrivateScanner } from './fixture-support.mjs'; +import { stopTestChild } from './stop-test-child.mjs'; + +const owner = createFixtureOwner(); +owner.install({ control: process.stdin, event: 'end' }); +const root = owner.ownRoot(mkdtempSync(join(tmpdir(), 'learnstack-bff-session-'))); +const deadline = setTimeout(() => { + void owner.cancel(); +}, 240_000); +deadline.unref(); +let stage = 'input'; +function lines() { + let buffered = ''; + const queued = []; + let ended = false; + let failure; + let wake; + const changed = () => wake?.(); + process.stdin.setEncoding('utf8'); + // Flow independently of the command iterator: compile/build/request awaits + // must never postpone the parent's EOF lifetime signal. + process.stdin.on('data', (chunk) => { + try { + buffered += chunk; + assert.ok(Buffer.byteLength(buffered) <= 65536, 'bounded private input'); + while (buffered.includes('\n')) { + const end = buffered.indexOf('\n'); + queued.push(JSON.parse(buffered.slice(0, end))); + buffered = buffered.slice(end + 1); + assert.ok(queued.length <= 2, 'one private command at a time'); + } + } catch { + failure = new Error('Invalid bounded private input'); + void owner.cancel(); + } + changed(); + }); + process.stdin.once('end', () => { + ended = true; + if (buffered) failure = new Error('Incomplete private input'); + changed(); + }); + process.stdin.once('error', () => { + failure = new Error('Private input failed'); + changed(); + void owner.cancel(); + }); + return (async function* () { + while (true) { + if (failure) throw failure; + if (queued.length) yield queued.shift(); + else if (ended) return; + else + await new Promise((resolve) => { + wake = resolve; + }); + } + })(); +} +const send = (value) => + new Promise((resolve, reject) => { + process.stdout.write(JSON.stringify(value) + '\n', (error) => + error ? reject(error) : resolve(), + ); + }); +try { + const shutdownCounter = process.argv[2] === '--without-successful-native-stop'; + const heldBuild = process.argv[2] === '--hold-build'; + assert.ok( + process.argv.length === 2 || (process.argv.length === 3 && (shutdownCounter || heldBuild)), + ); + const input = lines(); + const { value: config } = await input.next(); + assert.ok(config && config.protocol === 1 && config.mode === 'real'); + assert.equal( + Object.keys(config).sort().join(','), + 'apiOrigin,authority,clientSecret,issuer,mode,protocol,secret,sites', + ); + const issuer = new URL(config.issuer); + const api = new URL(config.apiOrigin); + stage = 'input-issuer'; + assert.ok( + issuer.protocol === 'https:' && + ['127.0.0.1', 'localhost'].includes(issuer.hostname) && + issuer.pathname === '/realms/learnstack' && + !issuer.search && + !issuer.hash && + !issuer.username && + !issuer.password, + ); + stage = 'input-api'; + assert.ok( + api.protocol === 'http:' && + api.origin === config.apiOrigin && + api.hostname === '127.0.0.1' && + api.pathname === '/' && + !api.search && + !api.hash && + !api.username && + !api.password, + ); + stage = 'input-secrets'; + assert.match(config.secret, /^[a-z0-9]{48,64}$/); + assert.ok( + typeof config.clientSecret === 'string' && + config.clientSecret.length > 0 && + config.clientSecret.length <= 256 && + !/[\r\n]/.test(config.clientSecret), + ); + assert.ok( + typeof config.authority === 'string' && + config.authority.length < 8192 && + config.authority.startsWith('-----BEGIN CERTIFICATE-----'), + ); + stage = 'input-sites'; + assert.ok(Array.isArray(config.sites) && config.sites.length === 2); + for (const site of config.sites) { + assert.equal(Object.keys(site).sort().join(','), 'displayName,host,locale'); + assert.match(site.host, /^[a-z0-9-]+(?:\.[a-z0-9-]+)+:3000$/); + assert.ok( + typeof site.displayName === 'string' && + site.displayName.length > 0 && + site.displayName.length <= 256, + ); + assert.ok(['en', 'tr', 'tr-TR'].includes(site.locale)); + } + assert.notEqual(config.sites[0].host, config.sites[1].host); + stage = 'setup'; + const privateValues = [config.secret, config.clientSecret]; + const host = await startBffProof({ + owner, + root, + privateValues, + secret: config.secret, + marker: randomBytes(32).toString('base64url'), + uiMode: true, + sites: config.sites, + external: config, + mutation: shutdownCounter ? 'shutdown' : heldBuild ? 'held-build' : undefined, + mark: (value) => { + stage = value; + void send({ + protocol: 1, + phase: value === 'held-build' ? 'held-build' : 'progress', + stage: value, + }).catch(() => owner.cancel()); + }, + }); + await send({ protocol: 1, phase: 'ready' }); + let finished = false; + let count = 0; + for await (const command of input) { + stage = 'command'; + assert.ok(++count <= 128 && command && command.protocol === 1); + if (command.action === 'finish') { + assert.equal(Object.keys(command).sort().join(','), 'action,protocol'); + finished = true; + break; + } + assert.equal( + Object.keys(command).sort().join(','), + 'action,body,cookie,host,method,path,protocol', + ); + assert.equal(command.action, 'request'); + const site = config.sites.find((site) => site.host === command.host); + assert.ok(site); + assert.ok(['GET', 'HEAD', 'POST'].includes(command.method)); + assert.ok( + typeof command.path === 'string' && + command.path.length <= 16384 && + !/[\r\n#]/.test(command.path), + ); + const path = new URL(command.path, 'https://' + site.host); + assert.equal(path.origin, 'https://' + site.host); + assert.ok( + [ + '/studio', + '/portal', + '/', + '/api/auth/csrf', + '/api/auth/session', + '/api/auth/signin', + '/api/auth/signin/keycloak', + '/api/auth/callback/keycloak', + '/api/auth/signout', + '/api/auth/extend', + ].includes(path.pathname), + ); + assert.ok( + typeof command.cookie === 'string' && + command.cookie.length <= 16384 && + !/[\r\n]/.test(command.cookie), + ); + assert.ok(typeof command.body === 'string' && Buffer.byteLength(command.body) <= 16384); + for (const key of ['code', 'state', 'error_description']) { + const value = path.searchParams.get(key); + if (value) privateValues.push(value); + } + stage = 'request'; + let response; + try { + response = await host.get(command.path, command.cookie, { + method: command.method, + body: command.body || undefined, + headers: { + host: site.host, + accept: 'text/html', + ...(command.method === 'GET' && ['/studio', '/portal'].includes(path.pathname) + ? { 'sec-fetch-dest': 'document' } + : {}), + ...(command.method === 'POST' + ? { + origin: 'https://' + site.host, + 'content-type': 'application/x-www-form-urlencoded', + 'content-length': Buffer.byteLength(command.body).toString(), + } + : {}), + }, + }); + } catch (error) { + stage = + error?.message === 'owned request deadline' + ? 'request-deadline' + : error?.code === 'ECONNREFUSED' + ? 'request-connect' + : [ + 'CERT_HAS_EXPIRED', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'ERR_TLS_CERT_ALTNAME_INVALID', + ].includes(error?.code) + ? 'request-tls' + : 'request-socket'; + throw new Error('Closed BFF fixture transport refusal'); + } + // Private redirects/cookies return only to the owning C# process. No raw HTML, + // token, provider diagnostics or request headers become test result artifacts. + stage = 'response-json'; + const data = + response.status !== 303 && + command.method !== 'HEAD' && + response.headers['content-type']?.includes('application/json') + ? JSON.parse(response.body) + : null; + await send({ + protocol: 1, + phase: 'response', + status: response.status, + cookies: response.headers['set-cookie'] ?? [], + location: response.headers.location ?? null, + cache: response.headers['cache-control'] ?? null, + data, + rendered: response.body.includes(site.displayName), + privateFree: !createPrivateScanner(privateValues).contains(response.body), + }); + } + assert.ok(finished, 'explicit successful protocol completion'); + stage = 'cleanup'; + const stopped = await stopTestChild(host.server.process); + await owner.dispose(); + stage = 'native-stop'; + const native = await host.server.completed; + assert.equal(stopped.forced, false); + assert.equal(native.code, 0); + assert.equal(native.signal, null); + assert.equal( + host.scanners.some((scanner) => scanner.leaked), + false, + ); + clearTimeout(deadline); + await send({ protocol: 1, phase: 'complete' }); +} catch { + let cleanup = true; + try { + await owner.dispose(); + } catch { + cleanup = false; + } + clearTimeout(deadline); + process.stdin.destroy(); + process.stderr.write('BFF session fixture failed: ' + stage + '\n'); + if (!cleanup) process.stderr.write('BFF session cleanup failed.\n'); + process.exitCode = 1; +} diff --git a/frontend/apps/web/scripts/verify-ingress.mjs b/frontend/apps/web/scripts/verify-ingress.mjs index df44650c..f4aa3a5b 100644 --- a/frontend/apps/web/scripts/verify-ingress.mjs +++ b/frontend/apps/web/scripts/verify-ingress.mjs @@ -583,8 +583,8 @@ async function run() { ['/favicon.ico/extra', 404, 1], ['/nested/favicon.ico', 404, 1], ['/en/status/not-found', 404, 1], - ['/studio', 200, 1], - ['/portal', 200, 1], + ['/studio', 503, 1], + ['/portal', 503, 1], ]) { for (const method of ['GET', 'HEAD']) { const before = bootstrapCalls; @@ -601,6 +601,14 @@ async function run() { 'Exact matcher/bootstrap control failed: ' + path, ); assert.equal(response.headers['x-powered-by'], undefined, 'Framework identified itself'); + if (status === 503) { + assert.equal(response.headers['cache-control'], 'no-store'); + assert.equal(response.headers['retry-after'], '30'); + assert.equal(response.headers['set-cookie'], undefined); + assert.equal(response.body.includes('data-bff-protected'), false); + assert.equal(response.body.includes(' 0, 'GET success control must contain a body'); @@ -721,7 +729,7 @@ async function run() { await stopTestChild(stock); await stopTestChild(native); - // Next 15.5.27 loads next.config.ts for this custom production server; its + // Next 15.5.28 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'); diff --git a/frontend/apps/web/scripts/verify-public-admission.mjs b/frontend/apps/web/scripts/verify-public-admission.mjs index ff2ada53..0c14749a 100644 --- a/frontend/apps/web/scripts/verify-public-admission.mjs +++ b/frontend/apps/web/scripts/verify-public-admission.mjs @@ -326,8 +326,8 @@ function instrumentCopy() { ); replaceOnce( launcher, - ' const admission = installPublicAdmissionRuntime();', - ` const admission = installPublicAdmissionRuntime(); + ' const admission = installPublicAdmissionRuntime(bff.enabled);', + ` const admission = installPublicAdmissionRuntime(bff.enabled); const completions = createNativeCompletionObserver(); const snapshotIds = new WeakMap(); let snapshotsSeen = 0; @@ -381,13 +381,21 @@ function instrumentCopy() { ); replaceOnce( launcher, - '.then(() => admission.run(binding, request, response, () => handle(request, response)))', + `.then(() => + admission.run(binding, request, response, () => + bffRequests + ? bffRequests.run(binding, request, response, () => handle(request, response)) + : handle(request, response), + ), + )`, `.then(() => fixture.mode === 'missing' ? handle(request, response) : admission.run(fixture.mode === 'mismatch' ? { ...binding, target: '/mismatch' } : binding, request, response, () => { completions.track(response); - return handle(request, response); + return bffRequests + ? bffRequests.run(binding, request, response, () => handle(request, response)) + : handle(request, response); }))`, ); replaceOnce( @@ -458,12 +466,12 @@ export async function LateAdmissionProbe() { ); replaceOnce( layout, - '{children}', - '{children}', + '{children}', + '{children}', ); - const studio = join(app, 'src/app/(studio)/studio'); + const catalog = join(app, 'src/app/(public)/[locale]/courses'); writeFileSync( - join(studio, 'admission-navigation.tsx'), + join(catalog, 'admission-navigation.tsx'), `'use client'; import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; @@ -475,11 +483,16 @@ export default function AdmissionNavigation({ target }: { target: string }) { } `, ); - writeFileSync( - join(studio, 'page.tsx'), - `import AdmissionNavigation from './admission-navigation'; -export default function AdmissionPage() { return

Admission fixture

; } -`, + const catalogPage = join(catalog, 'page.tsx'); + replaceOnce( + catalogPage, + "import { notFound } from 'next/navigation';", + "import { notFound } from 'next/navigation';\nimport AdmissionNavigation from './admission-navigation';", + ); + replaceOnce( + catalogPage, + 'return ;', + `return <>;`, ); } @@ -610,7 +623,7 @@ async function lifetimes(native) { const scaffoldResponses = await Promise.all(pendingScaffolds.map((request) => request.result)); scaffoldResponses.forEach((response, index) => { safe(response); - assert.equal(response.status, 200); + assert.equal(response.status, 503); const tenant = scaffolds[index].tenant; const other = configuration.tenants.find((row) => row.host !== tenant.host); const dom = new JSDOM(response.body); @@ -618,9 +631,12 @@ async function lifetimes(native) { const styles = [...dom.window.document.querySelectorAll('style')].map( (style) => style.textContent, ); - assert.ok(styles.includes(themeCss(tenant)), 'Scaffold renders its own tenant palette'); + assert.ok( + styles.some((style) => style.includes(themeCss(tenant))), + 'Unavailable shell renders its own tenant palette', + ); assert.equal( - styles.includes(themeCss(other)), + styles.some((style) => style.includes(themeCss(other))), false, 'Scaffold excludes the other tenant palette', ); @@ -632,7 +648,11 @@ async function lifetimes(native) { const scaffoldAfter = await settled(native); empty(scaffoldAfter); assert.equal(scaffoldAfter.immutable, true); - assert.equal(scaffoldAfter.snapshotsSeen - scaffoldBefore.snapshotsSeen, 4); + assert.equal( + scaffoldAfter.snapshotsSeen - scaffoldBefore.snapshotsSeen, + 0, + 'Disabled shells never dispatch RSC consumers', + ); await checkpoint('done-scaffold-overlap'); stage = 'keep-alive'; const agent = new Agent({ keepAlive: true, maxSockets: 1 }); @@ -660,7 +680,7 @@ async function lifetimes(native) { await checkpoint('done-head'); stage = 'late HEAD'; await control(native, 'mode', 'late'); - const late = await call('/studio', { method: 'HEAD' }); + const late = await call(catalog, { method: 'HEAD' }); assert.equal(late.status, 200); assert.equal(late.body, ''); for (let attempt = 0; attempt < 100; attempt++) { @@ -793,7 +813,7 @@ try { root, certificate, origin: 'https://' + configuration.tenants[0].host + ':3000', - initialPath: '/studio', + initialPath: '/' + configuration.locale + '/courses', target: '/' + configuration.locale + '/courses/' + configuration.courseSlug, status, secrets: [configuration.secret, configuration.privateMarker], diff --git a/frontend/apps/web/src/app/(portal)/layout.tsx b/frontend/apps/web/src/app/(portal)/layout.tsx index 89fae256..7e937b51 100644 --- a/frontend/apps/web/src/app/(portal)/layout.tsx +++ b/frontend/apps/web/src/app/(portal)/layout.tsx @@ -5,5 +5,9 @@ type PortalLayoutProps = { }; export default function PortalLayout({ children }: PortalLayoutProps) { - return
{children}
; + return ( +
+ {children} +
+ ); } diff --git a/frontend/apps/web/src/app/(portal)/portal/page.tsx b/frontend/apps/web/src/app/(portal)/portal/page.tsx index 198eb20e..54a71f62 100644 --- a/frontend/apps/web/src/app/(portal)/portal/page.tsx +++ b/frontend/apps/web/src/app/(portal)/portal/page.tsx @@ -1,12 +1,6 @@ -export const metadata = { robots: { index: false, follow: false } }; +import { AccountShell, accountShellMetadata } from '@/components/auth/shell'; +export const generateMetadata = () => accountShellMetadata('portal'); export default function PortalHomePage() { - return ( -
-

Portal

-

- Learner and instructor portal shell. Real screens land in Phase 07. -

-
- ); + return ; } diff --git a/frontend/apps/web/src/app/(studio)/layout.tsx b/frontend/apps/web/src/app/(studio)/layout.tsx index e57cb30e..a7671c30 100644 --- a/frontend/apps/web/src/app/(studio)/layout.tsx +++ b/frontend/apps/web/src/app/(studio)/layout.tsx @@ -5,5 +5,9 @@ type StudioLayoutProps = { }; export default function StudioLayout({ children }: StudioLayoutProps) { - return
{children}
; + return ( +
+ {children} +
+ ); } diff --git a/frontend/apps/web/src/app/(studio)/studio/page.tsx b/frontend/apps/web/src/app/(studio)/studio/page.tsx index 2e87882d..cd546ff4 100644 --- a/frontend/apps/web/src/app/(studio)/studio/page.tsx +++ b/frontend/apps/web/src/app/(studio)/studio/page.tsx @@ -1,12 +1,6 @@ -export const metadata = { robots: { index: false, follow: false } }; +import { AccountShell, accountShellMetadata } from '@/components/auth/shell'; +export const generateMetadata = () => accountShellMetadata('studio'); export default function StudioHomePage() { - return ( -
-

Studio

-

- Admin and content studio shell. Real screens land in Phase 04 / Phase 06. -

-
- ); + return ; } diff --git a/frontend/apps/web/src/app/globals.css b/frontend/apps/web/src/app/globals.css index fdfc2de1..64e332bc 100644 --- a/frontend/apps/web/src/app/globals.css +++ b/frontend/apps/web/src/app/globals.css @@ -2,6 +2,34 @@ @tailwind components; @tailwind utilities; +.auth-shell { + width: min(100%, 60rem); + padding: 2rem 1.5rem; + margin-inline: auto; +} +.auth-shell p, +.auth-shell nav, +.auth-shell form { + margin-block: 1rem; +} +.auth-shell nav { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} +.auth-shell [role='alert'] { + border: 2px solid currentColor; + padding: 1rem; +} +.auth-shell button { + background: var(--ls-bg); + color: var(--ls-fg); +} +.auth-shell button:focus-visible, +.auth-shell a:focus-visible { + outline-color: var(--ls-fg); +} + /* Null or malformed whole themes retain these local defaults (P02d-6 G42). */ :root { --ls-primary: #1f6feb; @@ -64,6 +92,11 @@ font-size: 1.2rem; font-weight: 600; } + .public-header nav { + display: flex; + flex-wrap: wrap; + gap: 1rem; + } .public-main { min-height: 65vh; padding-block: 3rem; diff --git a/frontend/apps/web/src/app/layout.tsx b/frontend/apps/web/src/app/layout.tsx index 633cf04d..ac171358 100644 --- a/frontend/apps/web/src/app/layout.tsx +++ b/frontend/apps/web/src/app/layout.tsx @@ -1,8 +1,12 @@ import type { Metadata } from 'next'; +import Script from 'next/script'; import type { ReactNode } from 'react'; +import { BFF_HISTORY_SOURCE } from '@/components/auth/history'; +import { AuthSkipLink } from '@/components/auth/skip-link'; import { PublicTheme } from '@/components/public/theme'; import { textDirection } from '@/i18n/locale'; +import { bffSessionEnabled } from '@/server/auth/session-owner'; import { assertPublicRequestActive } from '@/server/public-request'; import { requirePublicResource } from '@/server/public-resource'; @@ -18,15 +22,24 @@ type 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 { request, kind } = await requirePublicResource(); assertPublicRequestActive(request); const locale = request.locale ?? 'en'; + const protectedShell = kind === 'scaffold' && bffSessionEnabled(); return ( + {protectedShell && ( + + )} - {children} + + {protectedShell && } + {children} + ); } diff --git a/frontend/apps/web/src/client/bff-session.test.ts b/frontend/apps/web/src/client/bff-session.test.ts new file mode 100644 index 00000000..5fd516a4 --- /dev/null +++ b/frontend/apps/web/src/client/bff-session.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { extendBffSession } from './bff-session'; + +const token = 'A'.repeat(43); +const value = { + state: 'authenticated', + displayName: 'School', + csrfToken: token, + expiresAt: 2_000_000_000_000, + limitedByAbsolute: false, +}; +afterEach(() => vi.unstubAllGlobals()); +describe('fixed same-origin BFF browser action', () => { + it('submits only the session CSRF scalar to the no-store fixed action and returns a frozen safe projection', async () => { + const fetch = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify(value), { headers: { 'content-type': 'application/json' } }), + ); + vi.stubGlobal('fetch', fetch); + const result = await extendBffSession(token); + expect(result).toEqual(value); + expect(Object.isFrozen(result)).toBe(true); + expect(fetch).toHaveBeenCalledExactlyOnceWith('/api/auth/extend', { + method: 'POST', + credentials: 'same-origin', + cache: 'no-store', + redirect: 'error', + headers: { 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8' }, + body: 'csrfToken=' + token, + signal: expect.any(AbortSignal), + }); + }); + it.each([ + { ...value, accessToken: 'private-canary' }, + { ...value, expiresAt: 'tomorrow' }, + { ...value, limitedByAbsolute: undefined }, + { state: 'anonymous' }, + ])('refuses a widened or invalid browser projection', async (body) => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } }), + ), + ); + await expect(extendBffSession(token)).rejects.toThrow('Session unavailable'); + }); + it('refuses invalid CSRF before transport and does not expose a failed response body', async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response('private-provider-failure', { status: 503 })); + vi.stubGlobal('fetch', fetch); + await expect(extendBffSession('bad')).rejects.toThrow('Session unavailable'); + expect(fetch).not.toHaveBeenCalled(); + await expect(extendBffSession(token)).rejects.toThrow('Session unavailable'); + }); + it.each([16_384, 16_385])( + 'checks the %i-byte boundary independently of JSON validation', + async (size) => { + const json = JSON.stringify(value); + const body = json + ' '.repeat(size - new TextEncoder().encode(json).byteLength); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response(body, { headers: { 'content-type': 'application/json' } }), + ), + ); + if (size === 16_384) await expect(extendBffSession(token)).resolves.toEqual(value); + else await expect(extendBffSession(token)).rejects.toThrow('Session unavailable'); + }, + ); +}); diff --git a/frontend/apps/web/src/client/bff-session.ts b/frontend/apps/web/src/client/bff-session.ts new file mode 100644 index 00000000..c7034469 --- /dev/null +++ b/frontend/apps/web/src/client/bff-session.ts @@ -0,0 +1,59 @@ +import type { SessionProjection } from '@/server/auth/session-projection'; + +/** Browser → same-origin BFF only. This is not a backend/API transport. */ +export async function extendBffSession(csrfToken: string): Promise { + if (!/^[A-Za-z0-9_-]{43}$/.test(csrfToken)) throw new Error('Session unavailable'); + // eslint-disable-next-line no-restricted-globals -- Fixed BFF action, never the backend API. + const response = await fetch('/api/auth/extend', { + method: 'POST', + credentials: 'same-origin', + cache: 'no-store', + redirect: 'error', + headers: { 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8' }, + body: new URLSearchParams({ csrfToken }).toString(), + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok || !response.headers.get('content-type')?.startsWith('application/json')) + throw new Error('Session unavailable'); + const reader = response.body?.getReader(); + if (!reader) throw new Error('Session unavailable'); + try { + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + size += chunk.value.byteLength; + if (size > 16_384) throw new Error('Session unavailable'); + chunks.push(chunk.value); + } + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body)); + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Session unavailable'); + const session = value as Record; + if ( + Object.keys(session).sort().join(',') !== + 'csrfToken,displayName,expiresAt,limitedByAbsolute,state' || + session.state !== 'authenticated' || + typeof session.displayName !== 'string' || + typeof session.expiresAt !== 'number' || + !Number.isFinite(session.expiresAt) || + typeof session.limitedByAbsolute !== 'boolean' || + typeof session.csrfToken !== 'string' || + !/^[A-Za-z0-9_-]{43}$/.test(session.csrfToken) + ) + throw new Error('Session unavailable'); + return Object.freeze(session as SessionProjection); + } catch { + throw new Error('Session unavailable'); + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} diff --git a/frontend/apps/web/src/components/auth/history.test.ts b/frontend/apps/web/src/components/auth/history.test.ts new file mode 100644 index 00000000..427d47da --- /dev/null +++ b/frontend/apps/web/src/components/auth/history.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { BFF_HISTORY_SOURCE } from './history'; + +describe('fixed protected history lifecycle', () => { + it('veils before caching and forces a fresh navigation on restore without exposing the old document', () => { + const listeners = new Map void>(); + const style = { visibility: '' }; + const reload = vi.fn(); + // Test-only execution of the exact fixed script; no request/user input is evaluated. + const install = new Function('addEventListener', 'document', 'location', BFF_HISTORY_SOURCE); + install( + (event: string, callback: (event: { persisted?: boolean }) => void) => + listeners.set(event, callback), + { documentElement: { style } }, + { reload }, + ); + listeners.get('pageshow')!({ persisted: false }); + expect(style.visibility).toBe(''); + expect(reload).not.toHaveBeenCalled(); + listeners.get('pagehide')!({}); + expect(style.visibility).toBe('hidden'); + listeners.get('pageshow')!({ persisted: true }); + expect(style.visibility).toBe('hidden'); + expect(reload).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/apps/web/src/components/auth/history.ts b/frontend/apps/web/src/components/auth/history.ts new file mode 100644 index 00000000..a67a6d42 --- /dev/null +++ b/frontend/apps/web/src/components/auth/history.ts @@ -0,0 +1,3 @@ +// Fixed source only. Veil before a document enters the history cache; a restored +// document stays veiled until a complete, freshly admitted network navigation. +export const BFF_HISTORY_SOURCE = `(()=>{let leaving=false;addEventListener('pagehide',()=>{leaving=true;document.documentElement.style.visibility='hidden'});addEventListener('pageshow',e=>{if(e.persisted||leaving){document.documentElement.style.visibility='hidden';location.reload()}})})();`; diff --git a/frontend/apps/web/src/components/auth/session.test.tsx b/frontend/apps/web/src/components/auth/session.test.tsx new file mode 100644 index 00000000..1f86331b --- /dev/null +++ b/frontend/apps/web/src/components/auth/session.test.tsx @@ -0,0 +1,297 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { hydrateRoot } from 'react-dom/client'; +import { renderToStaticMarkup, renderToString } from 'react-dom/server'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { SessionControls } from './session'; +import { extendBffSession } from '../../client/bff-session'; +import en from '../../i18n/messages/en/auth.json'; + +vi.mock('../../client/bff-session', () => ({ extendBffSession: vi.fn() })); +const initial = () => ({ + state: 'authenticated' as const, + displayName: 'School', + csrfToken: 'A'.repeat(43), + expiresAt: Date.now() + 150_000, + limitedByAbsolute: false, +}); +afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); +}); +const view = (value = initial()) => + render( + + + + } + > +

Admitted account view

+
, + ); +describe('session warning, truthful extension and safe expiry', () => { + it('warns without stealing focus, then hides protected content at expiry without a polling touch', () => { + vi.useFakeTimers(); + view(); + const button = screen.getByRole('button', { name: en.session.extend }); + button.focus(); + expect(screen.queryByText(en.session.warning)).toBeNull(); + act(() => { + vi.advanceTimersByTime(31_000); + }); + expect(screen.getByText(en.session.warning).parentElement).toHaveAttribute( + 'aria-live', + 'polite', + ); + expect(document.activeElement).toBe(button); + act(() => { + vi.advanceTimersByTime(120_000); + }); + expect(screen.queryByText('Admitted account view')).toBeNull(); + expect(screen.queryByText(en.session.warning)).toBeNull(); + expect(screen.getByRole('alert')).toHaveTextContent(en.session.expired); + expect(screen.queryByText(en.session.failed)).toBeNull(); + expect(document.activeElement).toBe(screen.getByRole('alert')); + expect(screen.getByRole('link', { name: en.session.reauthenticate })).toHaveAttribute( + 'href', + '/api/auth/signin?returnTo=%2Fstudio', + ); + expect(extendBffSession).not.toHaveBeenCalled(); + }); + it('joins one explicit extension, displays the actual later expiry and prevents double submission', async () => { + const value = initial(); + let complete!: (value: ReturnType) => void; + vi.mocked(extendBffSession).mockReturnValue( + new Promise((resolve) => { + complete = resolve; + }), + ); + view(value); + const button = screen.getByRole('button', { name: en.session.extend }); + fireEvent.click(button); + fireEvent.click(button); + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(button).toBeEnabled(); + expect(button).toHaveAttribute('aria-busy', 'true'); + expect(extendBffSession).toHaveBeenCalledExactlyOnceWith(value.csrfToken); + await act(async () => { + complete({ ...value, expiresAt: value.expiresAt + 60_000 }); + }); + expect(screen.getByText(en.session.extended)).toBeInTheDocument(); + expect(document.querySelector('time')).toHaveAttribute( + 'datetime', + new Date(value.expiresAt + 60_000).toISOString(), + ); + }); + it('does not claim extension if the provider deadline did not move', async () => { + const value = initial(); + vi.mocked(extendBffSession).mockResolvedValue(value); + view(value); + screen.getByRole('button', { name: en.session.extend }).focus(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: en.session.extend })); + }); + expect(screen.getByText(en.session.unchanged)).toBeInTheDocument(); + expect(screen.queryByText(en.session.extended)).toBeNull(); + expect(screen.queryByRole('button', { name: en.session.extend })).toBeNull(); + expect(screen.getByRole('status')).toHaveTextContent(en.session.unchanged); + expect(document.activeElement).toBe(screen.getByRole('status')); + }); + it('announces and focuses the maximum notice when a completed extension removes its button', async () => { + const value = initial(); + vi.mocked(extendBffSession).mockResolvedValue({ + ...value, + expiresAt: value.expiresAt + 60_000, + limitedByAbsolute: true, + }); + view(value); + screen.getByRole('button', { name: en.session.extend }).focus(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: en.session.extend })); + }); + expect(screen.getByRole('status')).toHaveTextContent(en.session.maximum); + expect(document.activeElement).toBe(screen.getByRole('status')); + expect(screen.queryByRole('button', { name: en.session.extend })).toBeNull(); + }); + it.each(['expiry', 'unchanged'] as const)( + 'does not steal external focus on %s recovery', + async (kind) => { + vi.useFakeTimers(); + const value = initial(); + vi.mocked(extendBffSession).mockResolvedValue(value); + render(); + view(value); + const outside = screen.getByRole('button', { name: 'Outside session' }); + outside.focus(); + if (kind === 'expiry') + act(() => { + vi.advanceTimersByTime(151_000); + }); + else + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: en.session.extend })); + }); + expect(screen.getByRole(kind === 'expiry' ? 'alert' : 'status')).toBeInTheDocument(); + expect(document.activeElement).toBe(outside); + }, + ); + it('offers reauthentication rather than extension at the known absolute maximum', () => { + view({ ...initial(), limitedByAbsolute: true }); + expect(screen.getByText(en.session.maximum)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: en.session.extend })).toBeNull(); + }); + it('accepts a newly admitted RSC projection and ignores an older in-flight UI result', async () => { + const first = initial(); + let complete!: (value: ReturnType) => void; + vi.mocked(extendBffSession).mockReturnValue( + new Promise((resolve) => { + complete = resolve; + }), + ); + const { rerender } = view(first); + fireEvent.click(screen.getByRole('button', { name: en.session.extend })); + const fresh = { ...first, expiresAt: first.expiresAt + 120_000 }; + rerender( + +

Fresh account view

+
, + ); + await act(async () => { + complete(first); + }); + expect(document.querySelector('time')).toHaveAttribute( + 'datetime', + new Date(fresh.expiresAt).toISOString(), + ); + expect(screen.getByText('Fresh account view')).toBeInTheDocument(); + expect(screen.queryByText(en.session.unchanged)).toBeNull(); + }); + it('hides account content and focuses an accessible recovery notice after an explicit extension failure', async () => { + vi.mocked(extendBffSession).mockRejectedValue(new Error('private-provider-canary')); + view(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: en.session.extend })); + }); + const alert = screen.getByRole('alert'); + expect(document.activeElement).toBe(alert); + expect(screen.queryByText('Admitted account view')).toBeNull(); + expect(screen.getByRole('button', { name: 'Sign out' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: en.session.retry })).toBeInTheDocument(); + expect(document.body.textContent).not.toContain('private-provider-canary'); + }); + it('keeps protected content hidden during recovery and restores it only after a positive extension', async () => { + const value = initial(); + let complete!: (value: ReturnType) => void; + vi.mocked(extendBffSession) + .mockRejectedValueOnce(new Error('unavailable')) + .mockReturnValueOnce( + new Promise((resolve) => { + complete = resolve; + }), + ); + view(value); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: en.session.extend })); + }); + const retry = screen.getByRole('button', { name: en.session.retry }); + retry.focus(); + fireEvent.click(retry); + expect(screen.queryByText('Admitted account view')).toBeNull(); + expect(screen.getByRole('button', { name: 'Sign out' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: en.session.extending })).toHaveAttribute( + 'aria-disabled', + 'true', + ); + await act(async () => { + complete({ ...value, expiresAt: value.expiresAt + 60_000 }); + }); + expect(screen.getByText('Admitted account view')).toBeInTheDocument(); + expect(document.activeElement).toBe(screen.getByRole('button', { name: en.session.extend })); + }); + it('announces and focuses a fresh notice after each failed retry', async () => { + vi.mocked(extendBffSession).mockRejectedValue(new Error('unavailable')); + view(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: en.session.extend })); + }); + const first = screen.getByRole('alert'); + screen.getByRole('button', { name: en.session.retry }).focus(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: en.session.retry })); + }); + expect(screen.getByRole('alert')).not.toBe(first); + expect(document.activeElement).toBe(screen.getByRole('alert')); + expect(screen.queryByText('Admitted account view')).toBeNull(); + }); + it('hydrates the admitted projection without a clock-dependent markup mismatch', async () => { + vi.useFakeTimers(); + vi.setSystemTime(2_000_000_000_000); + const element = ( + +

Admitted account view

+
+ ); + const container = document.createElement('div'); + container.innerHTML = renderToString(element); + document.body.append(container); + vi.setSystemTime(2_000_000_200_000); + const recoverable = vi.fn(); + let root: ReturnType | undefined; + try { + await act(async () => { + root = hydrateRoot(container, element, { onRecoverableError: recoverable }); + }); + expect(recoverable).not.toHaveBeenCalled(); + expect(container.textContent).toContain(en.session.expired); + expect(container.textContent).not.toContain('Admitted account view'); + } finally { + await act(async () => root?.unmount()); + container.remove(); + } + }); + it('drops the earlier success announcement when the next warning window begins', async () => { + vi.useFakeTimers(); + const value = initial(); + vi.mocked(extendBffSession).mockResolvedValue({ + ...value, + expiresAt: value.expiresAt + 60_000, + }); + view(value); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: en.session.extend })); + }); + expect(screen.getByText(en.session.extended)).toBeInTheDocument(); + act(() => { + vi.advanceTimersByTime(91_000); + }); + expect(screen.getByText(en.session.warning)).toBeInTheDocument(); + expect(screen.queryByText(en.session.extended)).toBeNull(); + }); + it('renders a truthful non-JavaScript fallback without an inert extension button', () => { + const html = renderToStaticMarkup( + + + + } + > +

Admitted account view

+
, + ); + expect(html).not.toContain(en.session.extend); + expect(html).toContain(en.session.javascript_required); + expect(html).toContain('/api/auth/signin?returnTo=%2Fportal'); + expect(html).toContain('action="/api/auth/signout"'); + }); +}); diff --git a/frontend/apps/web/src/components/auth/session.tsx b/frontend/apps/web/src/components/auth/session.tsx new file mode 100644 index 00000000..18419fad --- /dev/null +++ b/frontend/apps/web/src/components/auth/session.tsx @@ -0,0 +1,174 @@ +'use client'; + +/* eslint-disable @next/next/no-html-link-for-pages -- Full document navigation prevents protected Router Cache reuse (ADR-0070). */ + +import { useEffect, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; + +import { extendBffSession } from '@/client/bff-session'; +import type { SessionProjection } from '@/server/auth/session-projection'; + +export type SessionLabels = Readonly<{ + warning: string; + until: string; + extend: string; + extending: string; + extended: string; + unchanged: string; + maximum: string; + failed: string; + expired: string; + reauthenticate: string; + retry: string; + javascript_required: string; +}>; + +/** Only a safe projection and already translated labels cross this client boundary. */ +export function SessionControls({ + initial, + labels, + children, + locale, + returnTo, + recoveryActions, +}: { + readonly initial: SessionProjection; + readonly locale: string; + readonly labels: SessionLabels; + readonly children: ReactNode; + readonly returnTo: '/studio' | '/portal'; + readonly recoveryActions?: ReactNode; +}) { + const [session, setSession] = useState(initial); + // The server already admitted this projection. The first client render must + // match its markup; evaluate the display clock after hydration, never as authority. + const [now, setNow] = useState(null); + const [busy, setBusy] = useState(false); + const [interactive, setInteractive] = useState(false); + const [outcome, setOutcome] = useState<'extended' | 'unchanged' | 'failed' | null>(null); + const [failure, setFailure] = useState(0); + const inFlight = useRef(false); + const alive = useRef(true); + const generation = useRef(0); + const container = useRef(null); + const alert = useRef(null); + const status = useRef(null); + const extendButton = useRef(null); + const expired = now !== null && now >= session.expiresAt; + const ended = expired || outcome === 'failed'; + const recovery = session.limitedByAbsolute || outcome === 'unchanged'; + const previousView = useRef({ ended, recovery, failure }); + useEffect(() => { + ++generation.current; + setSession(initial); + setNow(Date.now()); + setOutcome(null); + setFailure(0); + }, [initial]); + useEffect(() => { + alive.current = true; + setInteractive(true); + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => { + alive.current = false; + clearInterval(timer); + }; + }, []); + useEffect(() => { + const changed = + ended !== previousView.current.ended || + (ended && failure !== previousView.current.failure) || + (recovery && !previousView.current.recovery); + previousView.current = { ended, recovery, failure }; + if (!changed) return; + // A removed focused descendant falls back to body. Recover that focus, but + // never interrupt a user who moved to another part of the document. + const focused = document.activeElement; + if (focused !== document.body && !container.current?.contains(focused)) return; + (ended ? alert.current : recovery ? status.current : extendButton.current)?.focus(); + }, [ended, recovery, failure]); + const warning = !ended && now !== null && session.expiresAt - now <= 120_000; + const signin = '/api/auth/signin?returnTo=' + encodeURIComponent(returnTo); + async function extend() { + if (inFlight.current) return; + inFlight.current = true; + setBusy(true); + // A failed check withholds protected content until a later positive result. + // Starting a retry must not briefly restore the previous projection. + setOutcome((previous) => (previous === 'failed' ? previous : null)); + const current = generation.current; + try { + const updated = await extendBffSession(session.csrfToken); + if (!alive.current || current !== generation.current) return; + setOutcome(updated.expiresAt > session.expiresAt ? 'extended' : 'unchanged'); + setSession(updated); + setNow(Date.now()); + } catch { + if (alive.current && current === generation.current) { + setOutcome('failed'); + setFailure((previous) => previous + 1); + } + } finally { + inFlight.current = false; + if (alive.current) setBusy(false); + } + } + return ( +
+
+ {warning &&

{labels.warning}

} + {busy &&

{labels.extending}

} + {!ended && !warning && !busy && outcome === 'extended' &&

{labels.extended}

} +
+

+ {labels.until}:{' '} + +

+ {ended ? ( + <> +

+ {expired ? labels.expired : labels.failed} {labels.reauthenticate} +

+ {interactive && !session.limitedByAbsolute && ( + + )} + + ) : ( + <> + {recovery ? ( +

+ {session.limitedByAbsolute ? labels.maximum : labels.unchanged}{' '} + {labels.reauthenticate} +

+ ) : ( + interactive && ( + + ) + )} + {children} + + )} + {recoveryActions} + +
+ ); +} diff --git a/frontend/apps/web/src/components/auth/shell.test.tsx b/frontend/apps/web/src/components/auth/shell.test.tsx new file mode 100644 index 00000000..9f3c8ef5 --- /dev/null +++ b/frontend/apps/web/src/components/auth/shell.test.tsx @@ -0,0 +1,88 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { generateMetadata as portalMetadata } from '@/app/(portal)/portal/page'; +import { generateMetadata as studioMetadata } from '@/app/(studio)/studio/page'; + +import { AccountShell } from './shell'; +import { extendBffSession } from '../../client/bff-session'; +import en from '../../i18n/messages/en/auth.json'; +import tr from '../../i18n/messages/tr/auth.json'; + +const { resource, session } = vi.hoisted(() => ({ resource: vi.fn(), session: vi.fn() })); +const state = vi.hoisted(() => ({ locale: 'en', active: vi.fn() })); +vi.mock('@/server/public-resource', () => ({ requirePublicResource: resource })); +vi.mock('@/server/public-request', () => ({ assertPublicRequestActive: state.active })); +vi.mock('@/server/auth/auth', () => ({ auth: session })); +vi.mock('@/client/bff-session', () => ({ extendBffSession: vi.fn() })); +vi.mock('next/navigation', () => ({ redirect: vi.fn() })); + +beforeEach(() => { + vi.clearAllMocks(); + resource.mockImplementation(async () => ({ request: { site: { defaultLocale: state.locale } } })); + session.mockResolvedValue({ + state: 'authenticated', + displayName: 'Admitted institution', + expiresAt: Date.now() + 180_000, + csrfToken: 'A'.repeat(43), + limitedByAbsolute: false, + }); + vi.mocked(extendBffSession).mockRejectedValue(new Error('private-provider-canary')); +}); + +describe('protected shell document metadata', () => { + it.each([ + ['en', 'Studio', 'Your portal'], + ['tr-TR', 'Stüdyo', 'Portalınız'], + ])( + 'uses the admitted institution locale %s and keeps both shells out of search', + async (locale, studio, portal) => { + state.locale = locale; + expect(await studioMetadata()).toEqual({ + title: studio, + robots: { index: false, follow: false }, + }); + expect(await portalMetadata()).toEqual({ + title: portal, + robots: { index: false, follow: false }, + }); + expect(state.active).toHaveBeenCalled(); + }, + ); +}); + +it.each([ + ['studio', 'en'], + ['portal', 'en'], + ['studio', 'tr'], + ['portal', 'tr'], +] as const)( + 'keeps %s recovery on its closed route and %s locale after failure', + async (kind, locale) => { + resource.mockResolvedValue({ request: { site: { defaultLocale: locale } } }); + const copy = locale === 'en' ? en : tr; + const element = await AccountShell({ kind }); + const html = renderToStaticMarkup(element); + expect(html).toContain(copy.session.javascript_required); + expect(html).not.toContain(copy.session.extend); + expect(html).toContain('action="/api/auth/signout"'); + render(element); + expect(screen.getByText('Admitted institution', { exact: false })).toBeInTheDocument(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: copy.session.extend })); + }); + expect(screen.queryByText('Admitted institution', { exact: false })).toBeNull(); + expect(screen.getByRole('alert')).toHaveTextContent(copy.session.failed); + const signout = screen.getByRole('button', { name: copy.shell.signout }).closest('form')!; + expect(signout).toHaveAttribute('action', '/api/auth/signout'); + expect(signout).toHaveAttribute('method', 'post'); + expect(signout.querySelector('input[name=csrfToken]')).toHaveValue('A'.repeat(43)); + expect(screen.getByRole('link', { name: copy.session.reauthenticate })).toHaveAttribute( + 'href', + '/api/auth/signin?returnTo=' + encodeURIComponent('/' + kind), + ); + expect(screen.getByRole('button', { name: copy.session.retry })).toBeEnabled(); + expect(document.body.textContent).not.toContain('private-provider-canary'); + }, +); diff --git a/frontend/apps/web/src/components/auth/shell.tsx b/frontend/apps/web/src/components/auth/shell.tsx new file mode 100644 index 00000000..88e962e3 --- /dev/null +++ b/frontend/apps/web/src/components/auth/shell.tsx @@ -0,0 +1,71 @@ +import 'server-only'; + +/* eslint-disable @next/next/no-html-link-for-pages -- Full document navigation prevents protected Router Cache reuse (ADR-0070). */ + +import { redirect } from 'next/navigation'; + +import { SessionControls } from '@/components/auth/session'; +import { createAuthTranslator } from '@/i18n/auth-catalogues'; +import { auth } from '@/server/auth/auth'; +import { assertPublicRequestActive } from '@/server/public-request'; +import { requirePublicResource } from '@/server/public-resource'; + +export async function AccountShell({ kind }: { readonly kind: 'studio' | 'portal' }) { + const returnTo = kind === 'studio' ? '/studio' : '/portal'; + const { request } = await requirePublicResource(); + const session = await auth(); + assertPublicRequestActive(request); + if (!session) redirect('/api/auth/signin?returnTo=' + encodeURIComponent(returnTo)); + const { locale, direction, copy } = createAuthTranslator(request.site.defaultLocale); + const labels = { + warning: copy('session.warning'), + until: copy('session.until'), + extend: copy('session.extend'), + extending: copy('session.extending'), + extended: copy('session.extended'), + unchanged: copy('session.unchanged'), + maximum: copy('session.maximum'), + failed: copy('session.failed'), + expired: copy('session.expired'), + reauthenticate: copy('session.reauthenticate'), + retry: copy('session.retry'), + javascript_required: copy('session.javascript_required'), + }; + return ( +
+

{kind === 'studio' ? copy('shell.studio') : copy('shell.portal')}

+ + + +

{copy('shell.signout_scope')}

+ + } + > +

+ {copy('shell.active')}: {session.displayName} +

+

{copy('shell.account')}

+ +
+
+ ); +} + +export async function accountShellMetadata(kind: 'studio' | 'portal') { + const { request } = await requirePublicResource(); + assertPublicRequestActive(request); + const { copy } = createAuthTranslator(request.site.defaultLocale); + return { + title: kind === 'studio' ? copy('shell.studio') : copy('shell.portal'), + robots: { index: false, follow: false }, + }; +} diff --git a/frontend/apps/web/src/components/auth/signin-link.tsx b/frontend/apps/web/src/components/auth/signin-link.tsx new file mode 100644 index 00000000..a8e6287d --- /dev/null +++ b/frontend/apps/web/src/components/auth/signin-link.tsx @@ -0,0 +1,12 @@ +import 'server-only'; + +/* eslint-disable @next/next/no-html-link-for-pages -- Authentication is an owned full document flow (ADR-0070), never a prefetched route. */ + +import { createAuthTranslator } from '@/i18n/auth-catalogues'; +import { bffSessionEnabled } from '@/server/auth/session-owner'; + +export function SignInLink({ locale }: { readonly locale: string }) { + if (!bffSessionEnabled()) return null; + const { copy } = createAuthTranslator(locale); + return {copy('shell.signin')}; +} diff --git a/frontend/apps/web/src/components/auth/skip-link.tsx b/frontend/apps/web/src/components/auth/skip-link.tsx new file mode 100644 index 00000000..96133cfc --- /dev/null +++ b/frontend/apps/web/src/components/auth/skip-link.tsx @@ -0,0 +1,12 @@ +import 'server-only'; + +import { createPublicTranslator } from '@/i18n/catalogues'; + +export function AuthSkipLink({ locale }: { readonly locale: string }) { + const { locale: uiLocale, direction, t } = createPublicTranslator(locale); + return ( + + {t('chrome.skip_to_content')} + + ); +} diff --git a/frontend/apps/web/src/components/public/chrome.tsx b/frontend/apps/web/src/components/public/chrome.tsx index 8530805e..592814d8 100644 --- a/frontend/apps/web/src/components/public/chrome.tsx +++ b/frontend/apps/web/src/components/public/chrome.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; +import { SignInLink } from '@/components/auth/signin-link'; import type { PublicTranslate } from '@/i18n/catalogues'; import type { TextDirection } from '@/i18n/locale'; import type { PublicSite } from '@/server/public-entry'; @@ -32,6 +33,7 @@ export function PublicChrome({ +
{children} diff --git a/frontend/apps/web/src/components/public/foundation.test.tsx b/frontend/apps/web/src/components/public/foundation.test.tsx index 486d5952..498fe045 100644 --- a/frontend/apps/web/src/components/public/foundation.test.tsx +++ b/frontend/apps/web/src/components/public/foundation.test.tsx @@ -10,6 +10,11 @@ import { ErrorLabelsProvider } from './error-labels'; import { PublicState } from './state'; import { PublicTheme, publicThemeCss } from './theme'; +vi.mock('@/components/auth/signin-link', () => ({ + // eslint-disable-next-line @next/next/no-html-link-for-pages -- The real sign-in control uses full document navigation. + SignInLink: () => Sign in, +})); + const palette = { primary: '#1f6feb', background: '#ffffff', @@ -77,6 +82,7 @@ describe('P02d-6 document foundation', () => { const nav = screen.getByRole('navigation'); expect(nav).toHaveAttribute('lang', 'en'); expect(nav).toHaveAttribute('dir', 'ltr'); + expect(nav).not.toContainElement(screen.getByRole('link', { name: 'Sign in' })); expect(screen.getByRole('link', { name: 'Course catalog' })).toHaveAttribute( 'href', '/ar/courses', diff --git a/frontend/apps/web/src/i18n/auth-catalogues.test.ts b/frontend/apps/web/src/i18n/auth-catalogues.test.ts new file mode 100644 index 00000000..d129bdf4 --- /dev/null +++ b/frontend/apps/web/src/i18n/auth-catalogues.test.ts @@ -0,0 +1,50 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it, vi } from 'vitest'; + +import { createAuthTranslator, getAuthCatalogue } from './auth-catalogues'; +import { checkCatalogues, publicCallsiteKeys } from '../test/ui-catalogue-checks'; +import en from './messages/en/auth.json'; +import tr from './messages/tr/auth.json'; + +function callsites(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return entry.name === 'test' ? [] : callsites(path); + return /\.tsx?$/.test(path) && !/\.(test|spec)\.tsx?$/.test(path) + ? publicCallsiteKeys(readFileSync(path, 'utf8'), path, 'copy') + : []; + }); +} +describe('complete auth UI catalogues and typed callsites', () => { + it('validates en/tr ICU and all auth copy callsites with the same catalogue guard as public UI', () => { + const used = callsites(join(process.cwd(), 'src')); + expect(used.length).toBeGreaterThan(25); + checkCatalogues([en, tr], used); + expect(getAuthCatalogue('tr-Latn-TR').messages).toBe(tr); + expect(getAuthCatalogue('ar').messages).toBe(en); + }); + it('kills missing-from-all, misspelled, dynamic and malformed ICU controls', () => { + for (const source of [ + "copy('shell.unknown');", + "const {copy: labels} = createAuthTranslator(locale); labels('shell.unknown');", + ]) + expect(() => + checkCatalogues([en, tr], publicCallsiteKeys(source, 'test.tsx', 'copy')), + ).toThrow('Used UI key'); + expect(() => publicCallsiteKeys('ui.copy(problem.messageKey)', 'test.tsx', 'copy')).toThrow( + 'literal', + ); + const broken = structuredClone(tr); + broken.session.extend = '{broken'; + expect(() => checkCatalogues([en, broken], [])).toThrow(); + }); + it('keeps fallback whole-catalogue and formatter failures credential-free', () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(createAuthTranslator('tr-TR').copy('signin.title')).toBe(tr.signin.title); + // @ts-expect-error A wire or absent key cannot become an auth UI identifier. + expect(createAuthTranslator('tr').copy('lockey_secret')).toBe(tr.error.description); + expect(log).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/apps/web/src/i18n/auth-catalogues.ts b/frontend/apps/web/src/i18n/auth-catalogues.ts new file mode 100644 index 00000000..cb6a97de --- /dev/null +++ b/frontend/apps/web/src/i18n/auth-catalogues.ts @@ -0,0 +1,29 @@ +import 'server-only'; + +import { createTranslator } from 'next-intl'; + +import { selectUiLocale, textDirection } from './locale'; +import en from './messages/en/auth.json'; +import tr from './messages/tr/auth.json'; + +const catalogues = { en, tr }; +type Leaves = { + [K in keyof T & string]: T[K] extends string ? K : `${K}.${Leaves}`; +}[keyof T & string]; +export type AuthKey = Leaves; +export type AuthCopy = (key: AuthKey) => string; +export function getAuthCatalogue(contentLocale: string) { + const locale = selectUiLocale(contentLocale); + return Object.freeze({ locale, direction: textDirection(locale), messages: catalogues[locale] }); +} +export function createAuthTranslator(contentLocale: string) { + const { locale, messages } = getAuthCatalogue(contentLocale); + const translate = createTranslator({ + locale, + messages: { auth: messages }, + namespace: 'auth', + onError: () => {}, + getMessageFallback: () => messages.error.description, + }); + return Object.freeze({ locale, direction: textDirection(locale), copy: translate as AuthCopy }); +} diff --git a/frontend/apps/web/src/i18n/catalogues.test.ts b/frontend/apps/web/src/i18n/catalogues.test.ts index 40faef08..a3f74126 100644 --- a/frontend/apps/web/src/i18n/catalogues.test.ts +++ b/frontend/apps/web/src/i18n/catalogues.test.ts @@ -1,172 +1,15 @@ 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 { uiLocales } from './locale'; +import { checkCatalogues, publicCallsiteKeys } from '../test/ui-catalogue-checks'; +import type { Catalogue } from '../test/ui-catalogue-checks'; import en from './messages/en/public.json'; import tr from './messages/tr/public.json'; -type Catalogue = Record; - -function flatten(catalogue: Catalogue, prefix = ''): Map { - const entries = new Map(); - 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'); - } - } -} - -/** 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, - 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 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) { - 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.isPropertyAccessExpression(initializer.expression) && - initializer.expression.expression.getText(file) === 'Promise' && - initializer.expression.name.text === 'all' && - initializer.arguments[0] && - ts.isArrayLiteralExpression(initializer.arguments[0]) - ) { - 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)) { - 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.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)) { - if (isTranslator(node.expression)) { - 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 })) { diff --git a/frontend/apps/web/src/i18n/messages/en/auth.json b/frontend/apps/web/src/i18n/messages/en/auth.json new file mode 100644 index 00000000..9c635619 --- /dev/null +++ b/frontend/apps/web/src/i18n/messages/en/auth.json @@ -0,0 +1,41 @@ +{ + "signin": { + "title": "Sign in", + "description": "Continue securely to your institution account.", + "continue": "Continue to sign in", + "unavailable": "Sign-in is unavailable. Please try again later.", + "unavailable_title": "Sign-in unavailable" + }, + "error": { + "title": "Sign-in could not be completed", + "description": "Your session could not be verified. Please try signing in again.", + "retry": "Try signing in again", + "busy": "There are too many sign-in requests. Please wait before trying again." + }, + "shell": { + "studio": "Studio", + "portal": "Your portal", + "account": "Account access", + "active": "Signed-in institution", + "catalog": "Browse courses", + "signin": "Sign in", + "signout": "Sign out", + "signout_scope": "Other signed-in institution tabs may also require sign-in." + }, + "session": { + "unavailable_title": "Account access unavailable", + "unavailable": "Your account could not be checked. You can check again or sign in again.", + "warning": "Your session ends soon", + "until": "Session end time", + "extend": "Try to extend session", + "extending": "Checking your session…", + "extended": "Your session end time has been updated.", + "unchanged": "The end time did not change. Sign in again before it expires.", + "maximum": "This session will end at its maximum lifetime. Sign in again to continue.", + "failed": "Your session could not be extended. Please sign in again.", + "expired": "Your session has ended. Please sign in again.", + "reauthenticate": "Sign in again", + "retry": "Check session again", + "javascript_required": "Session extension requires JavaScript. You can still sign out or sign in again." + } +} diff --git a/frontend/apps/web/src/i18n/messages/tr/auth.json b/frontend/apps/web/src/i18n/messages/tr/auth.json new file mode 100644 index 00000000..2e5cbda8 --- /dev/null +++ b/frontend/apps/web/src/i18n/messages/tr/auth.json @@ -0,0 +1,41 @@ +{ + "signin": { + "title": "Giriş yap", + "description": "Kurum hesabınıza güvenle devam edin.", + "continue": "Giriş yapmaya devam edin", + "unavailable": "Giriş şu anda kullanılamıyor. Lütfen daha sonra tekrar deneyin.", + "unavailable_title": "Giriş kullanılamıyor" + }, + "error": { + "title": "Giriş tamamlanamadı", + "description": "Oturumunuz doğrulanamadı. Lütfen tekrar giriş yapmayı deneyin.", + "retry": "Tekrar giriş yapmayı deneyin", + "busy": "Çok fazla giriş isteği var. Lütfen tekrar denemeden önce bekleyin." + }, + "shell": { + "studio": "Stüdyo", + "portal": "Portalınız", + "account": "Hesap erişimi", + "active": "Oturumun kurumu", + "catalog": "Kurslara göz atın", + "signin": "Giriş yapın", + "signout": "Çıkış yapın", + "signout_scope": "Giriş yaptığınız diğer kurum sekmelerinde de tekrar giriş yapmanız gerekebilir." + }, + "session": { + "unavailable_title": "Hesap erişimi kullanılamıyor", + "unavailable": "Hesabınız kontrol edilemedi. Tekrar kontrol edebilir veya tekrar giriş yapabilirsiniz.", + "warning": "Oturumunuz yakında sona erecek", + "until": "Oturum bitiş saati", + "extend": "Oturumu uzatmayı deneyin", + "extending": "Oturumunuz kontrol ediliyor…", + "extended": "Oturumunuzun bitiş saati güncellendi.", + "unchanged": "Bitiş saati değişmedi. Süre dolmadan tekrar giriş yapın.", + "maximum": "Bu oturum azami süresi dolduğunda sona erecek. Devam etmek için tekrar giriş yapın.", + "failed": "Oturumunuz uzatılamadı. Lütfen tekrar giriş yapın.", + "expired": "Oturumunuz sona erdi. Lütfen tekrar giriş yapın.", + "reauthenticate": "Tekrar giriş yapın", + "retry": "Oturumu tekrar kontrol edin", + "javascript_required": "Oturumu uzatmak için JavaScript gerekir. Yine de çıkış yapabilir veya tekrar giriş yapabilirsiniz." + } +} diff --git a/frontend/apps/web/src/i18n/request.test.ts b/frontend/apps/web/src/i18n/request.test.ts index a29ccfed..935d049d 100644 --- a/frontend/apps/web/src/i18n/request.test.ts +++ b/frontend/apps/web/src/i18n/request.test.ts @@ -2,6 +2,7 @@ import { createTranslator } from 'next-intl'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getAuthCatalogue } from './auth-catalogues'; import * as catalogues from './catalogues'; import en from './messages/en/public.json'; import tr from './messages/tr/public.json'; @@ -45,7 +46,10 @@ describe('verified public next-intl request configuration', () => { expect(dependencies.request).toHaveBeenCalledWith(); expect(dependencies.notFound).not.toHaveBeenCalled(); expect(config.locale).toBe(uiLocale); - expect(config.messages).toEqual({ public: messages }); + expect(config.messages).toEqual({ + public: messages, + auth: getAuthCatalogue(String(locale ?? 'en')).messages, + }); const translate = createTranslator({ ...config, locale: config.locale!, diff --git a/frontend/apps/web/src/i18n/request.ts b/frontend/apps/web/src/i18n/request.ts index 0adad72d..0ca732a4 100644 --- a/frontend/apps/web/src/i18n/request.ts +++ b/frontend/apps/web/src/i18n/request.ts @@ -5,6 +5,7 @@ import { getRequestConfig } from 'next-intl/server'; import { assertPublicRequestActive, getPublicRequest } from '@/server/public-request'; +import { getAuthCatalogue } from './auth-catalogues'; import { getPublicCatalogue, publicFormattingOptions } from './catalogues'; export default getRequestConfig(async () => { @@ -16,7 +17,7 @@ export default getRequestConfig(async () => { const catalogue = getPublicCatalogue(contentLocale); return { locale: catalogue.locale, - messages: { public: catalogue.messages }, + messages: { public: catalogue.messages, auth: getAuthCatalogue(contentLocale).messages }, ...publicFormattingOptions(contentLocale), }; }); diff --git a/frontend/apps/web/src/middleware.test.ts b/frontend/apps/web/src/middleware.test.ts index 739a4fc2..43023977 100644 --- a/frontend/apps/web/src/middleware.test.ts +++ b/frontend/apps/web/src/middleware.test.ts @@ -458,7 +458,11 @@ describe('Node public middleware', () => { it.each(['/studio', '/portal'])( 'requires live bootstrap even for exact scaffold %s', async (target) => { - expect((await middleware(incoming(target))).headers.get('x-middleware-next')).toBe('1'); + const unavailable = await middleware(incoming(target)); + expect(unavailable.status).toBe(503); + expect(unavailable.headers.get('x-middleware-next')).toBeNull(); + expect(unavailable.headers.get('cache-control')).toBe('no-store'); + expect(unavailable.headers.getSetCookie()).toEqual([]); bootstrap.mockResolvedValueOnce({ kind: 'api-error', status: 404, diff --git a/frontend/apps/web/src/middleware.ts b/frontend/apps/web/src/middleware.ts index fda610c9..c062e809 100644 --- a/frontend/apps/web/src/middleware.ts +++ b/frontend/apps/web/src/middleware.ts @@ -1,5 +1,8 @@ import { NextResponse, type NextRequest } from 'next/server'; +import { renderBffDocument } from '@/server/auth/html'; +import { admitBffHttp } from '@/server/auth/http-admission'; +import { bffSessionEnabled } from '@/server/auth/session-owner'; import { createConfiguredPublicClient, publicTraceparent } from '@/server/configured-public-client'; import { INGRESS_HEADER, verifyNextProvenance } from '@/server/ingress'; import { beginPublicAdmission } from '@/server/public-admission'; @@ -92,15 +95,91 @@ export async function middleware(request: NextRequest) { } return finish(refusal(request.method, 503)); } - const entry = publicEntry(context.target, bootstrap.data); + const path = url.pathname; + const authPath = path === '/api/auth' || path.startsWith('/api/auth/'); + // Even authentication actions need the independently resolved live site and + // a valid locale catalogue before any cookie, reservation or provider work. + const entry = publicEntry(authPath ? '/' : context.target, bootstrap.data); if (entry.kind === 'refuse') return finish(refusal(request.method, entry.status)); + // An enabled locale named studio/portal still takes precedence over the + // unlocalized shell, exactly as in public entry admission. + const protectedPath = entry.kind === 'continue' && entry.locale === null; + const enabled = bffSessionEnabled(); + const document = ( + kind: 'unavailable' | 'error' | 'busy' | 'account_unavailable', + status: number, + ) => + new NextResponse( + request.method === 'HEAD' + ? null + : renderBffDocument( + bootstrap.data, + kind === 'account_unavailable' + ? { kind, returnPath: path === '/studio' ? '/studio' : '/portal' } + : { kind }, + ), + { + status, + headers: { + 'cache-control': 'no-store', + 'content-type': 'text/html; charset=utf-8', + 'referrer-policy': 'no-referrer', + 'x-content-type-options': 'nosniff', + 'x-frame-options': 'DENY', + ...(status === 429 || status === 503 ? { 'retry-after': '30' } : {}), + }, + }, + ); + let cookieEdits: readonly string[] = []; + const bffResponse = (response: NextResponse) => { + for (const cookie of cookieEdits) response.headers.append('set-cookie', cookie); + if (enabled) response.headers.set('x-frame-options', 'DENY'); + return response; + }; + if (!enabled && authPath) return finish(refusal(request.method, 404)); + if (!enabled && protectedPath) return finish(document('unavailable', 503)); + if (enabled) { + admission.publish(bootstrap.data); + const bff = await admitBffHttp(request, context, signal, (page) => + renderBffDocument(bootstrap.data, page), + ); + cookieEdits = bff.cookies; + if (bff.response) { + if ( + bff.response.status >= 400 && + bff.response.status !== 404 && + !path.endsWith('/session') && + !path.endsWith('/csrf') && + request.headers.get('accept')?.includes('text/html') + ) { + const response = document( + bff.response.status === 429 ? 'busy' : 'error', + bff.response.status, + ); + for (const cookie of bff.response.headers.getSetCookie()) + response.headers.append('set-cookie', cookie); + return bffResponse(response); + } + return bffResponse(bff.response); + } + if (protectedPath && bff.unavailable) + return bffResponse(document('account_unavailable', 503)); + if (protectedPath && !bff.session) { + const response = NextResponse.redirect( + publicRedirect(context.host, '/api/auth/signin?returnTo=' + encodeURIComponent(path)), + 303, + ); + response.headers.set('cache-control', 'no-store'); + return bffResponse(response); + } + } if (entry.kind === 'redirect') { const response = NextResponse.redirect( publicRedirect(context.host, entry.path), entry.status, ); response.headers.set('cache-control', 'no-store'); - return finish(response); + return enabled ? bffResponse(response) : finish(response); } // Next's pinned adapter hides Flight headers here and restores them after // middleware. Rebuild ordinary headers; protocol restoration is proved by @@ -115,8 +194,8 @@ export async function middleware(request: NextRequest) { request: { headers: downstream }, }); response.headers.set('cache-control', 'no-store'); - admission.publish(bootstrap.data); - return response; + if (!enabled) admission.publish(bootstrap.data); + return bffResponse(response); } catch { try { admission?.refuse(); diff --git a/frontend/apps/web/src/server/auth/account-client.test.ts b/frontend/apps/web/src/server/auth/account-client.test.ts new file mode 100644 index 00000000..eb03ffe3 --- /dev/null +++ b/frontend/apps/web/src/server/auth/account-client.test.ts @@ -0,0 +1,207 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { MAX_PUBLIC_RESPONSE_BYTES } from '../public-api-limits'; +import { createAccountSiteCaller } from './account-client'; + +const configuration = { + apiOrigin: 'http://127.0.0.1:5100', + secret: 'private-hop-'.padEnd(48, 'x'), +}; +const binding = { + host: 'institution.example.test:3000', + source: '203.0.113.19', + traceparent: '00-' + 'a'.repeat(32) + '-' + 'b'.repeat(16) + '-01', +}; +const actorId = '019a154b-0000-7000-8000-000000000011'; +const tenantId = '10000000-0000-4000-8000-000000000001'; +const site = { + displayName: 'School', + enabledLocales: ['en'], + defaultLocale: 'en', + theme: null, + showPlatformAttribution: true, +}; +const account = { actorId, tenantId, organizationId: null, site, extra: 'private-wire-canary' }; +const response = (value: unknown = account, status = 200, media = 'application/json') => + new Response(JSON.stringify(value), { status, headers: { 'content-type': media } }); +const signal = () => new AbortController().signal; +afterEach(() => vi.useRealTimers()); +describe('configured exact AccountSite capability', () => { + it('uses SDK with only server credentials/current host/visitor/trace, and discards additive response members', async () => { + const transport = vi.fn(async () => response()); + const caller = createAccountSiteCaller(configuration, transport); + expect(await caller('private-access-canary', binding, signal())).toEqual({ + actorId, + tenantId, + organizationId: null, + displayName: 'School', + }); + const [url, init] = transport.mock.calls[0]! as unknown as [string, RequestInit]; + expect(url).toBe(configuration.apiOrigin + '/api/v1/account/site'); + expect(init).toMatchObject({ + method: 'GET', + cache: 'no-store', + redirect: 'error', + credentials: 'omit', + }); + expect(Object.fromEntries(new Headers(init.headers))).toEqual({ + accept: 'application/json', + authorization: 'Bearer private-access-canary', + 'x-learnstack-host': binding.host, + 'x-learnstack-hop-secret': configuration.secret, + 'x-learnstack-visitor-address': binding.source, + traceparent: binding.traceparent, + }); + }); + it.each([ + 'https://api.example/path', + 'https://user:pass@api.example', + 'http://remote.example', + 'http://127.0.0.1:5100/', + ])('rejects noncanonical/unqualified origin %s', (apiOrigin) => { + expect(() => createAccountSiteCaller({ ...configuration, apiOrigin }, vi.fn())).toThrow( + 'BFF provider: configuration', + ); + }); + it.each([ + { host: 'UPPER.example', source: binding.source }, + { host: binding.host, source: 'forged' }, + ])('rejects invalid binding %j before transport', async (bad) => { + const transport = vi.fn(); + await expect( + createAccountSiteCaller(configuration, transport)('token', bad, signal()), + ).rejects.toMatchObject({ reason: 'configuration' }); + expect(transport).not.toHaveBeenCalled(); + }); + it('generates a valid trace when the request trace is absent/invalid', async () => { + const transport = vi.fn(async () => response()); + await createAccountSiteCaller(configuration, transport)( + 'token', + { ...binding, traceparent: 'private-canary' }, + signal(), + ); + const init = (transport.mock.calls[0]! as unknown as [string, RequestInit])[1]; + expect(new Headers(init.headers).get('traceparent')).toMatch( + /^00-[0-9a-f]{32}-[0-9a-f]{16}-00$/, + ); + }); + it.each([403, 404])( + 'maps a deliberate API access-boundary refusal %s to identity without exposing a problem payload', + async (status) => { + const problem = { + type: 'https://errors.learnstack.dev/not_found', + title: 'private-title-canary', + instance: '/api/v1/account/site', + status, + code: 'not_found', + messageKey: 'lockey_not_found', + correlationId: 'private-correlation-canary', + }; + await expect( + createAccountSiteCaller(configuration, async () => + response(problem, status, 'application/problem+json'), + )('token', binding, signal()), + ).rejects.toMatchObject({ reason: 'identity', message: 'BFF provider: identity' }); + }, + ); + it.each([400, 401, 405, 409, 422, 429, 500, 502, 503, 504])( + 'maps a valid uncertain API problem %s to availability rather than identity', + async (status) => { + const transport = vi.fn(async () => + response( + { + type: 'https://errors.learnstack.dev/dependency_unavailable', + title: 'private-outage-title', + instance: '/api/v1/account/site', + status, + code: 'dependency_unavailable', + messageKey: 'lockey_dependency_unavailable', + correlationId: 'private-outage-correlation', + }, + status, + 'application/problem+json', + ), + ); + await expect( + createAccountSiteCaller(configuration, transport)('token', binding, signal()), + ).rejects.toMatchObject({ + reason: 'provider_refused', + message: 'BFF provider: provider_refused', + }); + expect(transport).toHaveBeenCalledTimes(1); + }, + ); + it('maps a network rejection without cancellation to a closed transport failure without retry', async () => { + const transport = vi.fn(async () => { + throw new Error('private-network-canary'); + }); + await expect( + createAccountSiteCaller(configuration, transport)('token', binding, signal()), + ).rejects.toMatchObject({ reason: 'transport', message: 'BFF provider: transport' }); + expect(transport).toHaveBeenCalledTimes(1); + }); + it.each([ + response(account, 201), + response(account, 200, 'text/html'), + response({ ...account, actorId: '00000000-0000-0000-0000-000000000000' }), + response({}, 503, 'application/problem+json'), + response({}, 401, 'application/problem+json'), + response({}, 403, 'application/problem+json'), + response({}, 404, 'application/problem+json'), + ])('fails closed on unexpected status/media/identity/problem', async (bad) => { + await expect( + createAccountSiteCaller(configuration, async () => bad)('token', binding, signal()), + ).rejects.toMatchObject({ reason: 'response' }); + }); + it('bounds decoded body bytes at the existing API ceiling and cancels oversize consumption', async () => { + let cancelled = false; + const transport = vi.fn( + async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(MAX_PUBLIC_RESPONSE_BYTES + 1)); + }, + cancel() { + cancelled = true; + }, + }), + { headers: { 'content-type': 'application/json' } }, + ), + ); + await expect( + createAccountSiteCaller(configuration, transport)('token', binding, signal()), + ).rejects.toMatchObject({ reason: 'response' }); + expect(cancelled).toBe(true); + }); + it('distinguishes caller cancellation and its finite deadline with no replay', async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + const transport = vi.fn( + async (_url: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init!.signal!.addEventListener( + 'abort', + () => reject(new Error('private-network-canary')), + { once: true }, + ); + }), + ); + const caller = createAccountSiteCaller(configuration, transport); + const cancelled = expect(caller('token', binding, controller.signal)).rejects.toMatchObject({ + reason: 'cancelled', + }); + controller.abort(); + await cancelled; + const deadline = new AbortController(); + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(deadline.signal); + const timed = expect(caller('token', binding, signal())).rejects.toMatchObject({ + reason: 'deadline', + }); + deadline.abort(); + await timed; + expect(timeout).toHaveBeenCalledWith(10_000); + expect(transport).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/apps/web/src/server/auth/account-client.ts b/frontend/apps/web/src/server/auth/account-client.ts new file mode 100644 index 00000000..cfb8a226 --- /dev/null +++ b/frontend/apps/web/src/server/auth/account-client.ts @@ -0,0 +1,139 @@ +import { randomBytes } from 'node:crypto'; + +import { createAuthenticatedServerSdk } from '@learnstack/sdk/authenticated-server'; + +import { canonicalAddress, normalizeHost, validSecret } from '../ingress.js'; +import { MAX_PUBLIC_RESPONSE_BYTES, PUBLIC_API_DEADLINE_MS } from '../public-api-limits.js'; +import { BffProviderError } from './provider-error.js'; +import type { SessionDependencies } from './session-contract.js'; + +type Configuration = Readonly<{ apiOrigin: string; secret: string }>; +const trace = /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/; +function traceparent(value: string | undefined) { + const matched = value?.match(trace); + return matched && matched[1] !== '0'.repeat(32) && matched[2] !== '0'.repeat(16) + ? value! + : '00-' + randomBytes(16).toString('hex') + '-' + randomBytes(8).toString('hex') + '-00'; +} + +/** Native configured AccountSite capability; no UI-facing credential/header options. */ +export function createAccountSiteCaller( + configuration: Configuration, + transport: typeof fetch, +): SessionDependencies['account'] { + let origin: URL; + try { + origin = new URL(configuration.apiOrigin); + } catch { + throw new BffProviderError('configuration'); + } + const loopback = + origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname); + if ( + origin.origin !== configuration.apiOrigin || + (!loopback && origin.protocol !== 'https:') || + !validSecret(configuration.secret) + ) + throw new BffProviderError('configuration'); + const apiOrigin = origin.origin; + const secret = configuration.secret; + return async (accessToken, binding, caller) => { + if ( + normalizeHost(binding.host) !== binding.host || + canonicalAddress(binding.source) !== binding.source || + typeof accessToken !== 'string' || + !accessToken || + /[\r\n]/.test(accessToken) + ) + throw new BffProviderError('configuration'); + const deadline = AbortSignal.timeout(PUBLIC_API_DEADLINE_MS); + const signal = AbortSignal.any([caller, deadline]); + const interrupted = () => { + if (signal.aborted) throw new BffProviderError(caller.aborted ? 'cancelled' : 'deadline'); + }; + let failure: BffProviderError | undefined; + const sdk = createAuthenticatedServerSdk(async (path, init) => { + let reader: ReadableStreamDefaultReader | undefined; + try { + interrupted(); + if (path !== '/api/v1/account/site' || init.method !== 'GET') + throw new BffProviderError('configuration'); + const response = await transport(apiOrigin + path, { + method: 'GET', + cache: 'no-store', + credentials: 'omit', + redirect: 'error', + signal, + headers: { + Accept: 'application/json', + Authorization: 'Bearer ' + accessToken, + 'X-LearnStack-Host': binding.host, + 'X-LearnStack-Hop-Secret': secret, + 'X-LearnStack-Visitor-Address': binding.source, + traceparent: traceparent(binding.traceparent), + }, + }); + reader = response.body?.getReader(); + interrupted(); + if (response.redirected || (response.status >= 300 && response.status < 400)) + throw new BffProviderError('response'); + const media = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); + if (media !== (response.status === 200 ? 'application/json' : 'application/problem+json')) + throw new BffProviderError('response'); + const chunks: Uint8Array[] = []; + let bytes = 0; + while (reader) { + const chunk = await reader.read(); + interrupted(); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > MAX_PUBLIC_RESPONSE_BYTES) throw new BffProviderError('response'); + chunks.push(chunk.value); + } + const body = new Uint8Array(bytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return new Response([204, 205, 304].includes(response.status) ? null : body, { + status: response.status, + headers: { + 'content-type': media!, + ...(response.headers.has('retry-after') + ? { 'retry-after': response.headers.get('retry-after')! } + : {}), + }, + }); + } catch (error) { + interrupted(); + failure = error instanceof BffProviderError ? error : new BffProviderError('transport'); + throw failure; + } finally { + await reader?.cancel().catch(() => {}); + reader?.releaseLock(); + } + }); + const result = await sdk.getAccountSite({ signal }); + interrupted(); + if (failure) throw failure; + // API 401 includes undecidable credentials. Only its deliberate 403/404 + // access-boundary refusals select the session owner's identity category. + if (result.kind !== 'success') + throw new BffProviderError( + result.kind === 'api-error' && [403, 404].includes(result.status) + ? 'identity' + : result.kind === 'api-error' + ? 'provider_refused' + : result.kind === 'transport-error' + ? 'transport' + : 'response', + ); + return Object.freeze({ + actorId: result.data.actorId.toLowerCase(), + tenantId: result.data.tenantId.toLowerCase(), + organizationId: result.data.organizationId?.toLowerCase() ?? null, + displayName: result.data.site.displayName, + }); + }; +} diff --git a/frontend/apps/web/src/server/auth/account-site-policy.test.ts b/frontend/apps/web/src/server/auth/account-site-policy.test.ts new file mode 100644 index 00000000..705cd511 --- /dev/null +++ b/frontend/apps/web/src/server/auth/account-site-policy.test.ts @@ -0,0 +1,301 @@ +// @vitest-environment node +import { IncomingMessage, ServerResponse } from 'node:http'; +import { Socket } from 'node:net'; + +import { NextRequest } from 'next/server'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createAccountSiteCaller } from './account-client'; +import { BFF_COOKIES } from './cookies'; +import { createBffRequestRuntime } from './request-runtime'; +import { middleware } from '../../middleware'; +import { bffFixture, bffHost, bffRequest, bffSource } from '../../test/bff-fixture'; +import { INGRESS_HEADER, mintProvenance } from '../ingress'; +import { createPublicAdmissionRuntime } from '../public-admission-runtime'; + +const { getSite } = vi.hoisted(() => ({ getSite: vi.fn() })); +vi.mock('../configured-public-client', () => ({ + createConfiguredPublicClient: () => ({ getSite }), + publicTraceparent: () => '00-' + 'a'.repeat(32) + '-' + 'b'.repeat(16) + '-00', +})); + +const site = { + displayName: 'Institution', + enabledLocales: ['en'], + defaultLocale: 'en', + theme: null, + showPlatformAttribution: true, +}; +const account = { + actorId: '019a154b-0000-7000-8000-000000000011', + tenantId: '10000000-0000-4000-8000-000000000001', + organizationId: null, + site, +}; +const secret = 'policy-test-hop-'.padEnd(48, 'x'); +const fixtures: ReturnType[] = []; +const releases: (() => void)[] = []; +function gate() { + let release!: () => void; + const promise = new Promise((resolve) => { + release = resolve; + }); + releases.push(release); + return { promise, release }; +} +function policyFixture() { + let status = 200; + let held: ReturnType | undefined; + let now = 0; + const transport = vi.fn(async () => { + const observed = status; + await held?.promise; + return new Response( + JSON.stringify( + observed === 200 + ? account + : { + type: 'https://errors.learnstack.dev/unauthorized', + title: 'private-authority-canary', + status: observed, + code: 'unauthorized', + messageKey: 'lockey_unauthorized', + correlationId: 'private-correlation-canary', + instance: '/api/v1/account/site', + }, + ), + { + status: observed, + headers: { + 'content-type': observed === 200 ? 'application/json' : 'application/problem+json', + }, + }, + ); + }); + const caller = createAccountSiteCaller({ apiOrigin: 'http://127.0.0.1:5100', secret }, transport); + const f = bffFixture({ account: caller }); + fixtures.push(f); + for (const method of ['redeem', 'refresh'] as const) { + const original = f.provider[method].getMockImplementation()!; + f.provider[method].mockImplementation(async () => ({ + ...(await original()), + refreshExpiresAt: now + 24 * 60 * 60_000, + })); + } + return { + ...f, + transport, + advance(ms: number) { + now += ms; + f.advance(ms); + }, + response(value: number, wait?: ReturnType) { + status = value; + held = wait; + }, + }; +} +function middlewareHarness(f: ReturnType) { + vi.stubEnv('LEARNSTACK_PUBLIC_HOP_SECRET', secret); + getSite.mockResolvedValue({ kind: 'success', status: 200, data: site }); + const publicRuntime = createPublicAdmissionRuntime(true); + const requestRuntime = createBffRequestRuntime(); + const original = Object.getOwnPropertyDescriptor; + const holder = Object.freeze({ version: 1, store: Object.freeze(f.store), http: f.http }); + vi.spyOn(Object, 'getOwnPropertyDescriptor').mockImplementation((object, key) => { + const selected = + key === Symbol.for('learnstack.public-admission.v1') + ? publicRuntime.holder + : key === Symbol.for('learnstack.bff-request.v1') + ? requestRuntime.holder + : key === Symbol.for('learnstack.bff-sessions.v1') + ? holder + : null; + return object === globalThis && selected + ? { value: selected, writable: false, enumerable: false, configurable: false } + : original(object, key); + }); + return { + async run(target: string, cookie: string) { + const context = { host: bffHost, peer: bffSource, method: 'GET', target }; + const headers = new Headers({ + [INGRESS_HEADER]: mintProvenance(context, secret), + cookie, + 'sec-fetch-dest': 'document', + }); + const request = new IncomingMessage(new Socket()); + request.headers = Object.fromEntries(headers); + const response = new ServerResponse(request); + try { + return await publicRuntime.run(context, request, response, () => + requestRuntime.run(context, request, response, async () => { + const result = await middleware( + new NextRequest('https://' + bffHost + target, { headers }), + ); + return { response: result, session: requestRuntime.holder.read(context).session }; + }), + ); + } finally { + response.emit('finish'); + request.socket.destroy(); + } + }, + stop() { + requestRuntime.stop(); + publicRuntime.shutdown(); + }, + }; +} +afterEach(async () => { + for (const release of releases.splice(0)) release(); + await Promise.all(fixtures.splice(0).map((f) => f.store.stop())); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + getSite.mockReset(); +}); + +describe('AccountSite authority status through caller, owner and HTTP response', () => { + it.each(['/en/courses', '/studio'])( + 'held uncertain 401 on %s withholds checked content without touching idle or consuming another grant', + async (target) => { + const f = policyFixture(); + const current = await f.login(); + const before = f.store.peek(current.binding)!; + f.advance(120_000); + const held = gate(); + f.response(401, held); + const h = middlewareHarness(f); + try { + const pending = h.run(target, current.cookie); + await vi.waitFor(() => expect(f.transport).toHaveBeenCalledTimes(2)); + expect(f.store.counts()).toMatchObject({ owners: 1, credentialRecords: 1, operations: 1 }); + f.advance(30_000); + held.release(); + const refused = await pending; + expect(refused.session).toBeNull(); + expect(refused.response.status).toBe(target === '/studio' ? 503 : 200); + expect(refused.response.headers.get('x-middleware-next')).toBe( + target === '/studio' ? null : '1', + ); + expect(refused.response.headers.getSetCookie()).toEqual([]); + expect(refused.response.headers.get('cache-control')).toBe('no-store'); + expect(f.store.peek(current.binding)).toEqual(before); + expect(f.store.counts()).toMatchObject({ owners: 1, credentialRecords: 1, operations: 0 }); + expect(f.provider.redeem).toHaveBeenCalledTimes(1); + expect(f.provider.refresh).not.toHaveBeenCalled(); + expect(f.transport).toHaveBeenCalledTimes(2); + + f.response(200); + f.advance(60_000); + const recovered = await h.run(target, current.cookie); + expect(recovered.response.status).toBe(200); + expect(recovered.session).toMatchObject({ + state: 'authenticated', + expiresAt: before.expiresAt + 210_000, + }); + expect(recovered.response.headers.getSetCookie()).toEqual([]); + expect(f.store.peek(current.binding)).toEqual(recovered.session); + expect(f.store.counts()).toMatchObject({ owners: 1, credentialRecords: 1 }); + expect(f.provider.begin).toHaveBeenCalledTimes(1); + expect(f.provider.redeem).toHaveBeenCalledTimes(1); + expect(f.provider.refresh).not.toHaveBeenCalled(); + } finally { + h.stop(); + } + }, + ); + + it.each([false, true])( + 'initial callback 401 refuses publication and preserves any prior replacement: %s', + async (replacing) => { + const f = policyFixture(); + const prior = replacing ? await f.login() : null; + const before = prior && f.store.peek(prior.binding); + const started = await f.store.start({ + host: bffHost, + source: bffSource, + returnPath: '/studio', + ...(prior ? { browser: prior.binding.browser, handle: prior.binding.handle } : {}), + }); + const held = gate(); + f.response(401, held); + const pending = f.http.handle( + bffRequest('/api/auth/callback/keycloak?state=' + started.state + '&code=owned', { + cookie: + BFF_COOKIES.browser + + '=' + + started.browser + + '; ' + + BFF_COOKIES.login + + started.state + + '=' + + started.proof, + }), + ); + await vi.waitFor(() => expect(f.transport).toHaveBeenCalledTimes(prior ? 2 : 1)); + expect(f.store.counts()).toMatchObject({ + owners: prior ? 2 : 1, + pending: 1, + operations: 1, + credentialRecords: prior ? 1 : 0, + }); + held.release(); + const response = (await pending)!; + expect(response.status).toBe(503); + expect(response.headers.get('retry-after')).toBe('30'); + expect(await response.json()).toEqual({ code: 'provider_refused' }); + expect(response.headers.getSetCookie()).toEqual([ + BFF_COOKIES.login + started.state + '=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax', + ]); + expect(f.store.counts()).toMatchObject({ + owners: prior ? 1 : 0, + pending: 0, + operations: 0, + credentialRecords: prior ? 1 : 0, + transactionRecords: 0, + proofRecords: 0, + loginStates: 0, + }); + if (prior) expect(f.store.peek(prior.binding)).toEqual(before); + expect(f.provider.redeem).toHaveBeenCalledTimes(prior ? 2 : 1); + expect(f.provider.refresh).not.toHaveBeenCalled(); + }, + ); + + it('post-refresh AccountSite 401 returns availability status but revokes the unvalidated revision without replay', async () => { + const f = policyFixture(); + const current = await f.login(); + const held = gate(); + f.response(401, held); + const pending = f.http.handle( + bffRequest('/api/auth/extend', { + method: 'POST', + cookie: current.cookie, + form: new URLSearchParams({ csrfToken: current.finished.session.csrfToken }).toString(), + }), + ); + await vi.waitFor(() => expect(f.transport).toHaveBeenCalledTimes(2)); + expect(f.provider.refresh).toHaveBeenCalledTimes(1); + expect(f.store.counts()).toMatchObject({ owners: 1, operations: 1, credentialRecords: 1 }); + held.release(); + const response = (await pending)!; + expect(response.status).toBe(503); + expect(response.headers.get('retry-after')).toBe('30'); + expect(await response.json()).toEqual({ code: 'provider_refused' }); + expect(response.headers.getSetCookie()).toEqual([]); + expect(f.store.peek(current.binding)).toBeNull(); + expect(Object.values(f.store.counts()).every((value) => value === 0)).toBe(true); + const refused = await f.http.handle( + bffRequest('/api/auth/extend', { + method: 'POST', + cookie: current.cookie, + form: new URLSearchParams({ csrfToken: current.finished.session.csrfToken }).toString(), + }), + ); + expect(refused!.status).toBe(403); + expect(await refused!.json()).toEqual({ code: 'csrf' }); + expect(f.provider.refresh).toHaveBeenCalledTimes(1); + expect(f.provider.redeem).toHaveBeenCalledTimes(1); + expect(f.transport).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/apps/web/src/server/auth/auth.test.ts b/frontend/apps/web/src/server/auth/auth.test.ts new file mode 100644 index 00000000..b150169d --- /dev/null +++ b/frontend/apps/web/src/server/auth/auth.test.ts @@ -0,0 +1,98 @@ +// @vitest-environment node +import { EventEmitter } from 'node:events'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { INGRESS_HEADER, mintProvenance } from '../ingress'; +import { auth } from './auth'; +import { createBffRequestRuntime } from './request-runtime'; + +const incoming = vi.hoisted(() => ({ value: new Headers() })); +vi.mock('next/headers', () => ({ headers: async () => incoming.value })); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); +describe('read-only app-local auth()', () => { + it('uses actual signed provenance and native ALS; repeated RSC reads cannot refresh, touch or write cookies', async () => { + const secret = 'test-hop-'.padEnd(48, 'x'); + vi.stubEnv('LEARNSTACK_PUBLIC_HOP_SECRET', secret); + const binding = { + host: 'institution.example.test:3000', + peer: '203.0.113.1', + method: 'GET', + target: '/studio', + }; + incoming.value = new Headers({ [INGRESS_HEADER]: mintProvenance(binding, secret) }); + const runtime = createBffRequestRuntime(); + const original = Object.getOwnPropertyDescriptor; + vi.spyOn(Object, 'getOwnPropertyDescriptor').mockImplementation((object, key) => + object === globalThis && key === Symbol.for('learnstack.bff-request.v1') + ? { value: runtime.holder, configurable: false, enumerable: false, writable: false } + : original(object, key), + ); + const request = Object.assign(new EventEmitter(), { aborted: false, headers: {} }); + const response = Object.assign(new EventEmitter(), { destroyed: false, writableEnded: false }); + const session = { + state: 'authenticated' as const, + displayName: 'School', + expiresAt: Date.now() + 60_000, + limitedByAbsolute: false, + csrfToken: 'A'.repeat(43), + }; + await runtime.run(binding, request, response, async () => { + runtime.holder.publish(binding, session); + const first = await auth(); + const second = await auth(); + expect(first).toBe(second); + expect(Object.isFrozen(first)).toBe(true); + expect(first).toEqual(session); + response.emit('finish'); + await expect(auth()).rejects.toThrow('BFF request context unavailable'); + }); + runtime.stop(); + }); + it('a forged envelope cannot turn request headers into session authority', async () => { + const secret = 'test-hop-'.padEnd(48, 'x'); + vi.stubEnv('LEARNSTACK_PUBLIC_HOP_SECRET', secret); + const binding = { + host: 'institution.example.test:3000', + peer: '203.0.113.1', + method: 'GET', + target: '/studio', + }; + const runtime = createBffRequestRuntime(); + const read = vi.fn(runtime.holder.read); + const holder = Object.freeze({ ...runtime.holder, read }); + const original = Object.getOwnPropertyDescriptor; + vi.spyOn(Object, 'getOwnPropertyDescriptor').mockImplementation((object, key) => + object === globalThis && key === Symbol.for('learnstack.bff-request.v1') + ? { value: holder, configurable: false, enumerable: false, writable: false } + : original(object, key), + ); + const request = Object.assign(new EventEmitter(), { aborted: false, headers: {} }); + const response = Object.assign(new EventEmitter(), { destroyed: false, writableEnded: false }); + const session = { + state: 'authenticated' as const, + displayName: 'School', + expiresAt: Date.now() + 60_000, + limitedByAbsolute: false, + csrfToken: 'A'.repeat(43), + }; + try { + await runtime.run(binding, request, response, async () => { + runtime.holder.publish(binding, session); + incoming.value = new Headers({ [INGRESS_HEADER]: mintProvenance(binding, secret) }); + expect(await auth()).toEqual(session); + expect(read).toHaveBeenCalledOnce(); + read.mockClear(); + incoming.value = new Headers({ [INGRESS_HEADER]: 'forged' }); + await expect(auth()).rejects.toThrow('BFF request context unavailable'); + expect(read).not.toHaveBeenCalled(); + response.emit('finish'); + }); + } finally { + runtime.stop(); + } + }); +}); diff --git a/frontend/apps/web/src/server/auth/auth.ts b/frontend/apps/web/src/server/auth/auth.ts new file mode 100644 index 00000000..ca07287e --- /dev/null +++ b/frontend/apps/web/src/server/auth/auth.ts @@ -0,0 +1,20 @@ +import 'server-only'; + +import { headers } from 'next/headers'; + +import { INGRESS_HEADER, verifyProvenance } from '../ingress'; +import { bffRequestOwner } from './request-owner'; +import type { SessionProjection } from './session-contract'; + +/** Read-only RSC facade. No cookies, refresh, provider call or idle mutation. */ +export async function auth(): Promise { + const incoming = await headers(); + const binding = verifyProvenance( + incoming.get(INGRESS_HEADER), + process.env.LEARNSTACK_PUBLIC_HOP_SECRET, + ); + if (!binding) throw new Error('BFF request context unavailable'); + const read = bffRequestOwner().read(binding); + read.assertActive(); + return read.session; +} diff --git a/frontend/apps/web/src/server/auth/configuration.test.ts b/frontend/apps/web/src/server/auth/configuration.test.ts new file mode 100644 index 00000000..12789498 --- /dev/null +++ b/frontend/apps/web/src/server/auth/configuration.test.ts @@ -0,0 +1,185 @@ +// @vitest-environment node +import { + chmodSync, + linkSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { BffConfigurationError, bffConfiguration } from './configuration'; + +const roots: string[] = []; +const fields = { + LEARNSTACK_BFF_ENABLED: 'true', + LEARNSTACK_BFF_ISSUER: 'https://identity.example.test/realms/learnstack', + KEYCLOAK_WEB_CLIENT_SECRET: 'private-client-canary', +}; +function fixture(values: Record = {}) { + const root = mkdtempSync(join(tmpdir(), 'learnstack-bff-config-')); + roots.push(root); + mkdirSync(join(root, 'frontend/apps/web'), { recursive: true }); + const write = (path: string, entries: Record) => + writeFileSync( + join(root, path), + Object.entries(entries) + .map(([key, value]) => key + '=' + value) + .join('\n') + '\n', + { mode: 0o600 }, + ); + if (Object.keys(values).length) write('.env', values); + return { root, write }; +} +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); +describe('atomic optional native BFF configuration', () => { + it.each([ + ['activation', { LEARNSTACK_BFF_ENABLED: 'ambiguous-private-canary' }], + ['issuer', { ...fields, LEARNSTACK_BFF_ISSUER: 'private-malformed-issuer-canary' }], + ['client_secret', { ...fields, KEYCLOAK_WEB_CLIENT_SECRET: '' }], + ] as const)('reports the closed %s startup issue without input details', (issue, environment) => { + const f = fixture(); + let error: unknown; + try { + bffConfiguration(f.root, environment); + } catch (value) { + error = value; + } + expect(error).toBeInstanceOf(BffConfigurationError); + expect(error).toMatchObject({ issue, reason: 'configuration' }); + expect((error as Error).message).toBe('BFF provider: configuration'); + expect(JSON.stringify(error)).not.toContain('private-canary'); + expect(JSON.stringify(error)).not.toContain(f.root); + }); + it('absent/false remains public-only even with unrelated or conflicting secrets', () => { + const f = fixture({ KEYCLOAK_WEB_CLIENT_SECRET: 'old-private' }); + expect(bffConfiguration(f.root, {})).toEqual({ enabled: false }); + expect( + bffConfiguration(f.root, { + LEARNSTACK_BFF_ENABLED: 'false', + KEYCLOAK_WEB_CLIENT_SECRET: 'different-private', + }), + ).toEqual({ enabled: false }); + }); + it.each(['absent', 'false'] as const)( + 'keeps the regular bounded-file guard in %s public-only mode', + (mode) => { + const f = fixture( + mode === 'false' ? { LEARNSTACK_BFF_ENABLED: 'false' } : { PUBLIC_SETTING: 'value' }, + ); + const path = join(f.root, '.env'); + // Public-only mode permits a regular non-private file, but never follows links. + chmodSync(path, 0o644); + expect(bffConfiguration(f.root, {})).toEqual({ enabled: false }); + rmSync(path); + f.write('source', { PUBLIC_SETTING: 'value' }); + symlinkSync(join(f.root, 'source'), path); + expect(() => bffConfiguration(f.root, {})).toThrowError( + expect.objectContaining({ reason: 'configuration', issue: 'source_file' }), + ); + expect(lstatSync(path).isSymbolicLink()).toBe(true); + expect(readFileSync(join(f.root, 'source'), 'utf8')).toBe('PUBLIC_SETTING=value\n'); + }, + ); + it('accepts agreeing private sources without mutating files or contacting a provider', () => { + const f = fixture(fields); + f.write('frontend/apps/web/.env.local', fields); + const before = readFileSync(join(f.root, '.env')); + expect(bffConfiguration(f.root, fields)).toEqual({ + enabled: true, + issuer: fields.LEARNSTACK_BFF_ISSUER, + clientSecret: fields.KEYCLOAK_WEB_CLIENT_SECRET, + allowLoopbackHttp: false, + }); + expect(readFileSync(join(f.root, '.env'))).toEqual(before); + }); + it.each(['', '1', 'TRUE', 'enabled'])('refuses ambiguous opt-in %s', (value) => { + const f = fixture(); + expect(() => bffConfiguration(f.root, { ...fields, LEARNSTACK_BFF_ENABLED: value })).toThrow( + 'BFF provider: configuration', + ); + }); + it.each([ + 'LEARNSTACK_BFF_ENABLED', + 'LEARNSTACK_BFF_ISSUER', + 'KEYCLOAK_WEB_CLIENT_SECRET', + ] as const)('refuses conflicting %s before constructing an owner', (key) => { + const f = fixture(fields); + expect(() => bffConfiguration(f.root, { [key]: 'different-private-canary' })).toThrow( + 'BFF provider: configuration', + ); + try { + bffConfiguration(f.root, { [key]: 'different-private-canary' }); + } catch (error) { + expect(error).toMatchObject({ issue: 'source_conflict' }); + } + }); + it.each(['LEARNSTACK_BFF_ISSUER', 'KEYCLOAK_WEB_CLIENT_SECRET'] as const)( + 'true requires %s', + (key) => { + const f = fixture(); + const env = { ...fields }; + delete (env as Partial)[key]; + expect(() => bffConfiguration(f.root, env)).toThrow('BFF provider: configuration'); + }, + ); + it.each(['symlink', 'mode', 'hardlink'] as const)( + 'refuses unsafe %s without silently repairing it', + (kind) => { + const f = fixture(fields); + const path = join(f.root, '.env'); + const before = readFileSync(path); + if (kind === 'mode') chmodSync(path, 0o644); + if (kind === 'hardlink') linkSync(path, join(f.root, 'alias')); + if (kind === 'symlink') { + rmSync(path); + f.write('source', fields); + symlinkSync(join(f.root, 'source'), path); + } + const metadata = lstatSync(path); + expect(() => bffConfiguration(f.root, {})).toThrow('BFF provider: configuration'); + try { + bffConfiguration(f.root, {}); + } catch (error) { + expect(error).toMatchObject({ issue: kind === 'symlink' ? 'source_file' : 'private_file' }); + } + expect(readFileSync(path)).toEqual(before); + expect(lstatSync(path)).toMatchObject({ + mode: metadata.mode, + nlink: metadata.nlink, + ino: metadata.ino, + uid: metadata.uid, + }); + }, + ); + it('permits loopback HTTP only in the explicit development process and only in the tenant realm', () => { + const f = fixture(); + const env = { ...fields, LEARNSTACK_BFF_ISSUER: 'http://localhost:8080/realms/learnstack' }; + expect(() => bffConfiguration(f.root, env)).toThrow(); + expect(bffConfiguration(f.root, { ...env, NODE_ENV: 'development' })).toMatchObject({ + enabled: true, + allowLoopbackHttp: true, + }); + for (const issuer of [ + 'http://remote.example/realms/learnstack', + 'https://identity.example/realms/learnstack-hub', + 'https://identity.example/realms/learnstack/', + ]) + expect(() => + bffConfiguration(f.root, { + ...env, + NODE_ENV: 'development', + LEARNSTACK_BFF_ISSUER: issuer, + }), + ).toThrow(); + }); +}); diff --git a/frontend/apps/web/src/server/auth/configuration.ts b/frontend/apps/web/src/server/auth/configuration.ts new file mode 100644 index 00000000..6ae527cf --- /dev/null +++ b/frontend/apps/web/src/server/auth/configuration.ts @@ -0,0 +1,123 @@ +import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { parseLocalEnvironment } from '../ingress.js'; +import { BffProviderError } from './provider-error.js'; + +export const BFF_ENV_KEYS = Object.freeze([ + 'LEARNSTACK_BFF_ENABLED', + 'LEARNSTACK_BFF_ISSUER', + 'KEYCLOAK_WEB_CLIENT_SECRET', +] as const); +export type BffConfiguration = Readonly< + | { enabled: false } + | { enabled: true; issuer: string; clientSecret: string; allowLoopbackHttp: boolean } +>; +export type BffConfigurationIssue = + | 'activation' + | 'source_conflict' + | 'private_file' + | 'issuer' + | 'client_secret' + | 'source_file'; + +/** Closed startup context; contains no source path, setting name or private value. */ +export class BffConfigurationError extends BffProviderError { + constructor(readonly issue: BffConfigurationIssue) { + super('configuration'); + } +} + +const refused = (issue: BffConfigurationIssue): never => { + throw new BffConfigurationError(issue); +}; +const maximumBytes = 256 * 1024; +function source(path: string) { + let descriptor: number | undefined; + try { + descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const state = fstatSync(descriptor); + if (!state.isFile() || state.size > maximumBytes) return refused('source_file'); + const bytes = Buffer.alloc(maximumBytes + 1); + let size = 0; + while (size < bytes.length) { + const count = readSync(descriptor, bytes, size, bytes.length - size, null); + if (count === 0) break; + size += count; + } + if (size > maximumBytes) return refused('source_file'); + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, size)); + return { + values: parseLocalEnvironment(text), + privateFile: + state.uid === process.getuid?.() && state.nlink === 1 && (state.mode & 0o077) === 0, + }; + } catch (error) { + if ( + descriptor === undefined && + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) + return { values: {}, privateFile: true }; + return refused('source_file'); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +/** Native-only startup selection. Disabled mode never returns an issuer or secret. */ +export function bffConfiguration( + root: string, + environment: Readonly>, +): BffConfiguration { + try { + const sources = [ + source(resolve(root, '.env')), + source(resolve(root, 'frontend/apps/web/.env.local')), + ]; + const agree = (key: (typeof BFF_ENV_KEYS)[number]) => { + const values = [...sources.map((source) => source.values[key]), environment[key]].filter( + (value) => value !== undefined, + ); + if (values.some((value) => typeof value !== 'string') || new Set(values).size > 1) + return refused('source_conflict'); + return values[0]; + }; + const enabled = agree('LEARNSTACK_BFF_ENABLED'); + if (enabled !== undefined && enabled !== 'true' && enabled !== 'false') + return refused('activation'); + if (enabled !== 'true') return Object.freeze({ enabled: false }); + if (sources.some((source) => !source.privateFile)) return refused('private_file'); + const issuer = agree('LEARNSTACK_BFF_ISSUER'); + const clientSecret = agree('KEYCLOAK_WEB_CLIENT_SECRET'); + if (!issuer) return refused('issuer'); + if (!clientSecret || /[\r\n\0]/.test(clientSecret)) return refused('client_secret'); + let url: URL; + try { + url = new URL(issuer); + } catch { + return refused('issuer'); + } + const allowLoopbackHttp = environment.NODE_ENV === 'development'; + if ( + url.href !== issuer || + url.pathname !== '/realms/learnstack' || + url.search || + url.hash || + url.username || + url.password || + !( + url.protocol === 'https:' || + (allowLoopbackHttp && + url.protocol === 'http:' && + ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) + ) + ) + return refused('issuer'); + return Object.freeze({ enabled: true, issuer, clientSecret, allowLoopbackHttp }); + } catch (error) { + if (error instanceof BffConfigurationError) throw error; + return refused('source_file'); + } +} diff --git a/frontend/apps/web/src/server/auth/cookies.ts b/frontend/apps/web/src/server/auth/cookies.ts new file mode 100644 index 00000000..11b51f86 --- /dev/null +++ b/frontend/apps/web/src/server/auth/cookies.ts @@ -0,0 +1,92 @@ +import { AUTH_INPUT_LIMIT } from './http-paths.js'; +import { isBffOpaque } from './session-store.js'; + +export { AUTH_INPUT_LIMIT } from './http-paths.js'; +export const BFF_COOKIES = Object.freeze({ + session: '__Host-ls-session', + browser: '__Host-ls-browser', + csrf: '__Host-ls-csrf', + login: '__Host-ls-login-', + logout: '__Host-ls-logout-', +}); +const prefix = '__Host-ls-'; +const fixed = new Set([BFF_COOKIES.session, BFF_COOKIES.browser, BFF_COOKIES.csrf]); +export class BffInputError extends Error { + constructor() { + super('BFF input refused'); + this.name = 'BffInputError'; + } +} + +/** Parse only the closed auth family. No decoding, aliases or chunk reconstruction. */ +export function bffCookies(header: string | null): ReadonlyMap { + const values = new Map(); + let bytes = 0; + for (const member of (header ?? '').split(';')) { + const pair = member.trim(); + const equal = pair.indexOf('='); + const name = equal < 0 ? pair : pair.slice(0, equal); + if (!name.startsWith(prefix)) continue; + bytes += Buffer.byteLength(pair) + 2; + if (bytes > AUTH_INPUT_LIMIT || equal < 0 || values.has(name)) throw new BffInputError(); + const transaction = name.startsWith(BFF_COOKIES.login) + ? name.slice(BFF_COOKIES.login.length) + : name.startsWith(BFF_COOKIES.logout) + ? name.slice(BFF_COOKIES.logout.length) + : null; + const value = pair.slice(equal + 1); + if ((!fixed.has(name) && !isBffOpaque(transaction)) || !isBffOpaque(value)) + throw new BffInputError(); + values.set(name, value); + } + return values; +} + +export function bffCookie(name: string, value: string, maxAge: number): string { + if ( + !isBffOpaque(value) || + !Number.isSafeInteger(maxAge) || + maxAge <= 0 || + (!fixed.has(name) && + ![BFF_COOKIES.login, BFF_COOKIES.logout].some( + (type) => name.startsWith(type) && isBffOpaque(name.slice(type.length)), + )) + ) + throw new BffInputError(); + return name + '=' + value + '; Path=/; Max-Age=' + maxAge + '; HttpOnly; Secure; SameSite=Lax'; +} +export function clearBffCookie(name: string): string { + // Validate the exact name through the same closed writer; deletion grants no new grammar. + bffCookie(name, 'A'.repeat(43), 1); + return name + '=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax'; +} + +/** Recovery deletes only names in our host-cookie namespace; it never reflects values. */ +export function clearMalformedBffCookies(header: string | null): readonly string[] { + const names = new Map(); + let inputBytes = 0; + for (const member of (header ?? '').split(';')) { + const pair = member.trim(); + const name = pair.split('=', 1)[0]!; + if (!name.startsWith(prefix) || !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name)) continue; + inputBytes += Buffer.byteLength(pair) + 2; + names.set(name, { pair, count: (names.get(name)?.count ?? 0) + 1 }); + } + const edits: string[] = []; + let bytes = 0; + for (const [name, { pair, count }] of names) { + if (count === 1 && inputBytes <= AUTH_INPUT_LIMIT) { + try { + bffCookies(pair); + continue; + } catch (error) { + if (!(error instanceof BffInputError)) throw error; + } + } + const edit = name + '=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax'; + if (bytes + Buffer.byteLength(edit) > AUTH_INPUT_LIMIT) break; + bytes += Buffer.byteLength(edit); + edits.push(edit); + } + return Object.freeze(edits); +} diff --git a/frontend/apps/web/src/server/auth/html.test.ts b/frontend/apps/web/src/server/auth/html.test.ts new file mode 100644 index 00000000..8e6939b7 --- /dev/null +++ b/frontend/apps/web/src/server/auth/html.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import { renderBffDocument } from './html'; +import type { PublicSite } from '../public-entry'; + +const site = (): PublicSite => ({ + displayName: 'Institution', + enabledLocales: ['en', 'tr'], + defaultLocale: 'en', + theme: null, + showPlatformAttribution: true, +}); + +describe('bounded localized BFF documents', () => { + it.each([ + ['en', 'Account access unavailable'], + ['tr', 'Hesap erişimi kullanılamıyor'], + ] as const)( + 'offers a closed checked retry and reauthentication in %s without exposing a retained session', + (locale, title) => { + for (const returnPath of ['/studio', '/portal'] as const) { + const document = new DOMParser().parseFromString( + renderBffDocument( + { ...site(), defaultLocale: locale }, + { kind: 'account_unavailable', returnPath }, + ), + 'text/html', + ); + expect(document.querySelector('h1')?.textContent).toBe(title); + expect([...document.querySelectorAll('a')].map((a) => a.getAttribute('href'))).toEqual([ + '#main-content', + returnPath, + '/api/auth/signin?returnTo=' + encodeURIComponent(returnPath), + '/' + locale + '/courses', + ]); + expect(document.querySelector('script, form, input')).toBeNull(); + expect(document.body.textContent).not.toContain('Institution'); + } + }, + ); + it.each(['en', 'tr'])( + 'renders a native form and complete %s UI without a client catalogue or local password', + (locale) => { + const document = new DOMParser().parseFromString( + renderBffDocument( + { ...site(), defaultLocale: locale }, + { kind: 'signin', csrfToken: 'A'.repeat(43), returnPath: '/studio' }, + ), + 'text/html', + ); + expect(document.documentElement.lang).toBe(locale); + expect(document.body.firstElementChild?.getAttribute('href')).toBe('#main-content'); + expect(document.querySelector('#main-content')?.getAttribute('tabindex')).toBe('-1'); + expect(document.body.firstElementChild?.textContent).toMatch( + locale === 'en' ? /Skip/ : /İçeriğe/, + ); + expect(document.querySelector('form')?.getAttribute('action')).toBe( + '/api/auth/signin/keycloak', + ); + expect(document.querySelector('form')?.getAttribute('method')).toBe('post'); + expect(document.querySelector('[name="csrfToken"]')?.getAttribute('value')).toBe( + 'A'.repeat(43), + ); + expect(document.querySelector('[name="returnTo"]')?.getAttribute('value')).toBe('/studio'); + expect(document.querySelector('input[type="password"], script')).toBeNull(); + expect(document.querySelector('button')?.textContent).toMatch( + locale === 'en' ? /Continue/ : /devam/, + ); + }, + ); + it('escapes institution text and all form attributes, and rejects arbitrary theme CSS', () => { + const document = new DOMParser().parseFromString( + renderBffDocument( + { + ...site(), + displayName: '', + theme: { background: '' }, + } as never, + { kind: 'signin', csrfToken: '" autofocus onfocus=alert(1) x="', returnPath: '/studio' }, + ), + 'text/html', + ); + expect(document.querySelector('img, script, [autofocus], [onfocus]')).toBeNull(); + expect(document.body.textContent).toContain(' + value.replace( + /[&<>"']/g, + (character) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character]!, + ); + +/** Closed middleware document, with every text/attribute escaped. No user HTML or script. */ +export function renderBffDocument( + site: PublicSite, + page: + | BffPage + | Readonly<{ kind: 'unavailable' | 'busy' }> + | Readonly<{ kind: 'account_unavailable'; returnPath: '/studio' | '/portal' }>, +): string { + const ui = createAuthTranslator(site.defaultLocale); + const { t } = createPublicTranslator(site.defaultLocale); + const copy = ui.copy; + const title = + page.kind === 'account_unavailable' + ? copy('session.unavailable_title') + : page.kind === 'signin' + ? copy('signin.title') + : page.kind === 'unavailable' + ? copy('signin.unavailable_title') + : copy('error.title'); + const content = + page.kind === 'signin' + ? `

${escape(site.displayName)}

${escape(copy('signin.description'))}

+
+ + +
` + : `

${escape( + page.kind === 'busy' + ? copy('error.busy') + : page.kind === 'account_unavailable' + ? copy('session.unavailable') + : page.kind === 'unavailable' + ? copy('signin.unavailable') + : copy('error.description'), + )}

+ ${ + page.kind === 'account_unavailable' + ? `

${escape(copy('session.retry'))}

${escape(copy('session.reauthenticate'))}

` + : page.kind === 'unavailable' + ? '' + : `

${escape(copy('error.retry'))}

` + } +

${escape(copy('shell.catalog'))}

`; + return ` + + ${escape(title)} + +

${escape(title)}

${content}
`; +} diff --git a/frontend/apps/web/src/server/auth/http-admission.test.ts b/frontend/apps/web/src/server/auth/http-admission.test.ts new file mode 100644 index 00000000..054f2c11 --- /dev/null +++ b/frontend/apps/web/src/server/auth/http-admission.test.ts @@ -0,0 +1,242 @@ +// @vitest-environment node +import { IncomingMessage, ServerResponse, createServer, request as nodeRequest } from 'node:http'; +import { Socket } from 'node:net'; + +import { NextRequest } from 'next/server'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { bffFixture, bffHost, bffOpaque, bffSource } from '../../test/bff-fixture'; +import { admitIncomingRequest, INGRESS_HEADER, mintProvenance, verifyProvenance } from '../ingress'; +import { createPublicAdmissionRuntime } from '../public-admission-runtime'; +import { BFF_COOKIES } from './cookies'; +import { admitBffHttp } from './http-admission'; +import { createBffRequestRuntime } from './request-runtime'; + +const fixtures: ReturnType[] = []; +const fixture = () => { + const f = bffFixture(); + fixtures.push(f); + return f; +}; +const secret = 'test-hop-'.padEnd(48, 'x'); +afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + await Promise.all(fixtures.splice(0).map((f) => f.store.stop())); +}); +function harness(f: ReturnType) { + vi.stubEnv('LEARNSTACK_PUBLIC_HOP_SECRET', secret); + const publicRuntime = createPublicAdmissionRuntime(true); + const requestRuntime = createBffRequestRuntime(); + const original = Object.getOwnPropertyDescriptor; + vi.spyOn(Object, 'getOwnPropertyDescriptor').mockImplementation((object, key) => { + const holder = + key === Symbol.for('learnstack.public-admission.v1') + ? publicRuntime.holder + : key === Symbol.for('learnstack.bff-request.v1') + ? requestRuntime.holder + : key === Symbol.for('learnstack.bff-sessions.v1') + ? Object.freeze({ version: 1, store: Object.freeze(f.store), http: f.http }) + : null; + return object === globalThis && holder + ? { value: holder, configurable: false, writable: false, enumerable: false } + : original(object, key); + }); + async function run( + target: string, + options: { + method?: string; + body?: string; + cookie?: string; + known?: boolean; + observed?: string; + extra?: HeadersInit; + } = {}, + ) { + const context = { host: bffHost, peer: bffSource, method: options.method ?? 'GET', target }; + const headers = new Headers({ + [INGRESS_HEADER]: mintProvenance(context, secret), + ...options.extra, + }); + if (options.cookie) headers.set('cookie', options.cookie); + if (options.body !== undefined) { + headers.set('content-type', 'application/x-www-form-urlencoded'); + headers.set('origin', 'https://' + bffHost); + } + const next = new NextRequest('https://' + bffHost + (options.observed ?? target), { + method: context.method, + headers, + body: options.body, + }); + const request = new IncomingMessage(new Socket()); + request.headers = Object.fromEntries(headers); + const response = new ServerResponse(request); + try { + return await publicRuntime.run(context, request, response, () => + requestRuntime.run(context, request, response, async () => { + if (options.known !== false) + publicRuntime.holder.begin(context).publish({ + displayName: 'School', + enabledLocales: ['en'], + defaultLocale: 'en', + theme: null, + showPlatformAttribution: true, + }); + const result = await admitBffHttp(next, context, new AbortController().signal); + if (!result.response) + expect(requestRuntime.holder.read(context).session).toEqual(result.session); + return result; + }), + ); + } finally { + response.emit('finish'); + request.socket.destroy(); + } + } + return { + run, + stop() { + publicRuntime.shutdown(); + requestRuntime.stop(); + }, + }; +} +describe('pinned Next / native BFF admission seam', () => { + it('malformed public auth input publishes anonymous admission and response-owned cookie recovery', async () => { + const f = fixture(); + const h = harness(f); + try { + const result = await h.run('/en/courses', { + cookie: '__Host-ls-session=private-malformed-canary; foreign=keep', + extra: { 'sec-fetch-dest': 'document' }, + }); + expect(result.response).toBeNull(); + expect(result.session).toBeNull(); + expect(result.unavailable).toBe(false); + expect(result.cookies).toEqual([ + '__Host-ls-session=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax', + ]); + expect(f.account).not.toHaveBeenCalled(); + } finally { + h.stop(); + } + }); + it('a real NextRequest POST consumes only verified native authority and returns all distinct host cookies', async () => { + const f = fixture(); + const h = harness(f); + const csrf = bffOpaque(); + try { + const result = await h.run('/api/auth/signin/keycloak', { + method: 'POST', + body: 'csrfToken=' + csrf + '&returnTo=%2Fstudio', + cookie: BFF_COOKIES.csrf + '=' + csrf, + }); + expect(result.response?.status).toBe(303); + expect(result.response!.headers.getSetCookie()).toHaveLength(2); + expect(result.response!.headers.get('cache-control')).toBe('no-store'); + expect(f.provider.begin).toHaveBeenCalledTimes(1); + } finally { + h.stop(); + } + }); + it('no provider/cookie/session work runs before native live-host bootstrap, including auth POST', async () => { + const f = fixture(); + const h = harness(f); + try { + await expect( + h.run('/api/auth/signin/keycloak', { method: 'POST', body: 'csrfToken=x', known: false }), + ).rejects.toThrow('Public admission context unavailable'); + expect(f.provider.begin).not.toHaveBeenCalled(); + expect(f.store.counts().owners).toBe(0); + } finally { + h.stop(); + } + }); + it('wrong projected method/target is refused, while the pinned Next inert-query projection retains signed raw authority', async () => { + const f = fixture(); + const h = harness(f); + try { + await expect(h.run('/api/auth/csrf', { observed: '/api/auth/session' })).rejects.toThrow( + 'BFF request context unavailable', + ); + const active = await f.login(); + const result = await h.run('/studio?inert=a%20b', { + cookie: active.cookie, + observed: '/studio?inert=a+b', + extra: { 'sec-fetch-dest': 'document' }, + }); + expect(result.session?.state).toBe('authenticated'); + expect(f.account).toHaveBeenCalledTimes(2); + } finally { + h.stop(); + } + }); + it('actual native HTTP admission defaults closed and its explicit opt-in grants only three exact POST targets', async () => { + let enabled = false; + const server = createServer((request, response) => { + const admitted = admitIncomingRequest(request, secret, enabled); + const proof = admitted && verifyProvenance(String(request.headers[INGRESS_HEADER]), secret); + response.writeHead(proof ? 200 : 404, { connection: 'close' }); + response.end(proof ? JSON.stringify(proof) : 'Not found'); + }); + await new Promise((yes) => server.listen(0, '127.0.0.1', yes)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('owned address unavailable'); + const send = (path: string, extraHeaders: Record = {}, chunked = false) => + new Promise<{ status: number; body: string }>((resolve, reject) => { + const request = nodeRequest( + { + hostname: '127.0.0.1', + port: address.port, + method: 'POST', + path, + agent: false, + headers: { + host: bffHost, + [INGRESS_HEADER]: 'forged', + 'x-forwarded-for': '198.51.100.1', + ...extraHeaders, + }, + }, + (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => { + body += chunk; + }); + response.on('end', () => resolve({ status: response.statusCode!, body })); + }, + ); + request.on('error', reject); + if (chunked) request.write('x'); + request.end(); + }); + try { + expect((await send('/api/auth/signout')).status).toBe(404); + enabled = true; + for (const path of ['/api/auth/signin/keycloak', '/api/auth/signout', '/api/auth/extend']) { + const result = await send(path); + expect(result.status).toBe(200); + expect(JSON.parse(result.body)).toEqual({ + host: bffHost, + peer: '127.0.0.1', + method: 'POST', + target: path, + }); + } + expect((await send('/api/auth/signout', { 'content-length': '16385' })).status).toBe(404); + expect((await send('/api/auth/signout', {}, true)).status).toBe(404); + for (const path of [ + '/studio', + '/api/auth/signout/', + '/api/auth/extend?x=1', + '/api/auth/callback/keycloak', + '/api/auth/session', + ]) + expect((await send(path)).status).toBe(404); + } finally { + server.closeAllConnections(); + await new Promise((yes, no) => server.close((error) => (error ? no(error) : yes()))); + } + }); +}); diff --git a/frontend/apps/web/src/server/auth/http-admission.ts b/frontend/apps/web/src/server/auth/http-admission.ts new file mode 100644 index 00000000..d4ce3879 --- /dev/null +++ b/frontend/apps/web/src/server/auth/http-admission.ts @@ -0,0 +1,72 @@ +import 'server-only'; + +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +import { INGRESS_HEADER, verifyNextProvenance } from '../ingress'; +import type { IngressContext } from '../ingress'; +import { readPublicAdmission } from '../public-admission'; +import type { BffPageRenderer } from './http-protocol'; +import { bffRequestOwner } from './request-owner'; +import { bffSessionProtocol } from './session-owner'; + +/** Host admission must already be published by the real bootstrap. */ +export async function admitBffHttp( + request: NextRequest, + context: IngressContext, + signal: AbortSignal, + render?: BffPageRenderer, +) { + const observed = new URL(request.url); + const verified = verifyNextProvenance( + request.headers.get(INGRESS_HEADER), + process.env.LEARNSTACK_PUBLIC_HOP_SECRET, + { method: request.method, target: observed.pathname + observed.search }, + ); + if ( + !verified || + Object.keys(context).some( + (key) => verified[key as keyof IngressContext] !== context[key as keyof IngressContext], + ) + ) + throw new Error('BFF request context unavailable'); + const publicRead = readPublicAdmission(context); + const active = () => { + publicRead.assertActive(); + signal.throwIfAborted(); + }; + active(); + const protocol = bffSessionProtocol(); + const input = { + host: context.host, + source: context.peer, + method: context.method, + target: context.target, + headers: request.headers, + body: request.body, + signal, + }; + const response = await protocol.handle(input, render); + active(); + if (response) { + const headers = new Headers(response.headers); + const location = headers.get('location'); + // Next's middleware adapter requires an absolute Location. Only the independently + // verified host and the owner's three closed return paths can form a local redirect. + if (location?.startsWith('/')) { + if (!['/', '/studio', '/portal'].includes(location)) + throw new Error('BFF response destination unavailable'); + headers.set('location', 'https://' + context.host + location); + } + return Object.freeze({ + response: new NextResponse(response.body, { status: response.status, headers }), + session: null, + cookies: [], + unavailable: false, + }); + } + const projected = await protocol.project(input, bffRequestOwner().activity(context)); + active(); + bffRequestOwner().publish(context, projected.session); + return Object.freeze({ response: null, ...projected }); +} diff --git a/frontend/apps/web/src/server/auth/http-input.ts b/frontend/apps/web/src/server/auth/http-input.ts new file mode 100644 index 00000000..e78d9602 --- /dev/null +++ b/frontend/apps/web/src/server/auth/http-input.ts @@ -0,0 +1,122 @@ +import { BFF_POST_PATHS } from './http-paths.js'; +import { validTarget } from '../ingress.js'; +import { AUTH_INPUT_LIMIT, BffInputError } from './cookies.js'; + +export type BffHttpRequest = Readonly<{ + host: string; + source: string; + method: string; + target: string; + headers: Headers; + body: ReadableStream | null; + signal: AbortSignal; +}>; +export { BFF_POST_PATHS } from './http-paths.js'; +const readable = new Set([ + '/api/auth/signin', + '/api/auth/csrf', + '/api/auth/session', + '/api/auth/error', +]); +export function bffRoute(method: string, path: string): boolean { + return method === 'POST' + ? BFF_POST_PATHS.includes(path) + : (method === 'GET' && path === '/api/auth/callback/keycloak') || + ((method === 'GET' || method === 'HEAD') && readable.has(path)); +} +export function bffActivity(method: string, headers: Headers): 'read' | 'passive' { + if ( + method !== 'GET' || + headers.has('next-router-prefetch') || + headers.get('purpose') === 'prefetch' || + headers + .get('sec-purpose') + ?.toLowerCase() + .split(/[;\s]+/) + .includes('prefetch') + ) + return 'passive'; + return headers.get('rsc') === '1' || headers.get('sec-fetch-dest') === 'document' + ? 'read' + : 'passive'; +} +export function exactBffOrigin(request: BffHttpRequest) { + if (request.headers.get('origin') !== 'https://' + request.host) throw new BffInputError(); +} +function fields(text: string, allowed: readonly string[]) { + const query = new URLSearchParams(); + try { + for (const pair of text ? text.split('&') : []) { + const equal = pair.indexOf('='); + if (!pair || equal < 0) throw new BffInputError(); + const decode = (value: string) => decodeURIComponent(value.replace(/\+/g, ' ')); + const key = decode(pair.slice(0, equal)); + if (!allowed.includes(key) || query.has(key)) throw new BffInputError(); + query.set(key, decode(pair.slice(equal + 1))); + } + } catch { + throw new BffInputError(); + } + return query; +} +export function bffQuery(target: string, allowed: readonly string[]): URLSearchParams { + if (!validTarget(target)) throw new BffInputError(); + const question = target.indexOf('?'); + return fields(question < 0 ? '' : target.slice(question + 1), allowed); +} + +/** Native/Next stream, one finite input budget. Cancellation is not fabricated completion. */ +export async function bffForm( + request: BffHttpRequest, + allowed: readonly string[], +): Promise { + const content = request.headers + .get('content-type') + ?.toLowerCase() + .split(';') + .map((value) => value.trim()); + const length = request.headers.get('content-length'); + if ( + content?.[0] !== 'application/x-www-form-urlencoded' || + content.slice(1).some((value) => value !== 'charset=utf-8') || + (length !== null && (!/^[0-9]+$/.test(length) || Number(length) > AUTH_INPUT_LIMIT)) + ) + throw new BffInputError(); + const deadline = AbortSignal.timeout(10_000); + const signal = AbortSignal.any([request.signal, deadline]); + const reader = request.body?.getReader(); + const cancel = () => { + void reader?.cancel().catch(() => {}); + }; + signal.addEventListener('abort', cancel, { once: true }); + try { + if (signal.aborted) throw new BffInputError(); + const chunks: Uint8Array[] = []; + let bytes = 0; + while (reader) { + const chunk = await reader.read(); + if (signal.aborted) throw new BffInputError(); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > AUTH_INPUT_LIMIT) throw new BffInputError(); + chunks.push(chunk.value); + } + const body = new Uint8Array(bytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(body); + } catch { + throw new BffInputError(); + } + return fields(text, allowed); + } finally { + signal.removeEventListener('abort', cancel); + await reader?.cancel().catch(() => {}); + reader?.releaseLock(); + } +} diff --git a/frontend/apps/web/src/server/auth/http-paths.ts b/frontend/apps/web/src/server/auth/http-paths.ts new file mode 100644 index 00000000..b50bd903 --- /dev/null +++ b/frontend/apps/web/src/server/auth/http-paths.ts @@ -0,0 +1,11 @@ +export const AUTH_INPUT_LIMIT = 16 * 1024; + +/** The only native POST exemptions, gated off until the complete BFF is installed. */ +export const BFF_POST_PATHS = Object.freeze([ + '/api/auth/signin/keycloak', + '/api/auth/signout', + '/api/auth/extend', +]); +export function bffPostTarget(method: string, target: string): boolean { + return method === 'POST' && BFF_POST_PATHS.includes(target); +} diff --git a/frontend/apps/web/src/server/auth/http-protocol.ts b/frontend/apps/web/src/server/auth/http-protocol.ts new file mode 100644 index 00000000..2634d362 --- /dev/null +++ b/frontend/apps/web/src/server/auth/http-protocol.ts @@ -0,0 +1,287 @@ +import { randomBytes } from 'node:crypto'; + +import { + BFF_COOKIES, + BffInputError, + bffCookie, + bffCookies, + clearBffCookie, + clearMalformedBffCookies, +} from './cookies.js'; +import { bffActivity, bffForm, bffQuery, bffRoute, exactBffOrigin } from './http-input.js'; +import type { BffHttpRequest } from './http-input.js'; +import { BffSessionError } from './session-contract.js'; +import type { BffSessionStore, SessionBinding, SessionProjection } from './session-contract.js'; +import { bffSecretEquals, isBffOpaque } from './session-store.js'; + +const opaque = () => randomBytes(32).toString('base64url'); +const cookieLife = 8 * 60 * 60; +export type BffPage = Readonly< + { kind: 'signin'; csrfToken: string; returnPath: string } | { kind: 'error' } +>; +export type BffPageRenderer = (page: BffPage) => string; +type BffProjection = Readonly<{ + session: SessionProjection | null; + cookies: readonly string[]; + unavailable: boolean; +}>; +function result( + request: BffHttpRequest, + value: unknown, + status = 200, + cookies: readonly string[] = [], +): Response { + const headers = new Headers({ + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + 'referrer-policy': 'no-referrer', + 'x-content-type-options': 'nosniff', + 'x-frame-options': 'DENY', + }); + for (const cookie of cookies) headers.append('set-cookie', cookie); + if (status === 429 || status === 503) headers.set('retry-after', '30'); + return new Response(request.method === 'HEAD' ? null : JSON.stringify(value), { + status, + headers, + }); +} +function redirect(request: BffHttpRequest, location: string, cookies: readonly string[]): Response { + const response = result(request, null, 303, cookies); + response.headers.set('location', location); + return response; +} +function binding( + request: BffHttpRequest, + cookies: ReadonlyMap, +): SessionBinding | null { + const handle = cookies.get(BFF_COOKIES.session); + const browser = cookies.get(BFF_COOKIES.browser); + return handle && browser + ? { + host: request.host, + handle, + browser, + traceparent: request.headers.get('traceparent') ?? undefined, + } + : null; +} +const returnPath = (value: string | null): '/' | '/studio' | '/portal' => { + if (value === null) return '/'; + if (value !== '/' && value !== '/studio' && value !== '/portal') throw new BffInputError(); + return value; +}; +function input(request: BffHttpRequest) { + if (request.headers.has('authorization')) throw new BffInputError(); + return bffCookies(request.headers.get('cookie')); +} + +/** Called only after independent live-host bootstrap. No public SDK or browser bearer forwarding. */ +export function createBffHttpProtocol(store: BffSessionStore) { + return Object.freeze({ + async project( + request: BffHttpRequest, + activity: 'read' | 'passive' = bffActivity(request.method, request.headers), + ): Promise { + const anonymous = (cookies: readonly string[] = [], unavailable = false): BffProjection => + Object.freeze({ session: null, cookies, unavailable }); + let cookies: ReadonlyMap; + try { + cookies = bffCookies(request.headers.get('cookie')); + } catch (error) { + if (!(error instanceof BffInputError)) throw error; + return anonymous( + request.method === 'GET' && activity === 'read' + ? clearMalformedBffCookies(request.headers.get('cookie')) + : [], + ); + } + // Authorization is never a BFF credential and must not prevent public reading. + if (request.headers.has('authorization')) return anonymous(); + const current = binding(request, cookies); + if (!current) return anonymous(); + try { + const session = await store.admit(current, request.source, activity, request.signal); + return Object.freeze({ session, cookies: [], unavailable: false }); + } catch (error) { + if (!(error instanceof BffSessionError)) throw error; + // A failed plain read may leave a live owner. Protected reads return 503; + // public rendering stays anonymous without deleting that recoverable session. + return anonymous([], store.peek(current) !== null); + } + }, + async handle(request: BffHttpRequest, render?: BffPageRenderer): Promise { + const path = request.target.split('?', 1)[0]!; + // An inert public query is not a logout action. Only an owned proof-cookie + // family selects the root return protocol; unrecognized states touch nothing. + const rootFields = + path === '/' ? new URL(request.target, 'https://inert.invalid').searchParams : null; + const rootState = rootFields?.get('state'); + const rootReturn = + path === '/' && + isBffOpaque(rootState) && + (request.headers.get('cookie') ?? '') + .split(';') + .some((pair) => pair.trim().startsWith(BFF_COOKIES.logout + rootState + '=')); + if (!bffRoute(request.method, path) && !rootReturn) + return path.startsWith('/api/auth/') || path === '/api/auth' + ? result(request, { code: 'not_found' }, 404) + : null; + try { + if (request.signal.aborted) throw new BffInputError(); + const cookies = input(request); + const current = binding(request, cookies); + if (rootReturn) { + if (request.method !== 'GET') return result(request, { code: 'not_found' }, 404); + const fields = bffQuery(request.target, ['state']); + const state = fields.get('state'); + const browser = cookies.get(BFF_COOKIES.browser); + const proof = state && cookies.get(BFF_COOKIES.logout + state); + if ( + !isBffOpaque(state) || + !browser || + !proof || + !store.consumeLogout({ host: request.host, browser, state, proof }) + ) + return result(request, { code: 'transaction' }, 400); + return redirect(request, '/', [clearBffCookie(BFF_COOKIES.logout + state)]); + } + if (path === '/api/auth/signin' || path === '/api/auth/csrf') { + const fields = bffQuery(request.target, path.endsWith('/signin') ? ['returnTo'] : []); + const destination = returnPath(fields.get('returnTo')); + // HEAD is passive, including an absent presession token. + if (request.method === 'HEAD') return result(request, null); + const csrfToken = cookies.get(BFF_COOKIES.csrf) ?? opaque(); + const response = result( + request, + { csrfToken }, + 200, + cookies.has(BFF_COOKIES.csrf) ? [] : [bffCookie(BFF_COOKIES.csrf, csrfToken, 300)], + ); + if (render && path.endsWith('/signin')) { + response.headers.set('content-type', 'text/html; charset=utf-8'); + // Fetch serializes navigation POST Origin as null under no-referrer. + // This credential-free form needs its exact same-origin CSRF proof; + // provider redirects, callbacks and logout keep no-referrer. + response.headers.set('referrer-policy', 'same-origin'); + return new Response(render({ kind: 'signin', csrfToken, returnPath: destination }), { + status: response.status, + headers: response.headers, + }); + } + return response; + } + if (path === '/api/auth/error') { + bffQuery(request.target, []); + const response = result(request, { code: 'authentication_failed' }, 400); + if (!render || request.method === 'HEAD') return response; + response.headers.set('content-type', 'text/html; charset=utf-8'); + return new Response(render({ kind: 'error' }), { + status: 400, + headers: response.headers, + }); + } + if (path === '/api/auth/session') { + bffQuery(request.target, []); + return result( + request, + current ? (store.peek(current) ?? { state: 'anonymous' }) : { state: 'anonymous' }, + ); + } + if (path === '/api/auth/callback/keycloak') { + const fields = bffQuery(request.target, [ + 'state', + 'code', + 'iss', + 'session_state', + 'error', + 'error_description', + 'error_uri', + ]); + const state = fields.get('state'); + const browser = cookies.get(BFF_COOKIES.browser); + const proof = state && cookies.get(BFF_COOKIES.login + state); + if (!isBffOpaque(state) || !browser || !proof) throw new BffInputError(); + const completed = await store.complete({ + host: request.host, + source: request.source, + traceparent: request.headers.get('traceparent') ?? undefined, + state, + browser, + proof, + responseUrl: 'https://' + request.host + request.target, + signal: request.signal, + }); + if (request.signal.aborted) throw new BffInputError(); + return redirect(request, completed.returnPath, [ + bffCookie(BFF_COOKIES.session, completed.handle, cookieLife), + bffCookie(BFF_COOKIES.browser, completed.browser, cookieLife), + clearBffCookie(BFF_COOKIES.login + state), + ]); + } + bffQuery(request.target, []); + exactBffOrigin(request); + if (path === '/api/auth/signin/keycloak') { + const fields = await bffForm(request, ['csrfToken', 'returnTo']); + if (!bffSecretEquals(fields.get('csrfToken'), cookies.get(BFF_COOKIES.csrf))) + return result(request, { code: 'csrf' }, 403); + const started = await store.start({ + host: request.host, + source: request.source, + browser: cookies.get(BFF_COOKIES.browser), + handle: cookies.get(BFF_COOKIES.session), + returnPath: returnPath(fields.get('returnTo')), + signal: request.signal, + }); + if (request.signal.aborted) throw new BffInputError(); + return redirect(request, started.redirectUrl, [ + bffCookie(BFF_COOKIES.browser, started.browser, cookieLife), + bffCookie(BFF_COOKIES.login + started.state, started.proof, 300), + ]); + } + const fields = await bffForm(request, ['csrfToken']); + const session = current && store.peek(current); + if (!current || !session || !bffSecretEquals(fields.get('csrfToken'), session.csrfToken)) + return result(request, { code: 'csrf' }, 403); + if (path === '/api/auth/extend') + return result( + request, + await store.admit(current, request.source, 'extend', request.signal), + ); + const logout = store.signout(current, fields.get('csrfToken')!); + const edits = [ + clearBffCookie(BFF_COOKIES.session), + ...[...cookies.keys()] + .filter((name) => name.startsWith(BFF_COOKIES.login)) + .map(clearBffCookie), + bffCookie(BFF_COOKIES.logout + logout.state, logout.proof, 300), + ]; + return redirect(request, logout.redirectUrl ?? '/', edits); + } catch (error) { + if (error instanceof BffSessionError) { + const status = ['source_capacity', 'subject_capacity'].includes(error.reason) + ? 429 + : [ + 'pending_capacity', + 'global_capacity', + 'stopped', + 'deadline', + 'transport', + 'provider_refused', + 'configuration', + 'response', + ].includes(error.reason) + ? 503 + : 400; + // Only the owner can authorize clearing its verified failed transaction. + const edits = error.clearTransaction + ? [clearBffCookie(BFF_COOKIES.login + error.clearTransaction)] + : []; + return result(request, { code: error.reason }, status, edits); + } + return error instanceof BffInputError + ? result(request, { code: 'invalid_request' }, 400) + : result(request, { code: 'unavailable' }, 503); + } + }, + }); +} diff --git a/frontend/apps/web/src/server/auth/logout-response.test.ts b/frontend/apps/web/src/server/auth/logout-response.test.ts new file mode 100644 index 00000000..1f1b1296 --- /dev/null +++ b/frontend/apps/web/src/server/auth/logout-response.test.ts @@ -0,0 +1,192 @@ +// @vitest-environment node +import { createServer } from 'node:http'; +import type { ServerResponse } from 'node:http'; +import { createConnection } from 'node:net'; + +import { describe, expect, it, vi } from 'vitest'; + +import { protectBffLogoutResponse } from './logout-response'; + +const issuer = 'https://identity.example.test/realms/learnstack'; +const host = 'institution.example.test:3000'; +const hint = 'private-logout-hint-canary'; +const destination = issuer + '/protocol/openid-connect/logout?id_token_hint=' + hint; + +async function observe(action: (response: ServerResponse) => void) { + const server = createServer((_request, response) => { + protectBffLogoutResponse(response, issuer, host); + action(response); + }); + try { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Test listener unavailable'); + // eslint-disable-next-line no-restricted-globals -- Test-owned raw HTTP response, not an API call. + const response = await fetch('http://127.0.0.1:' + address.port, { redirect: 'manual' }); + return { status: response.status, headers: response.headers, body: await response.text() }; + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }); + } +} + +async function observeWire(action: (response: ServerResponse) => void, protect = true) { + const server = createServer((_request, response) => { + if (protect) protectBffLogoutResponse(response, issuer, host); + action(response); + }); + try { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Test listener unavailable'); + const wire = await new Promise((resolve, reject) => { + const socket = createConnection({ host: '127.0.0.1', port: address.port }); + const chunks: Buffer[] = []; + socket.setTimeout(2000, () => socket.destroy(new Error('Owned socket did not settle'))); + socket.once('connect', () => + socket.write('GET / HTTP/1.1\r\nHost: owned.test\r\nConnection: close\r\n\r\n'), + ); + socket.on('data', (chunk) => chunks.push(chunk)); + socket.once('error', reject); + socket.once('end', () => resolve(Buffer.concat(chunks))); + }); + const end = wire.indexOf('\r\n\r\n'); + expect(end).toBeGreaterThan(0); + return { headers: wire.subarray(0, end).toString(), body: wire.subarray(end + 4).toString() }; + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }); + } +} + +describe('native logout redirect containment', () => { + it.each( + (['write', 'end'] as const).flatMap((method) => + (['explicit', 'implicit'] as const).map((publication) => ({ method, publication })), + ), + )( + 'suppresses actual wire bytes from $method with $publication header publication', + async ({ method, publication }) => { + const callback = vi.fn(); + const action = (response: ServerResponse) => { + const headers = { + location: destination, + 'content-length': '0', + 'set-cookie': [ + '__Host-one=; Max-Age=0; Path=/; Secure', + '__Host-two=owned; Path=/; Secure', + ], + }; + if (publication === 'explicit') response.writeHead(303, headers); + else { + response.statusCode = 303; + for (const [name, value] of Object.entries(headers)) response.setHeader(name, value); + } + if (method === 'write') { + response.write(hint, callback); + response.end(); + } else response.end(hint, callback); + }; + const clean = await observeWire(action); + expect(clean.headers).toMatch(/^HTTP\/1\.1 303 /); + expect(clean.headers.toLowerCase()).toContain('content-length: 0'); + expect(clean.headers.toLowerCase().match(/\r\nset-cookie:/g)).toHaveLength(2); + expect(clean.body).toBe(''); + expect(callback).toHaveBeenCalledOnce(); + // Same observer and same declared zero length must expose an unprotected body. + const control = await observeWire(action, false); + expect(control.headers.toLowerCase()).toContain('content-length: 0'); + expect(control.body).toBe(hint); + expect(callback).toHaveBeenCalledTimes(2); + }, + ); + it.each([destination, '/', 'https://' + host + '/'])( + 'retains the allowed Location but removes the Next redirect body: %s', + async (location) => { + const result = await observe((response) => { + response.statusCode = 303; + response.setHeader('location', location); + response.setHeader('set-cookie', ['__Host-owned=; Max-Age=0; Path=/; Secure']); + response.end(location); + }); + expect(result.status).toBe(303); + expect(result.headers.get('location')).toBe(location); + expect(result.headers.get('content-length')).toBe('0'); + expect(result.headers.get('referrer-policy')).toBe('no-referrer'); + expect(result.headers.get('cache-control')).toBe('no-store'); + expect(result.headers.getSetCookie()).toHaveLength(1); + expect(result.body).toBe(''); + }, + ); + + it('suppresses streamed chunks and preserves write/end callbacks after explicit header publication', async () => { + const written = vi.fn(); + const ended = vi.fn(); + const result = await observe((response) => { + response.writeHead(303, 'See Other', { location: destination, 'content-length': '999' }); + response.write(hint, written); + response.end(hint, ended); + }); + expect(result.status).toBe(303); + expect(result.body).toBe(''); + expect(result.headers.get('content-length')).toBe('0'); + expect(written).toHaveBeenCalledTimes(1); + expect(ended).toHaveBeenCalledTimes(1); + }); + + it.each([ + 'https://foreign.example.test/logout?id_token_hint=' + hint, + 'https://foreign.example.test/realms/learnstack/protocol/openid-connect/logout?id_token_hint=' + + hint, + 'http://identity.example.test/realms/learnstack/protocol/openid-connect/logout?id_token_hint=' + + hint, + 'https://identity.example.test:444/realms/learnstack/protocol/openid-connect/logout?id_token_hint=' + + hint, + issuer + '/protocol/openid-connect/auth?id_token_hint=' + hint, + destination + '#fragment', + 'https://user@identity.example.test/realms/learnstack/protocol/openid-connect/logout', + '//foreign.example.test/', + 'https://foreign.example.test/', + 'https://' + host + '/?id_token_hint=' + hint, + ])('refuses a redirect outside the fixed provider logout boundary: %s', async (location) => { + const result = await observe((response) => { + response.writeHead(303, { location }); + response.end(location); + }); + expect(result.status).toBe(503); + expect(result.headers.has('location')).toBe(false); + expect(result.headers.get('retry-after')).toBe('30'); + expect(result.body).toBe(''); + }); + + it('preserves ordinary nonredirect error bodies', async () => { + const result = await observe((response) => { + response.statusCode = 403; + response.write('invalid'); + response.end(' request'); + }); + expect(result.status).toBe(403); + expect(result.body).toBe('invalid request'); + }); + + it('preserves distinct cookies passed as repeated raw writeHead headers', async () => { + const result = await observe((response) => { + response.writeHead(303, [ + 'Location', + destination, + 'Set-Cookie', + '__Host-one=; Max-Age=0; Path=/; Secure', + 'set-cookie', + '__Host-two=proof; Path=/; Secure; HttpOnly', + ]); + response.end(destination); + }); + expect(result.status).toBe(303); + expect(result.headers.getSetCookie()).toHaveLength(2); + expect(result.body).toBe(''); + }); +}); diff --git a/frontend/apps/web/src/server/auth/logout-response.ts b/frontend/apps/web/src/server/auth/logout-response.ts new file mode 100644 index 00000000..5e5ac0bc --- /dev/null +++ b/frontend/apps/web/src/server/auth/logout-response.ts @@ -0,0 +1,82 @@ +import type { ServerResponse } from 'node:http'; + +/** Next serializes a middleware redirect's Location as a body. Logout hints + * are allowed only in the fixed provider Location, never that framework body. */ +export function protectBffLogoutResponse(response: ServerResponse, issuer: string, host: string) { + const writeHead = response.writeHead; + const write = response.write; + const end = response.end; + let protectedRedirect = false; + + const prepare = () => { + if (response.statusCode !== 303) return; + protectedRedirect = true; + const location = response.getHeader('location'); + let allowed = location === '/' || location === 'https://' + host + '/'; + if (typeof location === 'string' && !allowed) { + try { + const url = new URL(location); + allowed = + url.origin + url.pathname === issuer + '/protocol/openid-connect/logout' && + !url.hash && + !url.username && + !url.password; + } catch { + allowed = false; + } + } + if (!allowed) { + response.statusCode = 503; + response.removeHeader('location'); + response.setHeader('retry-after', '30'); + } + response.setHeader('cache-control', 'no-store'); + response.setHeader('referrer-policy', 'no-referrer'); + response.removeHeader('transfer-encoding'); + response.setHeader('content-length', '0'); + }; + + response.writeHead = function (this: ServerResponse, status, message, headers) { + this.statusCode = status; + const supplied = typeof message === 'string' ? headers : (message ?? headers); + if (supplied) { + if (Array.isArray(supplied)) { + const values = new Map(); + for (let index = 0; index < supplied.length; index += 2) { + const name = supplied[index]; + if (typeof name !== 'string') throw new TypeError('Invalid response header name'); + const key = name.toLowerCase(); + const entries = values.get(key) ?? []; + entries.push(String(supplied[index + 1])); + values.set(key, entries); + } + for (const [name, entries] of values) + this.setHeader(name, entries.length === 1 ? entries[0]! : entries); + } else { + for (const [name, value] of Object.entries(supplied)) + if (value !== undefined) this.setHeader(name, value); + } + } + prepare(); + return Reflect.apply( + writeHead, + this, + typeof message === 'string' ? [this.statusCode, message] : [this.statusCode], + ); + } as ServerResponse['writeHead']; + + response.write = function (this: ServerResponse, ...args: Parameters) { + if (!this.headersSent) prepare(); + if (!protectedRedirect) return write.apply(this, args); + const callback = args.find((value) => typeof value === 'function'); + if (typeof callback === 'function') queueMicrotask(() => callback()); + return true; + } as ServerResponse['write']; + + response.end = function (this: ServerResponse, ...args: Parameters) { + if (!this.headersSent) prepare(); + if (!protectedRedirect) return end.apply(this, args); + const callback = args.find((value) => typeof value === 'function'); + return Reflect.apply(end, this, typeof callback === 'function' ? [callback] : []); + } as ServerResponse['end']; +} diff --git a/frontend/apps/web/src/server/auth/middleware.test.ts b/frontend/apps/web/src/server/auth/middleware.test.ts new file mode 100644 index 00000000..c4f01532 --- /dev/null +++ b/frontend/apps/web/src/server/auth/middleware.test.ts @@ -0,0 +1,212 @@ +// @vitest-environment node + +import { NextRequest, NextResponse } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { middleware } from '../../middleware'; +import { bffFixture } from '../../test/bff-fixture'; +import type * as ingress from '../ingress'; + +const f = vi.hoisted(() => ({ + enabled: false, + published: false, + site: { + displayName: 'School', + defaultLocale: 'tr', + enabledLocales: ['tr', 'en'], + theme: null, + showPlatformAttribution: true, + }, + getSite: vi.fn(), + bff: vi.fn(), + refuse: vi.fn(), +})); +vi.mock('../configured-public-client', () => ({ + createConfiguredPublicClient: () => ({ getSite: f.getSite }), + publicTraceparent: () => 'trace', +})); +vi.mock('../ingress', async (original) => ({ + ...(await original()), + verifyNextProvenance: ( + _: string, + _secret: string, + binding: { method: string; target: string }, + ) => ({ ...binding, host: 'institution.example.test:3000', peer: '203.0.113.1' }), +})); +vi.mock('../public-admission', () => ({ + beginPublicAdmission: () => ({ + signal: new AbortController().signal, + assertActive() {}, + publish() { + f.published = true; + }, + refuse: f.refuse, + }), +})); +vi.mock('./session-owner', () => ({ bffSessionEnabled: () => f.enabled })); +vi.mock('./http-admission', () => ({ admitBffHttp: f.bff })); + +const incoming = (path: string, headers: Record = {}) => + new NextRequest('https://institution.example.test:3000' + path, { + headers: { 'x-learnstack-ingress-provenance': 'owned', ...headers }, + }); +beforeEach(() => { + f.enabled = false; + f.published = false; + vi.resetAllMocks(); + f.getSite.mockResolvedValue({ kind: 'success', data: f.site }); + f.bff.mockImplementation(async () => { + expect(f.published).toBe(true); + return { response: null, session: null, cookies: [], unavailable: false }; + }); +}); +describe('atomic host-first BFF middleware activation', () => { + it.each([ + ['anonymous', {}], + ['authorization', { authorization: 'Bearer private-header-canary' }], + ['malformed-cookie', { cookie: '__Host-ls-session=private-cookie-canary' }], + ] as const)( + 'enabled public %s input remains readable through the actual protocol', + async (kind, headers) => { + f.enabled = true; + const actual = bffFixture(); + f.bff.mockImplementation(async (request) => ({ + response: null, + ...(await actual.http.project({ + host: 'institution.example.test:3000', + source: '203.0.113.1', + method: request.method, + target: '/en/courses', + headers: request.headers, + body: null, + signal: request.signal, + })), + })); + try { + const response = await middleware( + incoming('/en/courses', { ...headers, 'sec-fetch-dest': 'document' }), + ); + expect(response.status).toBe(200); + expect(response.headers.get('x-middleware-next')).toBe('1'); + expect(response.headers.getSetCookie()).toEqual( + kind === 'malformed-cookie' + ? ['__Host-ls-session=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax'] + : [], + ); + expect(actual.account).not.toHaveBeenCalled(); + expect(actual.provider.begin).not.toHaveBeenCalled(); + } finally { + await actual.store.stop(); + } + }, + ); + it.each(['/studio', '/en/courses'])( + 'a recoverable account outage at %s does not redirect to sign-in or clear cookies', + async (path) => { + f.enabled = true; + f.bff.mockResolvedValue({ response: null, session: null, cookies: [], unavailable: true }); + const response = await middleware(incoming(path)); + expect(response.status).toBe(path === '/studio' ? 503 : 200); + expect(response.headers.get('location')).toBeNull(); + expect(response.headers.getSetCookie()).toEqual([]); + expect(response.headers.get('x-frame-options')).toBe('DENY'); + if (path === '/studio') { + const html = await response.text(); + expect(html).toContain('Hesap erişimi kullanılamıyor'); + expect(html).toContain('href="/studio"'); + expect(html).toContain('href="/api/auth/signin?returnTo=%2Fstudio"'); + expect(html).not.toContain('csrfToken'); + } + }, + ); + it('unknown host refuses before mode selection, cookies, owner or provider work', async () => { + f.enabled = true; + f.getSite.mockResolvedValue({ kind: 'api-error', status: 404, error: { code: 'not_found' } }); + const response = await middleware(incoming('/api/auth/signin', { cookie: 'malformed' })); + expect(response.status).toBe(404); + expect(f.bff).not.toHaveBeenCalled(); + expect(f.published).toBe(false); + }); + it('disabled mode masks auth and renders a translated unavailable shell without cookies', async () => { + expect((await middleware(incoming('/api/auth/signin'))).status).toBe(404); + const response = await middleware(incoming('/studio')); + expect(response.status).toBe(503); + expect(await response.text()).toContain('Giriş kullanılamıyor'); + expect(response.headers.getSetCookie()).toEqual([]); + expect(f.bff).not.toHaveBeenCalled(); + }); + it('enabled anonymous shells redirect only to the same verified host and closed destination', async () => { + f.enabled = true; + const response = await middleware(incoming('/portal')); + expect(response.status).toBe(303); + expect(response.headers.get('location')).toBe( + 'https://institution.example.test:3000/api/auth/signin?returnTo=%2Fportal', + ); + expect(f.bff).toHaveBeenCalledOnce(); + }); + it('retains enabled-locale precedence over a same-spelled protected shell', async () => { + f.getSite.mockResolvedValue({ + kind: 'success', + data: { ...f.site, enabledLocales: ['studio'], defaultLocale: 'studio' }, + }); + const response = await middleware(incoming('/studio')); + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + 'https://institution.example.test:3000/studio/courses', + ); + expect(f.bff).not.toHaveBeenCalled(); + }); + it('continues authenticated shells with no browser cookie, origin or authorization in downstream headers', async () => { + f.enabled = true; + f.bff.mockResolvedValue({ + response: null, + session: { state: 'authenticated' }, + cookies: [], + unavailable: false, + }); + const response = await middleware( + incoming('/studio', { + cookie: 'private-canary', + origin: 'https://other.example', + authorization: 'Bearer private-canary', + }), + ); + expect(response.status).toBe(200); + for (const key of ['cookie', 'origin', 'authorization']) + expect(response.headers.get('x-middleware-request-' + key)).toBeNull(); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('x-frame-options')).toBe('DENY'); + }); + it('keeps root owned logout ahead of locale redirect and preserves only owner-authorized cookie edits', async () => { + f.enabled = true; + const response = new NextResponse(null, { + status: 303, + headers: { + location: 'https://institution.example.test:3000/', + 'set-cookie': '__Host-ls-logout-owned=; Secure; Path=/; Max-Age=0', + }, + }); + f.bff.mockResolvedValue({ response, session: null, cookies: [], unavailable: false }); + expect(await middleware(incoming('/?state=owned'))).toBe(response); + }); + it('renders a bounded localized browser recovery document instead of provider error text', async () => { + f.enabled = true; + f.bff.mockResolvedValue({ + response: new NextResponse('private-provider-canary', { + status: 400, + headers: { 'set-cookie': '__Host-ls-login-owned=; Secure; Path=/; Max-Age=0' }, + }), + session: null, + cookies: [], + unavailable: false, + }); + const response = await middleware( + incoming('/api/auth/callback/keycloak?state=x', { accept: 'text/html' }), + ); + expect(response.status).toBe(400); + const body = await response.text(); + expect(body).toContain('Giriş tamamlanamadı'); + expect(body).not.toContain('private-provider-canary'); + expect(response.headers.getSetCookie()).toHaveLength(1); + }); +}); diff --git a/frontend/apps/web/src/server/auth/provider-core.ts b/frontend/apps/web/src/server/auth/provider-core.ts new file mode 100644 index 00000000..ef775762 --- /dev/null +++ b/frontend/apps/web/src/server/auth/provider-core.ts @@ -0,0 +1,297 @@ +import * as oidc from 'openid-client'; + +import { BffProviderError } from './provider-error.js'; +import { providerOperation } from './provider-transport.js'; + +export type ProviderIdentity = Readonly<{ + issuer: string; + subject: string; + actorId: string; + tenantId: string; + organizationId: string | null; +}>; + +/** Server-only grant result; never a session/client/RSC projection. */ +export type ProviderCredentials = Readonly<{ + accessToken: string; + refreshToken?: string; + idToken: string; + identity: ProviderIdentity; + accessExpiresAt: number; + refreshExpiresAt: number; +}>; + +export type LoginTransaction = Readonly<{ + callbackUri: string; + state: string; + nonce: string; + verifier: string; +}>; + +type ProviderInput = Readonly<{ + issuer: string; + clientSecret: string; + allowLoopbackHttp?: boolean; +}>; +type Operation = ReturnType; +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const allZero = '00000000-0000-0000-0000-000000000000'; + +function identity(claims: oidc.IDToken | undefined, issuer: string): ProviderIdentity { + if ( + !claims || + claims.iss !== issuer || + typeof claims.sub !== 'string' || + !claims.sub || + claims.sub.length > 1024 || + /[\r\n]/.test(claims.sub) || + typeof claims.user_id !== 'string' || + !uuid.test(claims.user_id) || + claims.user_id === allZero || + typeof claims.tenant_id !== 'string' || + !uuid.test(claims.tenant_id) || + claims.tenant_id === allZero || + (claims.organization_id !== undefined && + (typeof claims.organization_id !== 'string' || + !uuid.test(claims.organization_id) || + claims.organization_id === allZero)) + ) + throw new BffProviderError('identity'); + return Object.freeze({ + issuer, + subject: claims.sub, + actorId: claims.user_id.toLowerCase(), + tenantId: claims.tenant_id.toLowerCase(), + organizationId: + typeof claims.organization_id === 'string' ? claims.organization_id.toLowerCase() : null, + }); +} + +function browserReturn(value: string, path: string): URL { + let url: URL; + try { + url = new URL(value); + } catch { + throw new BffProviderError('configuration'); + } + if ( + url.protocol !== 'https:' || + url.pathname !== path || + url.search || + url.hash || + url.username || + url.password + ) + throw new BffProviderError('configuration'); + return url; +} + +/** Inactive adapter: composition supplies verified configuration, never a request URL. */ +export function createTenantProvider(input: ProviderInput, transport: typeof fetch) { + let issuer: URL; + try { + issuer = new URL(input.issuer); + } catch { + throw new BffProviderError('configuration'); + } + const insecure = + issuer.protocol === 'http:' && + input.allowLoopbackHttp === true && + ['localhost', '127.0.0.1', '[::1]'].includes(issuer.hostname); + if ( + (!insecure && issuer.protocol !== 'https:') || + issuer.pathname !== '/realms/learnstack' || + issuer.href !== input.issuer || + issuer.search || + issuer.hash || + issuer.username || + issuer.password || + typeof input.clientSecret !== 'string' || + !input.clientSecret || + /[\r\n]/.test(input.clientSecret) + ) + throw new BffProviderError('configuration'); + const secret = input.clientSecret; + const expectedIssuer = issuer.href; + let metadata: oidc.ServerMetadata | undefined; + const expected = { + authorization_endpoint: `${expectedIssuer}/protocol/openid-connect/auth`, + token_endpoint: `${expectedIssuer}/protocol/openid-connect/token`, + jwks_uri: `${expectedIssuer}/protocol/openid-connect/certs`, + end_session_endpoint: `${expectedIssuer}/protocol/openid-connect/logout`, + }; + + async function configuration(operation: Operation) { + if (!metadata) { + const response = await operation.fetch(`${expectedIssuer}/.well-known/openid-configuration`); + if (response.status !== 200) throw new BffProviderError('provider_refused'); + const document = (await response.json()) as oidc.ServerMetadata; + if ( + !document || + document.issuer !== expectedIssuer || + Object.entries(expected).some(([key, value]) => document[key] !== value) + ) + throw new BffProviderError('configuration'); + operation.interrupted(); + metadata = Object.freeze({ ...document }); + } + const config = new oidc.Configuration( + metadata, + 'learnstack-web', + { + id_token_signed_response_alg: 'RS256', + }, + oidc.ClientSecretPost(secret), + ); + config[oidc.customFetch] = operation.fetch; + if (insecure) oidc.allowInsecureRequests(config); + oidc.enableNonRepudiationChecks(config); + return config; + } + + async function perform( + signal: AbortSignal | undefined, + action: (op: Operation) => Promise, + ): Promise { + const operation = providerOperation(expectedIssuer, signal, transport); + try { + const result = await action(operation); + operation.interrupted(); + return result; + } catch (error) { + operation.interrupted(); + if (error instanceof BffProviderError) throw error; + if (error instanceof oidc.ClientError && error.cause instanceof BffProviderError) + throw new BffProviderError(error.cause.reason); + if (error instanceof oidc.ResponseBodyError) + throw new BffProviderError( + error.error === 'invalid_grant' ? 'invalid_grant' : 'provider_refused', + ); + throw new BffProviderError('response'); + } + } + + function credentials( + result: oidc.TokenEndpointResponse & oidc.TokenEndpointResponseHelpers, + started: number, + previous?: ProviderIdentity, + ): ProviderCredentials { + const current = identity(result.claims(), expectedIssuer); + if ( + previous && + Object.keys(current).some( + (key) => current[key as keyof ProviderIdentity] !== previous[key as keyof ProviderIdentity], + ) + ) + throw new BffProviderError('identity'); + const seconds = (value: unknown): number => { + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value <= 0 || + value > Number.MAX_SAFE_INTEGER / 1000 + ) + throw new BffProviderError('response'); + return value; + }; + if ( + typeof result.id_token !== 'string' || + !result.id_token || + typeof result.access_token !== 'string' || + !result.access_token || + (!previous && !result.refresh_token) || + (result.refresh_token !== undefined && + (typeof result.refresh_token !== 'string' || !result.refresh_token)) + ) + throw new BffProviderError('response'); + const bundle = Object.freeze({ + accessToken: result.access_token, + refreshToken: result.refresh_token, + idToken: result.id_token, + identity: current, + accessExpiresAt: started + seconds(result.expires_in) * 1000, + refreshExpiresAt: started + seconds(result.refresh_expires_in) * 1000, + }); + if (Buffer.byteLength(JSON.stringify(bundle)) > 32 * 1024) + throw new BffProviderError('response'); + return bundle; + } + + return Object.freeze({ + async begin(callbackUri: string, signal?: AbortSignal) { + browserReturn(callbackUri, '/api/auth/callback/keycloak'); + return perform(signal, async (operation) => { + const config = await configuration(operation); + const transaction = Object.freeze({ + callbackUri, + state: oidc.randomState(), + nonce: oidc.randomNonce(), + verifier: oidc.randomPKCECodeVerifier(), + }); + const url = oidc.buildAuthorizationUrl(config, { + redirect_uri: callbackUri, + scope: 'openid profile email', + code_challenge_method: 'S256', + code_challenge: await oidc.calculatePKCECodeChallenge(transaction.verifier), + state: transaction.state, + nonce: transaction.nonce, + }); + return Object.freeze({ transaction, redirectUrl: url.href }); + }); + }, + async redeem(transaction: LoginTransaction, responseUrl: string, signal?: AbortSignal) { + browserReturn(transaction.callbackUri, '/api/auth/callback/keycloak'); + let response: URL; + try { + response = new URL(responseUrl); + } catch { + throw new BffProviderError('response'); + } + if ( + response.origin + response.pathname !== transaction.callbackUri || + response.hash || + response.searchParams.getAll('state').length !== 1 || + response.searchParams.get('state') !== transaction.state + ) + throw new BffProviderError('response'); + return perform(signal, async (operation) => { + const started = performance.now(); + const config = await configuration(operation); + return credentials( + await oidc.authorizationCodeGrant(config, response, { + expectedState: transaction.state, + expectedNonce: transaction.nonce, + pkceCodeVerifier: transaction.verifier, + idTokenExpected: true, + }), + started, + ); + }); + }, + async refresh(refreshToken: string, previous: ProviderIdentity, signal?: AbortSignal) { + return perform(signal, async (operation) => { + const started = performance.now(); + return credentials( + await oidc.refreshTokenGrant(await configuration(operation), refreshToken), + started, + previous, + ); + }); + }, + logoutUrl(idToken: string, returnUri: string, state: string) { + browserReturn(returnUri, '/'); + if (!metadata) throw new BffProviderError('configuration'); + try { + const config = new oidc.Configuration(metadata, 'learnstack-web'); + if (insecure) oidc.allowInsecureRequests(config); + return oidc.buildEndSessionUrl(config, { + id_token_hint: idToken, + post_logout_redirect_uri: returnUri, + state, + }).href; + } catch { + throw new BffProviderError('response'); + } + }, + }); +} diff --git a/frontend/apps/web/src/server/auth/provider-error.ts b/frontend/apps/web/src/server/auth/provider-error.ts new file mode 100644 index 00000000..a71aaa7d --- /dev/null +++ b/frontend/apps/web/src/server/auth/provider-error.ts @@ -0,0 +1,17 @@ +export type ProviderFailure = + | 'configuration' + | 'cancelled' + | 'deadline' + | 'transport' + | 'response' + | 'identity' + | 'invalid_grant' + | 'provider_refused'; + +/** Upstream messages, causes, URLs and credential values never become diagnostics. */ +export class BffProviderError extends Error { + constructor(readonly reason: ProviderFailure) { + super(`BFF provider: ${reason}`); + this.name = 'BffProviderError'; + } +} diff --git a/frontend/apps/web/src/server/auth/provider-transport.ts b/frontend/apps/web/src/server/auth/provider-transport.ts new file mode 100644 index 00000000..ea162dd0 --- /dev/null +++ b/frontend/apps/web/src/server/auth/provider-transport.ts @@ -0,0 +1,76 @@ +import { BffProviderError } from './provider-error.js'; + +export const PROVIDER_DEADLINE_MS = 10_000; +export const MAX_PROVIDER_BODY_BYTES = 256 * 1024; + +/** Private RP transport: one fixed issuer/purpose set, one deadline through body. */ +export function providerOperation( + issuer: string, + caller: AbortSignal | undefined, + transport: typeof fetch, +) { + const deadline = AbortSignal.timeout(PROVIDER_DEADLINE_MS); + const signal = caller ? AbortSignal.any([caller, deadline]) : deadline; + const purposes = new Map([ + [`${issuer}/.well-known/openid-configuration`, 'GET'], + [`${issuer}/protocol/openid-connect/token`, 'POST'], + [`${issuer}/protocol/openid-connect/certs`, 'GET'], + ]); + const interrupted = () => { + if (signal.aborted) throw new BffProviderError(caller?.aborted ? 'cancelled' : 'deadline'); + }; + return { + signal, + interrupted, + async fetch(input: string | URL | Request, init?: RequestInit): Promise { + interrupted(); + const url = input instanceof Request ? input.url : String(input); + if (purposes.get(url) !== (init?.method ?? 'GET').toUpperCase()) + throw new BffProviderError('configuration'); + let reader: ReadableStreamDefaultReader | undefined; + try { + const response = await transport(url, { + ...init, + signal, + redirect: 'error', + credentials: 'omit', + cache: 'no-store', + }); + reader = response.body?.getReader(); + interrupted(); + if (response.redirected || (response.status >= 300 && response.status < 400)) + throw new BffProviderError('response'); + const media = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); + if (media !== 'application/json' && media !== 'application/jwk-set+json') + throw new BffProviderError('response'); + const chunks: Uint8Array[] = []; + let length = 0; + while (reader) { + const part = await reader.read(); + interrupted(); + if (part.done) break; + length += part.value.byteLength; + if (length > MAX_PROVIDER_BODY_BYTES) throw new BffProviderError('response'); + chunks.push(part.value); + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + const headers = new Headers(response.headers); + // Fetch supplies decoded bytes. Never describe them as the original encoding. + headers.delete('content-encoding'); + headers.delete('content-length'); + return new Response(bytes, { status: response.status, headers }); + } catch (error) { + interrupted(); + throw error instanceof BffProviderError ? error : new BffProviderError('transport'); + } finally { + await reader?.cancel().catch(() => {}); + reader?.releaseLock(); + } + }, + }; +} diff --git a/frontend/apps/web/src/server/auth/provider.test.ts b/frontend/apps/web/src/server/auth/provider.test.ts new file mode 100644 index 00000000..5994e555 --- /dev/null +++ b/frontend/apps/web/src/server/auth/provider.test.ts @@ -0,0 +1,645 @@ +// @vitest-environment node +import { createHash } from 'node:crypto'; +import { once } from 'node:events'; +import http from 'node:http'; + +import { exportJWK, generateKeyPair, SignJWT } from 'jose'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { createTenantProvider } from './provider'; +import { BffProviderError } from './provider-error'; +import { MAX_PROVIDER_BODY_BYTES, providerOperation } from './provider-transport'; + +const issuer = 'https://identity.example.test/realms/learnstack'; +const callback = 'https://school.example.test/api/auth/callback/keycloak'; +const privateCanary = 'private-provider-response-canary'; +const actorId = '019a154b-0000-7000-8000-000000000011'; +const tenantId = '10000000-0000-4000-8000-000000000001'; +let keys: Awaited>; +let otherKeys: Awaited>; +type WireCall = Readonly<{ + url: string; + method: string; + body: URLSearchParams; + redirect: RequestRedirect | undefined; + credentials: RequestCredentials | undefined; + cache: RequestCache | undefined; +}>; +const wires: { issuer: string; calls: WireCall[] }[] = []; + +afterEach(() => { + // Assertions outside customFetch cannot be mistaken for an expected transport + // rejection by the production adapter's closed error conversion. + for (const { issuer, calls } of wires.splice(0)) + for (const call of calls) { + expect(call.redirect).toBe('error'); + expect(call.credentials).toBe('omit'); + expect(call.cache).toBe('no-store'); + const token = `${issuer}/protocol/openid-connect/token`; + expect([ + `${issuer}/.well-known/openid-configuration`, + `${issuer}/protocol/openid-connect/certs`, + token, + ]).toContain(call.url); + expect(call.method).toBe(call.url === token ? 'POST' : 'GET'); + if (call.url === token) { + expect(call.body.getAll('client_id')).toEqual(['learnstack-web']); + expect(call.body.getAll('client_secret')).toEqual([privateCanary]); + } else expect([...call.body]).toEqual([]); + } +}); +beforeAll(async () => { + keys = await generateKeyPair('RS256'); + otherKeys = await generateKeyPair('RS256'); +}); + +async function fixture(issuer = 'https://identity.example.test/realms/learnstack') { + const calls: WireCall[] = []; + wires.push({ issuer, calls }); + let nonce: string | undefined; + let claims: Record = {}; + let tokenPatch: Record = {}; + let signingKey: CryptoKey | undefined; + let refused = false; + let failedPath: string | undefined; + let metadataPatch: Record = {}; + let response: (() => Response) | undefined; + const publicKey = await exportJWK(keys.publicKey); + const transport: typeof fetch = vi.fn(async (input, init) => { + const url = String(input); + const body = new URLSearchParams(String(init?.body ?? '')); + calls.push({ + url, + method: init?.method ?? 'GET', + body, + redirect: init?.redirect, + credentials: init?.credentials, + cache: init?.cache, + }); + if (failedPath && url.endsWith(failedPath)) throw new Error(privateCanary); + if (response) return response(); + if (url.endsWith('/.well-known/openid-configuration')) + return Response.json({ + issuer, + authorization_endpoint: `${issuer}/protocol/openid-connect/auth`, + token_endpoint: `${issuer}/protocol/openid-connect/token`, + jwks_uri: `${issuer}/protocol/openid-connect/certs`, + end_session_endpoint: `${issuer}/protocol/openid-connect/logout`, + ...metadataPatch, + }); + if (url.endsWith('/certs')) + return Response.json({ + keys: [{ ...publicKey, kid: 'owned-key', use: 'sig', alg: 'RS256' }], + }); + if (refused) + return Response.json( + { error: 'invalid_grant', error_description: privateCanary }, + { status: 400 }, + ); + const now = Math.floor(Date.now() / 1000); + const id = await new SignJWT({ + sub: 'external-subject', + iss: issuer, + aud: 'learnstack-web', + iat: now, + exp: now + 900, + ...(nonce ? { nonce } : {}), + user_id: actorId, + tenant_id: tenantId, + ...claims, + }) + .setProtectedHeader({ alg: 'RS256', kid: 'owned-key' }) + .sign(signingKey ?? keys.privateKey); + return Response.json({ + access_token: privateCanary, + token_type: 'Bearer', + id_token: id, + refresh_token: privateCanary, + expires_in: 900, + refresh_expires_in: 1800, + ...tokenPatch, + }); + }); + const provider = createTenantProvider( + { issuer, clientSecret: privateCanary, allowLoopbackHttp: true }, + transport, + ); + async function login() { + const start = await provider.begin(callback); + nonce = start.transaction.nonce; + const url = new URL(callback); + url.searchParams.set('code', 'owned-code'); + url.searchParams.set('state', start.transaction.state); + const result = await provider.redeem(start.transaction, url.href); + return { ...start, result }; + } + return { + provider, + calls, + transport, + login, + setClaims(value: Record) { + claims = value; + }, + setTokens(value: Record) { + tokenPatch = value; + }, + setMetadata(value: Record) { + metadataPatch = value; + }, + setResponse(value: () => Response) { + response = value; + }, + setTransportFailure(path: string) { + failedPath = path; + }, + setRefused() { + refused = true; + }, + setWrongKey() { + signingKey = otherKeys.privateKey; + }, + }; +} + +describe('inactive tenant RP boundary', () => { + it('contacts nothing at construction; real pinned code/refresh validate signed ID tokens and return server-only credentials', async () => { + const f = await fixture(); + expect(f.calls).toHaveLength(0); + const beforeGrant = performance.now(); + const { transaction, redirectUrl, result } = await f.login(); + const afterGrant = performance.now(); + const authorization = new URL(redirectUrl); + expect(authorization.origin).toBe(new URL(issuer).origin); + expect(authorization.pathname).toBe('/realms/learnstack/protocol/openid-connect/auth'); + expect([...authorization.searchParams.keys()].sort()).toEqual([ + 'client_id', + 'code_challenge', + 'code_challenge_method', + 'nonce', + 'redirect_uri', + 'response_type', + 'scope', + 'state', + ]); + expect(authorization.searchParams.get('client_id')).toBe('learnstack-web'); + expect(authorization.searchParams.get('redirect_uri')).toBe(callback); + expect(authorization.searchParams.get('response_type')).toBe('code'); + expect(authorization.searchParams.get('scope')).toBe('openid profile email'); + expect(authorization.searchParams.get('code_challenge_method')).toBe('S256'); + expect(authorization.searchParams.get('code_challenge')).toBe( + createHash('sha256').update(transaction.verifier).digest('base64url'), + ); + expect(authorization.searchParams.get('code_challenge')).not.toBe(transaction.verifier); + expect(authorization.searchParams.get('nonce')).toBe(transaction.nonce); + expect(authorization.searchParams.get('state')).toBe(transaction.state); + expect(redirectUrl).not.toContain(privateCanary); + expect(result.identity).toEqual({ + issuer, + subject: 'external-subject', + actorId, + tenantId, + organizationId: null, + }); + expect(result.accessToken).toBe(privateCanary); + expect(result.accessExpiresAt).toBeGreaterThanOrEqual(beforeGrant + 900_000); + expect(result.accessExpiresAt).toBeLessThanOrEqual(afterGrant + 900_000); + expect(result.refreshExpiresAt).toBeGreaterThanOrEqual(beforeGrant + 1_800_000); + expect(result.refreshExpiresAt).toBeLessThanOrEqual(afterGrant + 1_800_000); + const beforeRefresh = performance.now(); + const refreshed = await f.provider.refresh(result.refreshToken!, result.identity); + const afterRefresh = performance.now(); + expect(refreshed.accessExpiresAt).toBeGreaterThanOrEqual(beforeRefresh + 900_000); + expect(refreshed.accessExpiresAt).toBeLessThanOrEqual(afterRefresh + 900_000); + expect(refreshed.refreshExpiresAt).toBeGreaterThanOrEqual(beforeRefresh + 1_800_000); + expect(refreshed.refreshExpiresAt).toBeLessThanOrEqual(afterRefresh + 1_800_000); + expect(refreshed.identity).toEqual(result.identity); + expect(f.calls.filter((call) => call.url.endsWith('/token'))).toHaveLength(2); + expect(f.calls.filter((call) => call.url.endsWith('/certs')).length).toBeGreaterThan(0); + const tokenCalls = f.calls.filter((call) => call.url.endsWith('/token')); + expect([...tokenCalls[0]!.body]).toHaveLength(6); + expect(Object.fromEntries(tokenCalls[0]!.body)).toEqual({ + client_id: 'learnstack-web', + client_secret: privateCanary, + code: 'owned-code', + code_verifier: transaction.verifier, + grant_type: 'authorization_code', + redirect_uri: callback, + }); + expect(Object.fromEntries(tokenCalls[1]!.body)).toEqual({ + client_id: 'learnstack-web', + client_secret: privateCanary, + grant_type: 'refresh_token', + refresh_token: result.refreshToken, + }); + expect([...tokenCalls[1]!.body]).toHaveLength(4); + const before = f.calls.length; + const logout = new URL( + f.provider.logoutUrl(result.idToken, 'https://school.example.test/', 'owned-return'), + ); + expect(logout.origin).toBe(new URL(issuer).origin); + expect(logout.pathname).toBe('/realms/learnstack/protocol/openid-connect/logout'); + expect(Object.fromEntries(logout.searchParams)).toEqual({ + client_id: 'learnstack-web', + id_token_hint: result.idToken, + post_logout_redirect_uri: 'https://school.example.test/', + state: 'owned-return', + }); + expect([...logout.searchParams]).toHaveLength(4); + expect(logout.href).not.toContain(privateCanary); + expect(f.calls).toHaveLength(before); + }); + + it('creates independent unpredictable state, nonce and verifier values per transaction', async () => { + const f = await fixture(); + const transactions = await Promise.all([ + f.provider.begin(callback), + f.provider.begin(callback), + ]); + const values = transactions.flatMap(({ transaction }) => [ + transaction.state, + transaction.nonce, + transaction.verifier, + ]); + for (const value of values) expect(value).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(new Set(values).size).toBe(6); + }); + + it('accepts a nonnil actor UUID independently of its version, matching the API profile', async () => { + const f = await fixture(); + const versionFourActor = 'FA22AEF7-7175-4CF3-8AD3-A300E2204776'; + f.setClaims({ user_id: versionFourActor }); + const { result } = await f.login(); + expect(result.identity.actorId).toBe(versionFourActor.toLowerCase()); + expect((await f.provider.refresh(result.refreshToken!, result.identity)).identity.actorId).toBe( + versionFourActor.toLowerCase(), + ); + }); + + it('retains the qualified loopback allowance for logout and closes malformed return errors', async () => { + const f = await fixture('http://127.0.0.1:8099/realms/learnstack'); + const { result } = await f.login(); + const calls = f.calls.length; + const logout = new URL( + f.provider.logoutUrl(result.idToken, 'https://school.example.test/', 'owned-return'), + ); + expect(logout.origin).toBe('http://127.0.0.1:8099'); + expect(logout.pathname).toBe('/realms/learnstack/protocol/openid-connect/logout'); + expect(logout.searchParams.get('id_token_hint')).toBe(result.idToken); + expect(logout.searchParams.get('state')).toBe('owned-return'); + expect(f.calls).toHaveLength(calls); + expect(() => f.provider.logoutUrl(result.idToken, privateCanary, 'owned-return')).toThrow( + BffProviderError, + ); + }); + + for (const [name, patch] of [ + ['wrong issuer', { iss: 'https://foreign.example.test/realms/learnstack' }], + ['wrong audience', { aud: 'another-client' }], + ['expired ID token', { exp: 1 }], + ['wrong nonce', { nonce: 'wrong' }], + ['non-scalar actor', { user_id: [actorId] }], + ['invalid actor UUID', { user_id: 'not-a-uuid' }], + ['nil actor UUID', { user_id: '00000000-0000-0000-0000-000000000000' }], + ['missing tenant', { tenant_id: null }], + ['invalid organization', { organization_id: 'private-profile-canary' }], + ] as const) { + it(`rejects ${name} through the actual signed grant without leaking upstream details`, async () => { + const f = await fixture(); + f.setClaims(patch); + await expect(f.login()).rejects.toBeInstanceOf(BffProviderError); + await f.login().catch((error: Error) => { + expect(error.stack).not.toContain(privateCanary); + expect(error.stack).not.toContain('private-profile-canary'); + }); + }); + } + + it('rejects wrong signatures at both code and refresh processing', async () => { + const f = await fixture(); + const { result } = await f.login(); + f.setWrongKey(); + await expect(f.provider.refresh(result.refreshToken!, result.identity)).rejects.toMatchObject({ + reason: 'response', + }); + await expect(f.login()).rejects.toMatchObject({ reason: 'response' }); + }); + + for (const patch of [ + { sub: 'replacement-subject' }, + { user_id: '019a154b-0000-7000-8000-000000000012' }, + { tenant_id: '20000000-0000-4000-8000-000000000002' }, + { organization_id: tenantId }, + ]) { + it(`refuses refresh identity reassignment of ${Object.keys(patch)[0]}`, async () => { + const f = await fixture(); + const { result } = await f.login(); + f.setClaims(patch); + await expect(f.provider.refresh(result.refreshToken!, result.identity)).rejects.toMatchObject( + { reason: 'identity' }, + ); + }); + } + + it('rejects state mismatch before any discovery or code exchange', async () => { + const f = await fixture(); + await expect( + f.provider.redeem( + { callbackUri: callback, state: 'expected', verifier: 'v', nonce: 'n' }, + `${callback}?code=secret-code&state=wrong`, + ), + ).rejects.toMatchObject({ reason: 'response' }); + expect(f.calls).toHaveLength(0); + }); + + it('does not let discovery point a credential-bearing exchange at another origin/path', async () => { + const f = await fixture(); + f.setMetadata({ token_endpoint: 'https://foreign.example.test/collect' }); + await expect(f.login()).rejects.toMatchObject({ reason: 'configuration' }); + expect(f.calls).toHaveLength(1); + expect(f.calls[0]?.body.toString()).not.toContain(privateCanary); + }); + + for (const path of ['/token', '/certs']) { + it(`preserves the closed transport reason through the library wrapper at ${path}`, async () => { + const f = await fixture(); + f.setTransportFailure(path); + const error = await f.login().catch((value: unknown) => value); + expect(error).toBeInstanceOf(BffProviderError); + expect(error).toMatchObject({ reason: 'transport', message: 'BFF provider: transport' }); + expect((error as Error).cause).toBeUndefined(); + expect((error as Error).stack).not.toContain(privateCanary); + expect(f.calls.filter((call) => call.url.endsWith(path))).toHaveLength(1); + }); + } + + it('retains network uncertainty during refresh without retry or an upstream cause', async () => { + const f = await fixture(); + const { result } = await f.login(); + const before = f.calls.filter((call) => call.url.endsWith('/token')).length; + f.setTransportFailure('/token'); + const error = await f.provider + .refresh(result.refreshToken!, result.identity) + .catch((value: unknown) => value); + expect(error).toBeInstanceOf(BffProviderError); + expect(error).toMatchObject({ reason: 'transport', message: 'BFF provider: transport' }); + expect((error as Error).cause).toBeUndefined(); + expect((error as Error).stack).not.toContain(privateCanary); + expect(f.calls.filter((call) => call.url.endsWith('/token'))).toHaveLength(before + 1); + }); + + it('maps invalid_grant without retrying or preserving provider descriptions', async () => { + const f = await fixture(); + const { result } = await f.login(); + f.setRefused(); + const before = f.calls.filter((call) => call.url.endsWith('/token')).length; + await expect(f.provider.refresh(result.refreshToken!, result.identity)).rejects.toMatchObject({ + reason: 'invalid_grant', + message: 'BFF provider: invalid_grant', + }); + expect(f.calls.filter((call) => call.url.endsWith('/token'))).toHaveLength(before + 1); + }); + + for (const [name, value] of [ + ['missing refresh lifetime', undefined], + ['unbounded refresh lifetime', 0], + ['malformed refresh lifetime', '1800'], + ]) { + it(`refuses ${name}`, async () => { + const f = await fixture(); + f.setTokens({ refresh_expires_in: value }); + await expect(f.login()).rejects.toMatchObject({ reason: 'response' }); + }); + } + + it('returns rotated refresh credentials and uses their replacement for the next signed grant', async () => { + const f = await fixture(); + const { result } = await f.login(); + const replacement = 'rotated-private-refresh-canary'; + f.setTokens({ refresh_token: replacement }); + const rotated = await f.provider.refresh(result.refreshToken!, result.identity); + expect(rotated.refreshToken).toBe(replacement); + await f.provider.refresh(rotated.refreshToken!, rotated.identity); + const refreshes = f.calls.filter((call) => call.body.get('grant_type') === 'refresh_token'); + expect(refreshes).toHaveLength(2); + expect(refreshes.map((call) => call.body.get('refresh_token'))).toEqual([ + result.refreshToken, + replacement, + ]); + }); + + it('allows omitted replacement refresh token only during refresh, never initial custody', async () => { + const f = await fixture(); + const { result } = await f.login(); + f.setTokens({ refresh_token: undefined }); + expect( + (await f.provider.refresh(result.refreshToken!, result.identity)).refreshToken, + ).toBeUndefined(); + await expect(f.login()).rejects.toMatchObject({ reason: 'response' }); + }); + + for (const [name, response] of [ + ['HTML', () => new Response(privateCanary, { headers: { 'content-type': 'text/html' } })], + [ + 'redirect', + () => + new Response(null, { status: 302, headers: { location: 'https://foreign.example.test/' } }), + ], + [ + 'oversized decoded body', + () => Response.json({ padding: 'x'.repeat(MAX_PROVIDER_BODY_BYTES) }), + ], + [ + 'malformed JSON', + () => new Response('{', { headers: { 'content-type': 'application/json' } }), + ], + ] as const) { + it(`bounds ${name} before library processing`, async () => { + const f = await fixture(); + f.setResponse(response); + await expect(f.login()).rejects.toBeInstanceOf(BffProviderError); + expect(f.calls).toHaveLength(1); + }); + } + + it.each(['media type', 'redirect', 'decoded byte limit'] as const)( + 'independently rejects %s before returning an otherwise valid discovery document', + async (defect) => { + const document = JSON.stringify({ + issuer, + authorization_endpoint: `${issuer}/protocol/openid-connect/auth`, + token_endpoint: `${issuer}/protocol/openid-connect/token`, + jwks_uri: `${issuer}/protocol/openid-connect/certs`, + end_session_endpoint: `${issuer}/protocol/openid-connect/logout`, + }); + for (const planted of [false, true]) { + let cancelled = false; + let sent = false; + const text = + planted && defect === 'decoded byte limit' ? document.padEnd(262_145, ' ') : document; + const transport = vi.fn( + async () => + new Response( + new ReadableStream( + { + pull(controller) { + if (sent) controller.close(); + else { + sent = true; + controller.enqueue(new TextEncoder().encode(text)); + } + }, + cancel() { + cancelled = true; + }, + }, + { highWaterMark: 0 }, + ), + { + status: planted && defect === 'redirect' ? 302 : 200, + headers: { + 'content-type': + planted && defect === 'media type' ? 'text/html' : 'application/json', + }, + }, + ), + ); + const operation = providerOperation(issuer, undefined, transport); + const received = operation.fetch(`${issuer}/.well-known/openid-configuration`); + if (planted) { + await expect(received).rejects.toMatchObject({ reason: 'response' }); + if (defect === 'decoded byte limit') expect(cancelled).toBe(true); + } else { + expect(await (await received).json()).toEqual(JSON.parse(document)); + } + expect(transport).toHaveBeenCalledTimes(1); + } + }, + ); + + it('accepts the literal decoded-body ceiling through the actual provider discovery path', async () => { + const document = JSON.stringify({ + issuer, + authorization_endpoint: `${issuer}/protocol/openid-connect/auth`, + token_endpoint: `${issuer}/protocol/openid-connect/token`, + jwks_uri: `${issuer}/protocol/openid-connect/certs`, + end_session_endpoint: `${issuer}/protocol/openid-connect/logout`, + }); + const f = await fixture(); + f.setResponse( + () => + new Response(document.padEnd(262_144, ' '), { + headers: { 'content-type': 'application/json' }, + }), + ); + await expect(f.provider.begin(callback)).resolves.toHaveProperty('transaction'); + expect(f.calls).toHaveLength(1); + }); + + it('uses one operation deadline and distinguishes it from caller cancellation without raw causes', async () => { + for (const cancelled of [false, true]) { + const deadline = new AbortController(); + const caller = new AbortController(); + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(deadline.signal); + const fetcher: typeof fetch = vi.fn( + async (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error(privateCanary)), { + once: true, + }); + (cancelled ? caller : deadline).abort(); + }), + ); + try { + const provider = createTenantProvider({ issuer, clientSecret: privateCanary }, fetcher); + await expect(provider.begin(callback, caller.signal)).rejects.toMatchObject({ + reason: cancelled ? 'cancelled' : 'deadline', + }); + expect(timeout).toHaveBeenCalledExactlyOnceWith(10_000); + } finally { + timeout.mockRestore(); + } + } + }); + + it('rejects unconfigured realm and non-loopback HTTP before any credential dispatch', () => { + const fetcher = vi.fn(); + for (const value of [ + 'http://identity.example.test/realms/learnstack', + 'https://identity.example.test/realms/learnstack-hub', + 'https://user:pass@identity.example.test/realms/learnstack', + `${issuer}?private=value`, + ]) + expect(() => + createTenantProvider( + { issuer: value, clientSecret: privateCanary, allowLoopbackHttp: true }, + fetcher, + ), + ).toThrow(BffProviderError); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('cancels a real HTTP token response while its body is still incomplete', async () => { + let localIssuer = ''; + let tokenSeen: () => void = () => {}; + const reachedToken = new Promise((resolve) => { + tokenSeen = resolve; + }); + const server = http.createServer((request, response) => { + response.setHeader('content-type', 'application/json'); + if (request.url?.endsWith('/.well-known/openid-configuration')) { + response.end( + JSON.stringify({ + issuer: localIssuer, + authorization_endpoint: `${localIssuer}/protocol/openid-connect/auth`, + token_endpoint: `${localIssuer}/protocol/openid-connect/token`, + jwks_uri: `${localIssuer}/protocol/openid-connect/certs`, + end_session_endpoint: `${localIssuer}/protocol/openid-connect/logout`, + }), + ); + } else { + request.resume(); + response.write('{"access_token":"'); + tokenSeen(); + } + }); + const caller = new AbortController(); + let pending: Promise | undefined; + let timer: ReturnType | undefined; + try { + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Owned HTTP stub has no port'); + localIssuer = `http://127.0.0.1:${address.port}/realms/learnstack`; + const provider = createTenantProvider( + { issuer: localIssuer, clientSecret: privateCanary, allowLoopbackHttp: true }, + globalThis.fetch, + ); + const start = await provider.begin(callback); + pending = provider.redeem( + start.transaction, + `${callback}?code=owned&state=${start.transaction.state}`, + caller.signal, + ); + await Promise.race([ + reachedToken, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('Owned token body was not reached')), 2000); + }), + ]); + caller.abort(); + await expect(pending).rejects.toMatchObject({ + reason: 'cancelled', + message: 'BFF provider: cancelled', + }); + } finally { + if (timer) clearTimeout(timer); + caller.abort(); + await pending?.catch(() => {}); + const closed = new Promise((resolve) => server.close(() => resolve())); + server.closeAllConnections(); + await closed; + } + }); +}); diff --git a/frontend/apps/web/src/server/auth/provider.ts b/frontend/apps/web/src/server/auth/provider.ts new file mode 100644 index 00000000..70ca3035 --- /dev/null +++ b/frontend/apps/web/src/server/auth/provider.ts @@ -0,0 +1,4 @@ +import 'server-only'; + +export { createTenantProvider } from './provider-core.js'; +export type { ProviderIdentity, ProviderCredentials, LoginTransaction } from './provider-core.js'; diff --git a/frontend/apps/web/src/server/auth/request-owner.ts b/frontend/apps/web/src/server/auth/request-owner.ts new file mode 100644 index 00000000..1337adf1 --- /dev/null +++ b/frontend/apps/web/src/server/auth/request-owner.ts @@ -0,0 +1,31 @@ +import 'server-only'; + +import type { BffRequestHolder } from './request-runtime'; + +/** A passive lookup, shared across native/middleware/RSC bundles. */ +export function bffRequestOwner(): BffRequestHolder { + const descriptor = Object.getOwnPropertyDescriptor( + globalThis, + Symbol.for('learnstack.bff-request.v1'), + ); + const value: unknown = descriptor?.value; + if ( + !descriptor || + descriptor.writable || + descriptor.configurable || + descriptor.enumerable || + !value || + typeof value !== 'object' || + !Object.isFrozen(value) || + !('version' in value) || + value.version !== 1 || + !('activity' in value) || + typeof value.activity !== 'function' || + !('publish' in value) || + typeof value.publish !== 'function' || + !('read' in value) || + typeof value.read !== 'function' + ) + throw new Error('BFF request context unavailable'); + return value as BffRequestHolder; +} diff --git a/frontend/apps/web/src/server/auth/request-runtime.ts b/frontend/apps/web/src/server/auth/request-runtime.ts new file mode 100644 index 00000000..254ad39f --- /dev/null +++ b/frontend/apps/web/src/server/auth/request-runtime.ts @@ -0,0 +1,183 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { EventEmitter } from 'node:events'; +import type { IncomingHttpHeaders } from 'node:http'; + +import { canonicalAddress, normalizeHost, validTarget } from '../ingress.js'; +import type { IngressContext } from '../ingress.js'; +import { BFF_POST_PATHS, bffActivity } from './http-input.js'; +import type { SessionProjection } from './session-contract.js'; +import { isBffOpaque } from './session-store.js'; + +export type BffRequestRead = Readonly<{ + session: SessionProjection | null; + assertActive: () => void; +}>; +export type BffRequestHolder = Readonly<{ + version: 1; + activity: (binding: IngressContext) => 'read' | 'passive'; + publish: (binding: IngressContext, session: SessionProjection | null) => void; + read: (binding: IngressContext) => BffRequestRead; +}>; +type Store = { + binding: IngressContext; + activity: 'read' | 'passive'; + phase: 'pending' | 'ready' | 'closed'; + session: SessionProjection | null; + close: () => void; +}; +const unavailable = (): never => { + throw new Error('BFF request context unavailable'); +}; +const same = (left: IngressContext, right: IngressContext) => + left.host === right.host && + left.peer === right.peer && + left.method === right.method && + left.target === right.target; +function snapshot(session: SessionProjection | null): SessionProjection | null { + if (session === null) return null; + if ( + session.state !== 'authenticated' || + typeof session.displayName !== 'string' || + session.displayName.length > 1024 || + !Number.isFinite(session.expiresAt) || + typeof session.limitedByAbsolute !== 'boolean' || + !isBffOpaque(session.csrfToken) + ) + return unavailable(); + return Object.freeze({ + state: 'authenticated', + displayName: session.displayName, + expiresAt: session.expiresAt, + limitedByAbsolute: session.limitedByAbsolute, + csrfToken: session.csrfToken, + }); +} + +/** Separate native ALS: the response owner publishes once; RSC only reads a safe snapshot. */ +export function createBffRequestRuntime() { + const storage = new AsyncLocalStorage(); + const active = new Set(); + let stopped = false; + function current(binding: IngressContext, phase: 'pending' | 'ready') { + const store = storage.getStore(); + if (!store || store.phase !== phase || !same(store.binding, binding)) return unavailable(); + return store; + } + const holder: BffRequestHolder = Object.freeze({ + version: 1, + activity(binding) { + return current(binding, 'pending').activity; + }, + publish(binding, session) { + const store = current(binding, 'pending'); + try { + store.session = snapshot(session); + store.phase = 'ready'; + } catch { + store.close(); + unavailable(); + } + }, + read(binding) { + const store = current(binding, 'ready'); + return Object.freeze({ + session: store.session, + assertActive() { + if (current(binding, 'ready') !== store) unavailable(); + }, + }); + }, + }); + return Object.freeze({ + holder, + run( + binding: IngressContext, + request: Pick & { + readonly aborted: boolean; + readonly headers: IncomingHttpHeaders; + }, + response: Pick & { + readonly destroyed: boolean; + readonly writableEnded: boolean; + }, + work: () => T, + ): T { + if ( + stopped || + normalizeHost(binding.host) !== binding.host || + canonicalAddress(binding.peer) !== binding.peer || + !validTarget(binding.target) || + !( + ['GET', 'HEAD'].includes(binding.method) || + (binding.method === 'POST' && BFF_POST_PATHS.includes(binding.target)) + ) + ) + return unavailable(); + // Capture protocol intent before Next strips Flight headers. Retain only + // the closed scalar, never the native request or its header collection. + const headers = new Headers(); + for (const name of [ + 'rsc', + 'next-router-prefetch', + 'purpose', + 'sec-purpose', + 'sec-fetch-dest', + 'accept', + ]) { + const value = request.headers[name]; + if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(',') : value); + } + const store: Store = { + activity: bffActivity(binding.method, headers), + binding: Object.freeze({ ...binding }), + phase: 'pending', + session: null, + close: () => {}, + }; + const close = () => { + if (store.phase === 'closed') return; + store.phase = 'closed'; + store.session = null; + active.delete(store); + request.removeListener('aborted', close); + response.removeListener('finish', close); + response.removeListener('close', close); + store.close = () => {}; + }; + 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, () => { + if (store.phase === 'closed') return unavailable(); + try { + return work(); + } catch (error) { + close(); + throw error; + } + }); + }, + stop() { + stopped = true; + for (const store of active) store.close(); + }, + counts() { + return active.size; + }, + }); +} +export function installBffRequestRuntime() { + const key = Symbol.for('learnstack.bff-request.v1'); + if (Object.getOwnPropertyDescriptor(globalThis, key)) return unavailable(); + const runtime = createBffRequestRuntime(); + Object.defineProperty(globalThis, key, { + value: runtime.holder, + configurable: false, + enumerable: false, + writable: false, + }); + return runtime; +} diff --git a/frontend/apps/web/src/server/auth/session-contract.ts b/frontend/apps/web/src/server/auth/session-contract.ts new file mode 100644 index 00000000..30f7d399 --- /dev/null +++ b/frontend/apps/web/src/server/auth/session-contract.ts @@ -0,0 +1,127 @@ +import type { LoginTransaction, ProviderCredentials, ProviderIdentity } from './provider.js'; +import type { SessionProjection } from './session-projection.js'; +export type { SessionProjection } from './session-projection.js'; + +/** Native-only capability. Application/UI callers receive projections, never this port. */ +export type SessionProvider = { + begin: ( + uri: string, + signal?: AbortSignal, + ) => Promise>; + redeem: ( + transaction: LoginTransaction, + uri: string, + signal?: AbortSignal, + ) => Promise; + refresh: ( + token: string, + identity: ProviderIdentity, + signal?: AbortSignal, + ) => Promise; + logoutUrl: (idToken: string, returnUri: string, state: string) => string; +}; + +export type SessionBinding = Readonly<{ + host: string; + browser: string; + handle: string; + traceparent?: string; +}>; +export type AccountWitness = Readonly<{ + actorId: string; + tenantId: string; + organizationId: string | null; + displayName: string; +}>; +export type AccountBinding = Readonly<{ host: string; source: string; traceparent?: string }>; +export type SessionActivity = 'passive' | 'read' | 'extend'; +export type LoginStart = Readonly<{ + state: string; + proof: string; + browser: string; + redirectUrl: string; +}>; +export type LoginCompletion = Readonly<{ + handle: string; + browser: string; + returnPath: '/' | '/studio' | '/portal'; + session: SessionProjection; +}>; +export type LogoutStart = Readonly<{ state: string; proof: string; redirectUrl: string | null }>; +export type SessionFailure = + | 'source_capacity' + | 'pending_capacity' + | 'global_capacity' + | 'subject_capacity' + | 'transaction' + | 'expired' + | 'revoked' + | 'stopped' + | 'cancelled' + | 'identity' + | 'configuration' + | 'deadline' + | 'transport' + | 'response' + | 'invalid_grant' + | 'provider_refused'; + +export class BffSessionError extends Error { + constructor( + readonly reason: SessionFailure, + readonly clearTransaction: string | null = null, + ) { + super('BFF session: ' + reason); + this.name = 'BffSessionError'; + } +} + +export type SessionDependencies = Readonly<{ + provider: SessionProvider; + account: ( + accessToken: string, + binding: AccountBinding, + signal: AbortSignal, + ) => Promise; + diagnostic?: (reason: SessionFailure) => void; + /** Internal deterministic test clock, never deployment configuration. */ + clock?: Readonly<{ monotonic: () => number; utc: () => number }>; +}>; + +/** Phase 11 replaces this native default before multiple web processes serve a session. */ +export interface BffSessionStore { + start( + input: Readonly<{ + host: string; + source: string; + browser?: string; + handle?: string; + signal?: AbortSignal; + returnPath: '/' | '/studio' | '/portal'; + }>, + ): Promise; + complete( + input: Readonly<{ + host: string; + source: string; + traceparent?: string; + browser: string; + state: string; + proof: string; + responseUrl: string; + signal?: AbortSignal; + }>, + ): Promise; + peek(binding: SessionBinding): SessionProjection | null; + admit( + binding: SessionBinding, + source: string, + activity: SessionActivity, + signal?: AbortSignal, + ): Promise; + signout(binding: SessionBinding, csrf: string): LogoutStart; + consumeLogout( + input: Readonly<{ host: string; browser: string; state: string; proof: string }>, + ): boolean; + stop(): Promise; +} diff --git a/frontend/apps/web/src/server/auth/session-http-lifetime.test.ts b/frontend/apps/web/src/server/auth/session-http-lifetime.test.ts new file mode 100644 index 00000000..d391a3f4 --- /dev/null +++ b/frontend/apps/web/src/server/auth/session-http-lifetime.test.ts @@ -0,0 +1,327 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createBffHttpProtocol } from './http-protocol'; +import { bffFixture, bffHost, bffOpaque, bffRequest, bffSource } from '../../test/bff-fixture'; + +const fixtures: ReturnType[] = []; +const releases: (() => void)[] = []; +function fixture() { + const f = bffFixture(); + fixtures.push(f); + return f; +} +function gate() { + let release!: () => void; + const promise = new Promise((yes) => { + release = yes; + }); + releases.push(release); + return { promise, release }; +} +afterEach(async () => { + for (const release of releases.splice(0)) release(); + await Promise.all(fixtures.splice(0).map((f) => f.store.stop())); +}); +describe('exclusive store work follows its caller lifetime', () => { + it('aborted discovery retains pending ownership until actual work settles and never publishes a transaction', async () => { + const f = fixture(); + const held = gate(); + const original = f.provider.begin.getMockImplementation()!; + f.provider.begin.mockImplementationOnce(async (uri) => { + await held.promise; + return original(uri); + }); + const controller = new AbortController(); + const pending = f.store.start({ + host: bffHost, + source: bffSource, + returnPath: '/', + signal: controller.signal, + }); + const refused = expect(pending).rejects.toMatchObject({ reason: 'cancelled' }); + await vi.waitFor(() => expect(f.provider.begin).toHaveBeenCalledTimes(1)); + controller.abort(); + expect(f.store.counts()).toMatchObject({ owners: 1, pending: 1 }); + held.release(); + await refused; + expect(f.store.counts().owners).toBe(0); + }); + it.each(['redeem', 'account'] as const)( + 'aborted %s retains actual work and cannot publish late credentials', + async (phase) => { + const f = fixture(); + const held = gate(); + const controller = new AbortController(); + const started = await f.store.start({ host: bffHost, source: bffSource, returnPath: '/' }); + if (phase === 'redeem') { + const original = f.provider.redeem.getMockImplementation()!; + f.provider.redeem.mockImplementationOnce(async () => { + await held.promise; + return original(); + }); + } else { + const original = f.account.getMockImplementation()!; + f.account.mockImplementationOnce(async () => { + await held.promise; + return original(); + }); + } + const pending = f.store.complete({ + host: bffHost, + source: bffSource, + browser: started.browser, + state: started.state, + proof: started.proof, + responseUrl: + 'https://' + + bffHost + + '/api/auth/callback/keycloak?state=' + + started.state + + '&code=owned', + signal: controller.signal, + }); + const refused = expect(pending).rejects.toMatchObject({ + reason: 'cancelled', + clearTransaction: started.state, + }); + await vi.waitFor(() => + expect(phase === 'redeem' ? f.provider.redeem : f.account).toHaveBeenCalledTimes(1), + ); + controller.abort(); + expect(f.store.counts().owners).toBe(1); + held.release(); + await refused; + expect(f.store.counts().owners).toBe(0); + }, + ); + it('carries only the current callback/read trace to AccountSite, never a stored login request trace', async () => { + const f = fixture(); + const started = await f.store.start({ host: bffHost, source: bffSource, returnPath: '/' }); + const finished = await f.store.complete({ + host: bffHost, + source: '203.0.113.2', + traceparent: 'callback-trace', + browser: started.browser, + state: started.state, + proof: started.proof, + responseUrl: + 'https://' + bffHost + '/api/auth/callback/keycloak?state=' + started.state + '&code=owned', + }); + expect(f.account).toHaveBeenLastCalledWith( + 'private-access-canary', + { host: bffHost, source: '203.0.113.2', traceparent: 'callback-trace' }, + expect.any(AbortSignal), + ); + await f.store.admit( + { + host: bffHost, + browser: finished.browser, + handle: finished.handle, + traceparent: 'read-trace', + }, + '203.0.113.3', + 'read', + ); + expect(f.account).toHaveBeenLastCalledWith( + 'private-access-canary', + { host: bffHost, source: '203.0.113.3', traceparent: 'read-trace' }, + expect.any(AbortSignal), + ); + }); +}); + +describe('HTTP protocol cancellation reaches the real session owner', () => { + it('sign-in abort keeps exclusive discovery owned until settlement and never emits browser authority', async () => { + const f = fixture(); + const held = gate(); + const original = f.provider.begin.getMockImplementation()!; + f.provider.begin.mockImplementationOnce(async (uri) => { + await held.promise; + return original(uri); + }); + const controller = new AbortController(); + const csrf = bffOpaque(); + const pending = f.http.handle( + bffRequest('/api/auth/signin/keycloak', { + method: 'POST', + cookie: '__Host-ls-csrf=' + csrf, + form: new URLSearchParams({ csrfToken: csrf, returnTo: '/studio' }).toString(), + signal: controller.signal, + }), + ); + await vi.waitFor(() => expect(f.provider.begin).toHaveBeenCalledTimes(1)); + controller.abort(); + expect(f.store.counts()).toMatchObject({ owners: 1, pending: 1, operations: 1 }); + held.release(); + const response = (await pending)!; + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ code: 'cancelled' }); + expect(response.headers.getSetCookie()).toEqual([]); + expect(f.store.counts()).toMatchObject({ owners: 0, loginStates: 0 }); + }); + + it.each(['redeem', 'account'] as const)( + 'callback abort during %s cannot publish a session or leave an orphan owner', + async (phase) => { + const f = fixture(); + const started = await f.store.start({ + host: bffHost, + source: bffSource, + returnPath: '/studio', + }); + const held = gate(); + const controller = new AbortController(); + const collaborator = phase === 'redeem' ? f.provider.redeem : f.account; + if (phase === 'redeem') { + const original = f.provider.redeem.getMockImplementation()!; + f.provider.redeem.mockImplementationOnce(async () => { + await held.promise; + return original(); + }); + } else { + const original = f.account.getMockImplementation()!; + f.account.mockImplementationOnce(async () => { + await held.promise; + return original(); + }); + } + const pending = f.http.handle( + bffRequest('/api/auth/callback/keycloak?state=' + started.state + '&code=owned', { + cookie: + '__Host-ls-browser=' + + started.browser + + '; __Host-ls-login-' + + started.state + + '=' + + started.proof, + signal: controller.signal, + }), + ); + await vi.waitFor(() => expect(collaborator).toHaveBeenCalledTimes(1)); + controller.abort(); + expect(f.store.counts()).toMatchObject({ + owners: 1, + pending: 1, + operations: 1, + credentialRecords: 0, + }); + held.release(); + const response = (await pending)!; + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ code: 'cancelled' }); + expect(response.headers.getSetCookie()).toHaveLength(1); + expect(response.headers.getSetCookie()[0]).toContain( + '__Host-ls-login-' + started.state + '=;', + ); + expect(response.headers.getSetCookie().join(';')).not.toContain('__Host-ls-session='); + expect(response.headers.getSetCookie().join(';')).not.toContain('__Host-ls-browser='); + expect(f.store.counts()).toMatchObject({ owners: 0, transactionRecords: 0, loginStates: 0 }); + }, + ); + + it('extend abort cancels its HTTP waiter while the shared grant remains owned until actual completion', async () => { + const f = fixture(); + const active = await f.login(); + const held = gate(); + const original = f.provider.refresh.getMockImplementation()!; + f.provider.refresh.mockImplementationOnce(async () => { + await held.promise; + return original(); + }); + const controller = new AbortController(); + const pending = f.http.handle( + bffRequest('/api/auth/extend', { + method: 'POST', + cookie: active.cookie, + form: new URLSearchParams({ csrfToken: active.finished.session.csrfToken }).toString(), + signal: controller.signal, + }), + ); + await vi.waitFor(() => expect(f.provider.refresh).toHaveBeenCalledTimes(1)); + controller.abort(); + const response = (await pending)!; + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ code: 'cancelled' }); + expect(response.headers.getSetCookie()).toEqual([]); + expect(f.store.counts()).toMatchObject({ owners: 1, operations: 1 }); + held.release(); + await vi.waitFor(() => expect(f.store.counts().operations).toBe(0)); + expect(f.store.peek(active.binding)).not.toBeNull(); + expect(f.provider.refresh).toHaveBeenCalledTimes(1); + }); + + it('projection abort denies the current HTTP read without abandoning its actual AccountSite operation', async () => { + const f = fixture(); + const active = await f.login(); + const held = gate(); + const original = f.account.getMockImplementation()!; + f.account.mockImplementationOnce(async () => { + await held.promise; + return original(); + }); + const controller = new AbortController(); + const pending = f.http.project( + bffRequest('/studio', { + cookie: active.cookie, + headers: { 'sec-fetch-dest': 'document' }, + signal: controller.signal, + }), + ); + await vi.waitFor(() => expect(f.account).toHaveBeenCalledTimes(2)); + controller.abort(); + expect(await pending).toEqual({ session: null, cookies: [], unavailable: true }); + expect(f.store.counts()).toMatchObject({ owners: 1, operations: 1 }); + held.release(); + await vi.waitFor(() => expect(f.store.counts().operations).toBe(0)); + expect(f.store.peek(active.binding)).not.toBeNull(); + }); + + it.each(['start', 'complete'] as const)( + 'rechecks cancellation at the %s-to-response handoff after the real owner has returned', + async (method) => { + const f = fixture(); + const controller = new AbortController(); + const adapter = new Proxy(f.store, { + get(target, key) { + const value = Reflect.get(target, key); + if (key === method) + return async (...args: unknown[]) => { + const result = await value.apply(target, args); + controller.abort(); + return result; + }; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + const http = createBffHttpProtocol(adapter); + const csrf = bffOpaque(); + const started = + method === 'complete' + ? await f.store.start({ host: bffHost, source: bffSource, returnPath: '/studio' }) + : undefined; + const request = started + ? bffRequest('/api/auth/callback/keycloak?state=' + started.state + '&code=owned', { + cookie: + '__Host-ls-browser=' + + started.browser + + '; __Host-ls-login-' + + started.state + + '=' + + started.proof, + signal: controller.signal, + }) + : bffRequest('/api/auth/signin/keycloak', { + method: 'POST', + cookie: '__Host-ls-csrf=' + csrf, + form: new URLSearchParams({ csrfToken: csrf, returnTo: '/studio' }).toString(), + signal: controller.signal, + }); + const response = (await http.handle(request))!; + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ code: 'invalid_request' }); + expect(response.headers.getSetCookie()).toEqual([]); + expect(method === 'start' ? f.provider.begin : f.provider.redeem).toHaveBeenCalledTimes(1); + }, + ); +}); diff --git a/frontend/apps/web/src/server/auth/session-owner.ts b/frontend/apps/web/src/server/auth/session-owner.ts new file mode 100644 index 00000000..7a97fdec --- /dev/null +++ b/frontend/apps/web/src/server/auth/session-owner.ts @@ -0,0 +1,56 @@ +import 'server-only'; + +import type { BffSessionHolder } from './session-runtime.js'; + +/** Passive cross-bundle lookup; a build or request never creates a second owner. */ +function holder(): BffSessionHolder { + const descriptor = Object.getOwnPropertyDescriptor( + globalThis, + Symbol.for('learnstack.bff-sessions.v1'), + ); + const holder: unknown = descriptor?.value; + if ( + !descriptor || + descriptor.configurable || + descriptor.enumerable || + descriptor.writable || + !holder || + typeof holder !== 'object' || + !Object.isFrozen(holder) || + !('version' in holder) || + holder.version !== 1 || + !('store' in holder) + ) + throw new Error('BFF session owner unavailable'); + const store = holder.store; + if ( + !store || + typeof store !== 'object' || + !Object.isFrozen(store) || + !['start', 'complete', 'peek', 'admit', 'signout', 'consumeLogout', 'stop'].every( + (key) => key in store && typeof (store as Record)[key] === 'function', + ) + ) + throw new Error('BFF session owner unavailable'); + const http = (holder as BffSessionHolder).http; + if ( + !http || + !Object.isFrozen(http) || + typeof http.handle !== 'function' || + typeof http.project !== 'function' + ) + throw new Error('BFF session owner unavailable'); + return holder as BffSessionHolder; +} + +export function bffSessionProtocol(): BffSessionHolder['http'] { + return holder().http; +} + +/** Native installation is the mode authority; no request/header or bundled fallback enables it. */ +export function bffSessionEnabled(): boolean { + if (!Object.getOwnPropertyDescriptor(globalThis, Symbol.for('learnstack.bff-sessions.v1'))) + return false; + holder(); + return true; +} diff --git a/frontend/apps/web/src/server/auth/session-projection.ts b/frontend/apps/web/src/server/auth/session-projection.ts new file mode 100644 index 00000000..39d55f95 --- /dev/null +++ b/frontend/apps/web/src/server/auth/session-projection.ts @@ -0,0 +1,8 @@ +/** Type-only browser contract; no provider or application identity. */ +export type SessionProjection = Readonly<{ + state: 'authenticated'; + displayName: string; + expiresAt: number; + limitedByAbsolute: boolean; + csrfToken: string; +}>; diff --git a/frontend/apps/web/src/server/auth/session-runtime.test.ts b/frontend/apps/web/src/server/auth/session-runtime.test.ts new file mode 100644 index 00000000..64b6af12 --- /dev/null +++ b/frontend/apps/web/src/server/auth/session-runtime.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment node +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +import { describe, expect, it, vi } from 'vitest'; + +import { bffSessionProtocol } from './session-owner'; + +describe('native BFF owner composition', () => { + it('a bundled request lookup never creates a fallback owner', () => { + expect(() => bffSessionProtocol()).toThrow('BFF session owner unavailable'); + }); + + it('the real compiled native module installs one immutable holder across independently loaded bundles', () => { + const moduleUrl = new URL('../../../.server/auth/session-runtime.js', import.meta.url).href; + const program = [ + "import assert from 'node:assert/strict';", + "import { randomBytes } from 'node:crypto';", + 'const first = await import(process.argv[1]);', + 'const second = await import(process.argv[1] + "?independent-bundle");', + 'let calls = 0;', + 'const dependencies = { provider: { begin: async uri => { ++calls; return { transaction: { callbackUri: uri, state: randomBytes(32).toString("base64url"), nonce: "owned", verifier: "owned" }, redirectUrl: "https://identity.example.test/" }; } }, account: async () => { throw new Error("unused"); } };', + 'const runtime = first.installBffSessionRuntime(dependencies);', + 'assert.equal(calls, 0);', + 'assert.throws(() => second.installBffSessionRuntime(dependencies), /already installed/);', + 'const key = Symbol.for("learnstack.bff-sessions.v1");', + 'const descriptor = Object.getOwnPropertyDescriptor(globalThis, key);', + 'assert.deepEqual([descriptor.enumerable, descriptor.writable, descriptor.configurable], [false, false, false]);', + 'assert.ok(Object.isFrozen(descriptor.value));', + 'assert.ok(Object.isFrozen(descriptor.value.store));', + 'assert.equal(globalThis[Symbol.for("learnstack.public-admission.v1")], undefined);', + 'const login = await descriptor.value.store.start({ host: "institution.example.test:3000", source: "203.0.113.1", returnPath: "/studio" });', + 'assert.equal(calls, 1);', + 'assert.equal(runtime.counts().owners, 1);', + 'assert.equal(login.proof.length, 43);', + 'await runtime.stop();', + 'assert.equal(runtime.counts().owners, 0);', + 'await assert.rejects(descriptor.value.store.start({ host: "institution.example.test:3000", source: "203.0.113.1", returnPath: "/" }), { reason: "stopped" });', + 'assert.throws(() => second.installBffSessionRuntime(dependencies), /already installed/);', + ].join('\n'); + expect( + execFileSync(process.execPath, ['--input-type=module', '-e', program, moduleUrl], { + encoding: 'utf8', + timeout: 10_000, + }), + ).toBe(''); + }); + + it('rejects a planted mutable, malformed or mismatched-version holder instead of using it', () => { + const original = Object.getOwnPropertyDescriptor; + let planted: PropertyDescriptor | undefined; + const descriptor = vi + .spyOn(Object, 'getOwnPropertyDescriptor') + .mockImplementation((object, key) => + object === globalThis && key === Symbol.for('learnstack.bff-sessions.v1') + ? planted + : original(object, key), + ); + try { + for (const value of [ + { version: 1, store: {} }, + Object.freeze({ version: 2, store: Object.freeze({}) }), + Object.freeze({ version: 1, store: Object.freeze({ start: () => {} }) }), + ]) { + planted = { value, configurable: false, writable: false, enumerable: false }; + expect(() => bffSessionProtocol()).toThrow('BFF session owner unavailable'); + } + } finally { + descriptor.mockRestore(); + } + }); + + it('retains passive server-only lookup and the independent native-public holder', () => { + const facade = readFileSync(new URL('./session-owner.ts', import.meta.url), 'utf8'); + expect(facade).toContain("import 'server-only'"); + expect(facade).not.toContain('new InMemoryBffSessionStore'); + const native = readFileSync(new URL('./session-runtime.ts', import.meta.url), 'utf8'); + expect(native).toContain('Object.getOwnPropertyDescriptor'); + expect(native).toContain('configurable: false'); + expect(native).not.toContain('learnstack.public-admission.v1'); + }); +}); diff --git a/frontend/apps/web/src/server/auth/session-runtime.ts b/frontend/apps/web/src/server/auth/session-runtime.ts new file mode 100644 index 00000000..09bc1f1d --- /dev/null +++ b/frontend/apps/web/src/server/auth/session-runtime.ts @@ -0,0 +1,39 @@ +import { createBffHttpProtocol } from './http-protocol.js'; +import type { BffSessionStore, SessionDependencies } from './session-contract.js'; +import { InMemoryBffSessionStore } from './session-store.js'; + +export const BFF_RUNTIME_KEY = 'learnstack.bff-sessions.v1'; +export type BffSessionHolder = Readonly<{ + version: 1; + store: BffSessionStore; + http: ReturnType; +}>; + +/** Called once by the native launcher. Bundled consumers never construct a fallback. */ +export function installBffSessionRuntime(dependencies: SessionDependencies) { + const key = Symbol.for(BFF_RUNTIME_KEY); + if (Object.getOwnPropertyDescriptor(globalThis, key)) + throw new Error('BFF session owner already installed'); + const owner = new InMemoryBffSessionStore(dependencies); + const store: BffSessionStore = Object.freeze({ + start: owner.start.bind(owner), + complete: owner.complete.bind(owner), + peek: owner.peek.bind(owner), + admit: owner.admit.bind(owner), + signout: owner.signout.bind(owner), + consumeLogout: owner.consumeLogout.bind(owner), + stop: owner.stop.bind(owner), + }); + const holder: BffSessionHolder = Object.freeze({ + version: 1, + store, + http: createBffHttpProtocol(store), + }); + Object.defineProperty(globalThis, key, { + value: holder, + enumerable: false, + configurable: false, + writable: false, + }); + return Object.freeze({ stop: store.stop, counts: owner.counts.bind(owner) }); +} diff --git a/frontend/apps/web/src/server/auth/session-store.test.ts b/frontend/apps/web/src/server/auth/session-store.test.ts new file mode 100644 index 00000000..1fc0d2cc --- /dev/null +++ b/frontend/apps/web/src/server/auth/session-store.test.ts @@ -0,0 +1,1318 @@ +// @vitest-environment node +import { randomBytes } from 'node:crypto'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { ProviderCredentials, ProviderIdentity } from './provider'; +import { BffProviderError } from './provider-error'; +import { BffSessionError } from './session-contract'; +import type { + AccountWitness, + SessionBinding, + SessionFailure, + SessionProvider, +} from './session-contract'; +import { BFF_LIMITS, bffSecretEquals, InMemoryBffSessionStore } from './session-store'; + +const host = 'institution.example.test:3000'; +const secondHost = 'second.example.test:3000'; +const source = '203.0.113.1'; +const secret = () => randomBytes(32).toString('base64url'); +const privateCanary = 'private-credential-canary'; +const identity: ProviderIdentity = { + issuer: 'https://identity.example.test/realms/learnstack', + subject: 'opaque-subject', + actorId: '019a154b-0000-7000-8000-000000000011', + tenantId: '10000000-0000-4000-8000-000000000001', + organizationId: null, +}; +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +const stores: InMemoryBffSessionStore[] = []; +const releases: (() => void)[] = []; +afterEach(async () => { + const stopped = stores.splice(0).map((store) => store.stop()); + for (const release of releases.splice(0)) release(); + await Promise.all(stopped); +}); + +function fixture(options: { initialTime?: number; utcJitter?: () => number } = {}) { + let now = options.initialTime ?? 0; + let currentIdentity = identity; + let sequence = 0; + let tokenPatch: Partial = {}; + let accountPatch: Partial = {}; + let refreshing: ReturnType> | undefined; + let redeeming: ReturnType> | undefined; + let accounting: ReturnType> | undefined; + const tokens = new Map(); + const diagnostics: SessionFailure[] = []; + const diagnostic = vi.fn((reason: SessionFailure) => diagnostics.push(reason)); + function credentials(): ProviderCredentials { + const accessToken = privateCanary + '-' + ++sequence; + const result = { + accessToken, + refreshToken: privateCanary, + idToken: privateCanary, + identity: currentIdentity, + accessExpiresAt: now + 900_000, + refreshExpiresAt: now + 1800_000, + ...tokenPatch, + }; + tokens.set(result.accessToken, result.identity); + return result; + } + const provider: SessionProvider = { + begin: vi.fn(async (uri) => ({ + transaction: { callbackUri: uri, state: secret(), nonce: secret(), verifier: secret() }, + redirectUrl: 'https://identity.example.test/authorize', + })), + redeem: vi.fn(async () => { + await redeeming?.promise; + return credentials(); + }), + refresh: vi.fn(async () => { + await refreshing?.promise; + return credentials(); + }), + logoutUrl: vi.fn(() => 'https://identity.example.test/logout?hint=private-credential-canary'), + }; + const account = vi.fn(async (token: string, _binding: unknown, _signal: AbortSignal) => { + await accounting?.promise; + const validated = tokens.get(token)!; + return { + actorId: validated.actorId, + tenantId: validated.tenantId, + organizationId: validated.organizationId, + displayName: 'Institution', + ...accountPatch, + }; + }); + const store = new InMemoryBffSessionStore({ + provider, + account, + clock: { + monotonic: () => now, + utc: () => 1_800_000_000_000 + now + (options.utcJitter?.() ?? 0), + }, + diagnostic, + }); + stores.push(store); + async function start(extra: Partial[0]> = {}) { + return store.start({ host, source, returnPath: '/studio', ...extra }); + } + async function complete( + login: Awaited>, + atHost = host, + callbackSource = source, + signal?: AbortSignal, + ) { + return store.complete({ + host: atHost, + source: callbackSource, + browser: login.browser, + state: login.state, + proof: login.proof, + responseUrl: + 'https://' + + atHost + + '/api/auth/callback/keycloak?state=' + + login.state + + '&code=owned-code', + signal, + }); + } + async function login(extra: Partial[0]> = {}) { + const started = await start(extra); + const finished = await complete(started, extra.host ?? host); + return { + started, + finished, + binding: { host: extra.host ?? host, browser: finished.browser, handle: finished.handle }, + }; + } + return { + store, + provider, + account, + diagnostics, + diagnostic, + start, + complete, + login, + credentials, + advance(ms: number) { + now += ms; + }, + setIdentity(value: Partial) { + currentIdentity = { ...identity, ...value }; + }, + setTokens(value: Partial) { + tokenPatch = value; + }, + setAccount(value: Partial) { + accountPatch = value; + }, + blockRefresh() { + refreshing = deferred(); + releases.push(() => refreshing!.resolve()); + return refreshing; + }, + blockRedeem() { + redeeming = deferred(); + releases.push(() => redeeming!.resolve()); + return redeeming; + }, + blockAccount() { + accounting = deferred(); + releases.push(() => accounting!.resolve()); + return accounting; + }, + }; +} +async function flush() { + for (let i = 0; i < 12; ++i) await Promise.resolve(); +} + +describe('Bff_Session_Refresh_And_Revocation_Have_One_Owner', () => { + it('keeps session CSRF and logout proofs independent from handles and all other opaque values', async () => { + const f = fixture(); + const values: string[] = []; + for (let i = 0; i < 2; ++i) { + const { started, binding, finished } = await f.login(); + const logout = f.store.signout(binding, finished.session.csrfToken); + values.push( + started.state, + started.proof, + binding.browser, + binding.handle, + finished.session.csrfToken, + logout.state, + logout.proof, + ); + } + for (const value of values) expect(value).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(new Set(values).size).toBe(14); + }); + + it('propagates cancelled sign-in to the actual provider owner and retains credit until settlement', async () => { + const f = fixture(); + const held = deferred(); + releases.push(() => held.resolve()); + const begin = vi.mocked(f.provider.begin).getMockImplementation()!; + vi.mocked(f.provider.begin).mockImplementationOnce(async (...args) => { + await held.promise; + return begin(...args); + }); + const controller = new AbortController(); + const pending = f.start({ signal: controller.signal }); + const refused = expect(pending).rejects.toMatchObject({ reason: 'cancelled' }); + await vi.waitFor(() => expect(f.provider.begin).toHaveBeenCalledTimes(1)); + const owner = vi.mocked(f.provider.begin).mock.calls[0]![1]!; + expect(owner.aborted).toBe(false); + controller.abort(); + expect(owner.aborted).toBe(true); + expect(f.store.counts()).toMatchObject({ owners: 1, pending: 1, operations: 1 }); + expect(f.provider.redeem).not.toHaveBeenCalled(); + held.resolve(); + await refused; + expect(Object.values(f.store.counts()).every((count) => count === 0)).toBe(true); + }); + + it.each(['redeem', 'account'] as const)( + 'propagates callback cancellation during %s without publishing or releasing unfinished work', + async (stage) => { + const f = fixture(); + const started = await f.start(); + const held = stage === 'redeem' ? f.blockRedeem() : f.blockAccount(); + const controller = new AbortController(); + const pending = f.complete(started, host, source, controller.signal); + const refused = expect(pending).rejects.toMatchObject({ + reason: 'cancelled', + clearTransaction: started.state, + }); + await vi.waitFor(() => + expect(stage === 'redeem' ? f.provider.redeem : f.account).toHaveBeenCalledTimes(1), + ); + const owner = + stage === 'redeem' + ? vi.mocked(f.provider.redeem).mock.calls[0]![2]! + : f.account.mock.calls[0]![2]; + expect(owner.aborted).toBe(false); + controller.abort(); + expect(owner.aborted).toBe(true); + expect(f.store.counts()).toMatchObject({ + owners: 1, + pending: 1, + operations: 1, + credentialRecords: 0, + }); + held.resolve(); + await refused; + expect(Object.values(f.store.counts()).every((count) => count === 0)).toBe(true); + }, + ); + + it('stores credentials privately and projects only closed immutable display/deadline/CSRF fields', async () => { + const f = fixture(); + expect(f.provider.begin).not.toHaveBeenCalled(); + const { binding, finished } = await f.login(); + const projection = f.store.peek(binding)!; + expect(projection).toEqual({ + state: 'authenticated', + displayName: 'Institution', + expiresAt: 1_800_001_800_000, + limitedByAbsolute: false, + csrfToken: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), + }); + expect(Object.isFrozen(projection)).toBe(true); + expect(JSON.stringify(projection)).not.toContain(privateCanary); + expect(JSON.stringify(projection)).not.toContain(binding.handle); + expect(JSON.stringify(projection)).not.toContain(binding.browser); + expect(finished.returnPath).toBe('/studio'); + expect(f.store.counts()).toMatchObject({ owners: 1, pending: 0, groups: 1, subjects: 1 }); + f.advance(10_000); + expect(f.store.peek(binding)!.expiresAt).toBe(projection.expiresAt); + expect(f.provider.refresh).not.toHaveBeenCalled(); + }); + + for (const field of ['host', 'browser', 'handle'] as const) { + it('requires the original independent ' + field + ' binding', async () => { + const f = fixture(); + const { binding } = await f.login(); + const wrong = { ...binding, [field]: field === 'host' ? secondHost : secret() }; + expect(f.store.peek(wrong)).toBeNull(); + await expect(f.store.admit(wrong, source, 'read')).rejects.toMatchObject({ + reason: 'revoked', + }); + expect(f.store.peek(binding)).not.toBeNull(); + }); + } + + for (const field of ['host', 'browser', 'proof', 'state'] as const) { + it('leaves a valid pending transaction untouched on mismatched ' + field, async () => { + const f = fixture(); + const login = await f.start(); + const input = { + host, + source, + browser: login.browser, + state: login.state, + proof: login.proof, + responseUrl: 'https://' + host + '/api/auth/callback/keycloak', + }; + await expect( + f.store.complete({ ...input, [field]: field === 'host' ? secondHost : secret() }), + ).rejects.toMatchObject({ reason: 'transaction', clearTransaction: null }); + expect(f.provider.redeem).not.toHaveBeenCalled(); + await expect(f.complete(login)).resolves.toHaveProperty('handle'); + await expect(f.complete(login)).rejects.toMatchObject({ + reason: 'transaction', + clearTransaction: null, + }); + expect(f.provider.redeem).toHaveBeenCalledTimes(1); + }); + } + + for (const patch of [ + { actorId: '019a154b-0000-7000-8000-000000000012' }, + { tenantId: '20000000-0000-4000-8000-000000000002' }, + { organizationId: undefined }, + ]) { + it( + 'refuses an independently witnessed API identity/scope conflict: ' + Object.keys(patch)[0], + async () => { + const f = fixture(); + f.setAccount(patch); + const login = await f.start(); + await expect(f.complete(login)).rejects.toMatchObject({ + reason: 'identity', + clearTransaction: login.state, + }); + await flush(); + expect(f.store.counts()).toMatchObject({ owners: 0, groups: 0 }); + }, + ); + } + + it('permits API host-resolved organization only when the protected ID claim is absent', async () => { + const f = fixture(); + const organization = '30000000-0000-4000-8000-000000000003'; + f.setAccount({ organizationId: organization }); + const first = await f.login(); + expect(f.store.peek(first.binding)).not.toBeNull(); + f.setIdentity({ organizationId: '40000000-0000-4000-8000-000000000004' }); + await expect(f.login()).rejects.toMatchObject({ reason: 'identity' }); + expect(f.store.peek(first.binding)).not.toBeNull(); + }); + + it('separates constant-time proof comparison from ordinary malformed inputs', () => { + const value = secret(); + expect(bffSecretEquals(value, value)).toBe(true); + for (const wrong of [secret(), '', value + '=', [], null, 'a'.repeat(16_385)]) + expect(bffSecretEquals(value, wrong)).toBe(false); + }); +}); + +describe('source, subject and global credits', () => { + it('refuses source 17 before reservation/provider work and preserves all 16 existing owners', async () => { + const f = fixture(); + for (let i = 0; i < 16; ++i) await f.start({ host: i % 2 ? host : secondHost }); + await expect(f.start()).rejects.toMatchObject({ reason: 'source_capacity' }); + expect(f.provider.begin).toHaveBeenCalledTimes(16); + expect(f.store.counts()).toMatchObject({ owners: 16, pending: 16 }); + await expect(f.start({ source: '203.0.113.2' })).resolves.toHaveProperty('state'); + expect(f.diagnostics).toEqual(['source_capacity']); + }); + + it('refuses pending 129 across eight distributed sources before provider work', async () => { + const f = fixture(); + for (let i = 0; i < 128; ++i) + await f.start({ source: '203.0.113.' + (1 + Math.floor(i / 16)) }); + await expect(f.start({ source: '203.0.113.99' })).rejects.toMatchObject({ + reason: 'pending_capacity', + }); + expect(f.provider.begin).toHaveBeenCalledTimes(128); + expect(f.store.counts().pending).toBe(128); + f.advance(BFF_LIMITS.transactionMs); + await expect(f.start()).resolves.toHaveProperty('state'); + expect(f.store.counts()).toMatchObject({ owners: 1, pending: 1, groups: 1 }); + }); + + it('retains expired pending/source credit until actual code exchange settles', async () => { + const f = fixture(); + const started = await f.start(); + const gate = f.blockRedeem(); + const work = f.complete(started); + const refused = expect(work).rejects.toBeInstanceOf(BffSessionError); + await flush(); + f.advance(BFF_LIMITS.transactionMs); + f.store.sweep(); + expect(f.store.counts()).toMatchObject({ owners: 1, pending: 1, operations: 1 }); + gate.resolve(); + await refused; + await flush(); + expect(f.store.counts()).toMatchObject({ owners: 0, pending: 0, groups: 0 }); + }); + + it('refuses a ninth subject across hosts after validation without evicting existing sessions', async () => { + const f = fixture(); + const bindings: SessionBinding[] = []; + for (let i = 0; i < 8; ++i) + bindings.push((await f.login({ host: i % 2 ? host : secondHost })).binding); + await expect(f.login()).rejects.toMatchObject({ reason: 'subject_capacity' }); + expect(f.provider.redeem).toHaveBeenCalledTimes(9); + for (const binding of bindings) expect(f.store.peek(binding)).not.toBeNull(); + expect(f.store.counts()).toMatchObject({ owners: 8, subjects: 8 }); + }); + + it('counts logout returns against the subject cap and never needs a new pending slot', async () => { + const f = fixture(); + const returns: { host: string; browser: string; state: string; proof: string }[] = []; + let browser: string | undefined; + for (let i = 0; i < 8; ++i) { + const { binding } = await f.login({ browser }); + browser = binding.browser; + const output = f.store.signout(binding, f.store.peek(binding)!.csrfToken); + expect(f.store.peek(binding)).toBeNull(); + returns.push({ host, browser: binding.browser, ...output }); + } + expect(f.store.counts()).toMatchObject({ owners: 8, pending: 0, subjects: 8 }); + await expect(f.login()).rejects.toMatchObject({ reason: 'subject_capacity' }); + expect(f.store.consumeLogout(returns[0]!)).toBe(true); + expect(f.store.consumeLogout(returns[0]!)).toBe(false); + await expect(f.login()).resolves.toHaveProperty('binding'); + expect(f.store.counts().subjects).toBe(8); + }); + + it('bounds all 1024 owners and fully reclaims expiry before global refusal', async () => { + const f = fixture(); + let retained!: SessionBinding; + for (let i = 0; i < 1024; ++i) { + f.setIdentity({ subject: 'subject-' + i }); + retained = (await f.login()).binding; + } + const calls = vi.mocked(f.provider.begin).mock.calls.length; + await expect(f.start()).rejects.toMatchObject({ reason: 'global_capacity' }); + expect(f.provider.begin).toHaveBeenCalledTimes(calls); + expect(f.store.peek(retained)).not.toBeNull(); + f.advance(BFF_LIMITS.idleMs); + await expect(f.start()).resolves.toHaveProperty('state'); + expect(f.store.counts()).toMatchObject({ owners: 1, pending: 1, groups: 1, subjects: 0 }); + }); +}); + +describe('browser elections and explicit replacement', () => { + it('publishes only the first validated tab and never consumes/clears a different transaction proof', async () => { + const f = fixture(); + const first = await f.start(); + const second = await f.start({ browser: first.browser }); + const gate = f.blockRedeem(); + const a = f.complete(first); + const b = f.complete(second); + const rejected = expect(b).rejects.toMatchObject({ + reason: 'revoked', + clearTransaction: second.state, + }); + await flush(); + gate.resolve(); + const output = await a; + await rejected; + expect(f.store.peek({ host, browser: output.browser, handle: output.handle })).not.toBeNull(); + expect(f.store.counts()).toMatchObject({ owners: 1, pending: 0, groups: 1 }); + }); + + it('keeps the old session on failed replacement validation or new-subject capacity refusal', async () => { + const f = fixture(); + const old = await f.login(); + for (let i = 0; i < 8; ++i) { + f.setIdentity({ subject: 'other-subject' }); + await f.login(); + } + await expect( + f.login({ browser: old.binding.browser, handle: old.binding.handle }), + ).rejects.toMatchObject({ reason: 'subject_capacity' }); + expect(f.store.peek(old.binding)).not.toBeNull(); + f.setAccount({ tenantId: '20000000-0000-4000-8000-000000000002' }); + await expect( + f.login({ browser: old.binding.browser, handle: old.binding.handle }), + ).rejects.toMatchObject({ reason: 'identity' }); + expect(f.store.peek(old.binding)).not.toBeNull(); + }); + + it('replaces only its verified same-browser primary, holding subject credit until old I/O settles', async () => { + const f = fixture(); + const old = await f.login(); + for (let i = 0; i < 7; ++i) await f.login(); + const gate = f.blockRefresh(); + const refresh = f.store.admit(old.binding, source, 'extend'); + const ended = expect(refresh).rejects.toBeInstanceOf(BffSessionError); + await flush(); + const login = await f.start({ browser: old.binding.browser, handle: old.binding.handle }); + let published = false; + const replacement = f.complete(login).then((value) => { + published = true; + return value; + }); + await flush(); + expect(published).toBe(false); + expect(f.store.peek(old.binding)).toBeNull(); + expect(f.store.counts()).toMatchObject({ owners: 9, pending: 1, subjects: 8 }); + await expect(f.login()).rejects.toMatchObject({ reason: 'subject_capacity' }); + gate.resolve(); + await ended; + const next = await replacement; + expect(f.store.peek({ host, browser: next.browser, handle: next.handle })).not.toBeNull(); + expect(f.store.peek(old.binding)).toBeNull(); + expect(f.store.counts()).toMatchObject({ owners: 8, subjects: 8 }); + }); + + it('a browser binding without its primary cookie cannot retire that primary', async () => { + const f = fixture(); + const old = await f.login(); + const next = await f.login({ browser: old.binding.browser }); + expect(next.binding.browser).not.toBe(old.binding.browser); + expect(f.store.peek(old.binding)).not.toBeNull(); + expect(f.store.peek(next.binding)).not.toBeNull(); + expect(f.store.counts()).toMatchObject({ owners: 2, groups: 2 }); + }); + + it('logout fences earlier tabs; a later sign-in and late logout return leave the new session live', async () => { + const f = fixture(); + const old = await f.login(); + const pending = await f.start({ browser: old.binding.browser, handle: old.binding.handle }); + const logout = f.store.signout(old.binding, f.store.peek(old.binding)!.csrfToken); + await expect(f.complete(pending)).rejects.toMatchObject({ + reason: 'transaction', + clearTransaction: null, + }); + const next = await f.login({ browser: old.binding.browser }); + expect( + f.store.consumeLogout({ host, browser: old.binding.browser, ...logout, proof: secret() }), + ).toBe(false); + expect(f.store.consumeLogout({ host, browser: old.binding.browser, ...logout })).toBe(true); + expect(f.store.peek(next.binding)).not.toBeNull(); + expect(f.store.peek(old.binding)).toBeNull(); + }); +}); + +describe('effective deadlines and one refresh owner', () => { + for (const reason of ['transport', 'deadline', 'response', 'provider_refused'] as const) { + it( + 'denies a failed ordinary API read without revoking or touching the live session: ' + reason, + async () => { + const f = fixture(); + const { binding } = await f.login(); + const before = f.store.peek(binding); + f.advance(10_000); + f.account.mockRejectedValueOnce(new BffProviderError(reason)); + await expect(f.store.admit(binding, source, 'read')).rejects.toMatchObject({ reason }); + expect(f.store.peek(binding)).toEqual(before); + expect(f.store.counts()).toMatchObject({ owners: 1, subjects: 1, operations: 0 }); + expect(f.provider.refresh).not.toHaveBeenCalled(); + expect(f.diagnostics).toEqual([reason]); + await expect(f.store.admit(binding, source, 'read')).resolves.toMatchObject({ + state: 'authenticated', + }); + expect(f.store.peek(binding)).not.toBeNull(); + }, + ); + + it( + 'revokes if the same API failure prevents validation of an already consumed refresh grant: ' + + reason, + async () => { + const f = fixture(); + const { binding } = await f.login(); + f.account.mockRejectedValueOnce(new BffProviderError(reason)); + await expect(f.store.admit(binding, source, 'extend')).rejects.toMatchObject({ reason }); + expect(f.provider.refresh).toHaveBeenCalledTimes(1); + expect(f.store.peek(binding)).toBeNull(); + await expect(f.store.admit(binding, source, 'read')).rejects.toMatchObject({ + reason: 'revoked', + }); + expect(f.provider.refresh).toHaveBeenCalledTimes(1); + }, + ); + } + + it('revokes an ordinary read on a proven API identity refusal', async () => { + const f = fixture(); + const { binding } = await f.login(); + f.account.mockRejectedValueOnce(new BffProviderError('identity')); + await expect(f.store.admit(binding, source, 'read')).rejects.toMatchObject({ + reason: 'identity', + }); + expect(f.store.peek(binding)).toBeNull(); + expect(f.store.counts().owners).toBe(0); + }); + + it('uses the rotated refresh token for the next grant and preserves it only when replacement is omitted', async () => { + const f = fixture(); + const { binding } = await f.login(); + const rotated = privateCanary + '-rotated'; + f.setTokens({ refreshToken: rotated }); + await f.store.admit(binding, source, 'extend'); + f.setTokens({ refreshToken: undefined }); + await f.store.admit(binding, source, 'extend'); + await f.store.admit(binding, source, 'extend'); + expect(vi.mocked(f.provider.refresh).mock.calls.map((call) => call[0])).toEqual([ + privateCanary, + rotated, + rotated, + ]); + expect(JSON.stringify(f.store.peek(binding))).not.toContain(rotated); + }); + + it('passive reads do not touch idle or call API/provider, while access-expired live activity refreshes once', async () => { + const f = fixture(); + const { binding } = await f.login(); + const expiry = f.store.peek(binding)!.expiresAt; + const calls = f.account.mock.calls.length; + f.advance(901_000); + await f.store.admit(binding, source, 'passive'); + expect(f.provider.refresh).not.toHaveBeenCalled(); + expect(f.account).toHaveBeenCalledTimes(calls); + expect(f.store.peek(binding)!.expiresAt).toBe(expiry); + await f.store.admit(binding, source, 'read'); + expect(f.provider.refresh).toHaveBeenCalledTimes(1); + expect(f.store.peek(binding)!.expiresAt).toBeGreaterThan(expiry); + }); + + it('extension refreshes even fresh access; concurrent extension/read callers share one grant', async () => { + const f = fixture(); + const { binding } = await f.login(); + f.advance(10_000); + const gate = f.blockRefresh(); + const calls = f.account.mock.calls.length; + const requests = Array.from({ length: 32 }, (_, i) => + f.store.admit(binding, source, i % 2 ? 'read' : 'extend'), + ); + await flush(); + expect(f.provider.refresh).toHaveBeenCalledTimes(1); + gate.resolve(); + const projections = await Promise.all(requests); + expect(new Set(projections.map((value) => value.expiresAt)).size).toBe(1); + expect(f.account).toHaveBeenCalledTimes(calls + 1); + expect(projections[0]!.expiresAt).toBe(1_800_001_810_000); + }); + + it('cancelling one waiter leaves the shared refresh owner and other callers intact', async () => { + const f = fixture(); + const { binding } = await f.login(); + const gate = f.blockRefresh(); + const controller = new AbortController(); + const a = f.store.admit(binding, source, 'extend', controller.signal); + const ended = expect(a).rejects.toMatchObject({ reason: 'cancelled' }); + const b = f.store.admit(binding, source, 'extend'); + await flush(); + controller.abort(); + await ended; + expect(vi.mocked(f.provider.refresh).mock.calls[0]![2]!.aborted).toBe(false); + expect(f.store.counts().operations).toBe(1); + gate.resolve(); + await expect(b).resolves.toHaveProperty('state', 'authenticated'); + expect(f.provider.refresh).toHaveBeenCalledTimes(1); + }); + + for (const reason of [ + 'invalid_grant', + 'transport', + 'deadline', + 'response', + 'identity', + ] as const) { + it('revokes uncertain refresh exactly once without replay: ' + reason, async () => { + const f = fixture(); + const { binding } = await f.login(); + vi.mocked(f.provider.refresh).mockRejectedValue(new BffProviderError(reason)); + await expect(f.store.admit(binding, source, 'extend')).rejects.toMatchObject({ reason }); + expect(f.store.peek(binding)).toBeNull(); + await expect(f.store.admit(binding, source, 'read')).rejects.toMatchObject({ + reason: 'revoked', + }); + expect(f.provider.refresh).toHaveBeenCalledTimes(1); + expect(f.diagnostics).toEqual([reason]); + }); + } + + for (const patch of [ + { subject: 'other-subject' }, + { issuer: 'https://other.example.test/realms/learnstack' }, + { actorId: '019a154b-0000-7000-8000-000000000012' }, + { tenantId: '20000000-0000-4000-8000-000000000002' }, + { organizationId: '30000000-0000-4000-8000-000000000003' }, + ]) { + it('does not publish refresh identity reassignment of ' + Object.keys(patch)[0], async () => { + const f = fixture(); + const { binding } = await f.login(); + f.setIdentity(patch); + await expect(f.store.admit(binding, source, 'extend')).rejects.toMatchObject({ + reason: 'identity', + }); + expect(f.store.peek(binding)).toBeNull(); + }); + } + + it('preserves raw organization absence and independently refuses resolved-organization drift', async () => { + const f = fixture(); + const { binding } = await f.login(); + f.setAccount({ organizationId: '30000000-0000-4000-8000-000000000003' }); + await expect(f.store.admit(binding, source, 'extend')).rejects.toMatchObject({ + reason: 'identity', + }); + expect(f.store.peek(binding)).toBeNull(); + }); + + it('preserves an omitted replacement refresh token without resetting the original absolute deadline', async () => { + const f = fixture(); + const { binding } = await f.login(); + f.setTokens({ refreshToken: undefined }); + for (let i = 0; i < 31; ++i) { + f.advance(15 * 60_000); + await f.store.admit(binding, source, 'extend'); + } + const final = f.store.peek(binding)!; + expect(final.expiresAt).toBe(1_800_000_000_000 + BFF_LIMITS.absoluteMs); + expect(final.limitedByAbsolute).toBe(true); + expect(vi.mocked(f.provider.refresh).mock.calls.at(-1)![0]).toBe(privateCanary); + f.advance(15 * 60_000); + expect(f.store.peek(binding)).toBeNull(); + await expect(f.store.admit(binding, source, 'extend')).rejects.toMatchObject({ + reason: 'revoked', + }); + }); + + it('never extends the provider deadline by touching local idle alone', async () => { + const f = fixture(); + f.setTokens({ refreshExpiresAt: 120_000 }); + const { binding } = await f.login(); + f.advance(60_000); + await f.store.admit(binding, source, 'read'); + expect(f.store.peek(binding)!.expiresAt).toBe(1_800_000_120_000); + f.advance(60_000); + expect(f.store.peek(binding)).toBeNull(); + expect(f.provider.refresh).not.toHaveBeenCalled(); + }); + + it('refuses late refresh after effective expiry even if the new provider bundle is live', async () => { + const f = fixture(); + const { binding } = await f.login(); + const gate = f.blockRefresh(); + const work = f.store.admit(binding, source, 'extend'); + const refused = expect(work).rejects.toMatchObject({ reason: 'expired' }); + await flush(); + f.advance(BFF_LIMITS.idleMs); + gate.resolve(); + await refused; + expect(f.store.peek(binding)).toBeNull(); + }); +}); + +describe('logout, actual settlement and shutdown', () => { + it('reports one closed local signout transition without logging invalid or repeated attempts', async () => { + const f = fixture(); + const { binding, finished } = await f.login(); + expect(() => f.store.signout(binding, secret())).toThrow(BffSessionError); + expect(f.diagnostic).not.toHaveBeenCalled(); + const logout = f.store.signout(binding, finished.session.csrfToken); + expect(f.diagnostic.mock.calls).toEqual([['revoked']]); + expect(f.store.counts()).toMatchObject({ credentialRecords: 0, logoutStates: 1 }); + expect(() => f.store.signout(binding, finished.session.csrfToken)).toThrow(BffSessionError); + expect(f.store.consumeLogout({ ...binding, ...logout })).toBe(true); + expect(f.store.consumeLogout({ ...binding, ...logout })).toBe(false); + f.advance(BFF_LIMITS.transactionMs); + f.store.sweep(); + expect(f.diagnostic.mock.calls).toEqual([['revoked']]); + }); + + it('retains logout-return subject credit through aborted refresh, return consumption and actual settlement', async () => { + const f = fixture(); + const { binding } = await f.login(); + const gate = f.blockRefresh(); + const refresh = f.store.admit(binding, source, 'extend'); + const ended = expect(refresh).rejects.toMatchObject({ reason: 'revoked' }); + await flush(); + const logout = f.store.signout(binding, f.store.peek(binding)!.csrfToken); + expect(f.store.peek(binding)).toBeNull(); + expect(f.store.counts()).toMatchObject({ owners: 1, subjects: 1, operations: 1 }); + expect(f.store.consumeLogout({ host, browser: binding.browser, ...logout })).toBe(true); + expect(f.store.counts()).toMatchObject({ owners: 1, subjects: 1, operations: 1 }); + gate.resolve(); + await ended; + await flush(); + expect(f.store.counts()).toMatchObject({ owners: 0, subjects: 0, operations: 0 }); + }); + + it('an aborted refresh cannot discard an unconsumed logout credit before its five-minute expiry', async () => { + const f = fixture(); + const { binding } = await f.login(); + const gate = f.blockRefresh(); + const work = f.store.admit(binding, source, 'extend'); + const ended = expect(work).rejects.toBeInstanceOf(BffSessionError); + await flush(); + const logout = f.store.signout(binding, f.store.peek(binding)!.csrfToken); + gate.resolve(); + await ended; + await flush(); + expect(f.store.counts()).toMatchObject({ owners: 1, subjects: 1, operations: 0 }); + f.advance(BFF_LIMITS.transactionMs); + f.store.sweep(); + expect(f.store.counts()).toMatchObject({ owners: 0, subjects: 0 }); + expect(f.store.consumeLogout({ host, browser: binding.browser, ...logout })).toBe(false); + }); + + it('a provider logout URL failure still revokes locally, drops credentials and keeps only the bounded return', async () => { + const f = fixture(); + const { binding } = await f.login(); + vi.mocked(f.provider.logoutUrl).mockImplementation(() => { + throw new Error(privateCanary); + }); + const output = f.store.signout(binding, f.store.peek(binding)!.csrfToken); + expect(output.redirectUrl).toBeNull(); + expect(f.store.peek(binding)).toBeNull(); + expect(f.diagnostics).toEqual(['response', 'revoked']); + expect(f.store.counts()).toMatchObject({ owners: 1, pending: 0, subjects: 1 }); + }); + + it('stop closes all gates immediately and joins actual work without claiming timeout settlement', async () => { + const f = fixture(); + const login = await f.start(); + const gate = f.blockRedeem(); + const completing = f.complete(login); + const refused = expect(completing).rejects.toMatchObject({ reason: 'stopped' }); + await flush(); + let stopped = false; + const first = f.store.stop(); + expect(f.store.stop()).toBe(first); + void first.then(() => { + stopped = true; + }); + await flush(); + expect(stopped).toBe(false); + await expect(f.start()).rejects.toMatchObject({ reason: 'stopped' }); + expect(f.store.counts()).toMatchObject({ owners: 1, pending: 1, operations: 1 }); + gate.resolve(); + await refused; + await first; + expect(Object.values(f.store.counts()).every((count) => count === 0)).toBe(true); + }); +}); + +describe('refusal and expiry boundaries', () => { + it('refuses unverified/canonicalized addresses and undeclared return paths before allocation', async () => { + const f = fixture(); + for (const invalid of [ + { host: 'Institution.example.test:3000' }, + { host: 'evil.example.test/path' }, + { source: 'hostname.example.test' }, + { source: '::ffff:203.0.113.1' }, + { browser: 'malformed' }, + { returnPath: '//foreign.example.test' as '/' }, + ]) + await expect(f.start(invalid)).rejects.toMatchObject({ reason: 'configuration' }); + expect(f.provider.begin).not.toHaveBeenCalled(); + expect(f.store.counts().owners).toBe(0); + }); + + it('a verified expired callback clears only its own transaction and never dispatches a grant', async () => { + const f = fixture(); + const started = await f.start(); + f.advance(BFF_LIMITS.transactionMs); + await expect(f.complete(started)).rejects.toMatchObject({ + reason: 'expired', + clearTransaction: started.state, + }); + expect(f.provider.redeem).not.toHaveBeenCalled(); + expect(f.store.counts()).toMatchObject({ owners: 0, groups: 0, pending: 0 }); + }); + + it('does not change a live session on an invalid signout CSRF proof', async () => { + const f = fixture(); + const { binding } = await f.login(); + expect(() => f.store.signout(binding, secret())).toThrow(BffSessionError); + expect(f.store.peek(binding)).not.toBeNull(); + expect(f.provider.logoutUrl).not.toHaveBeenCalled(); + }); + + it('checks the merged credential size when refresh omits a previously large replacement token', async () => { + const f = fixture(); + f.setTokens({ refreshToken: 'r'.repeat(15_000), idToken: 'i'.repeat(15_000) }); + const { binding } = await f.login(); + f.setTokens({ refreshToken: undefined, idToken: 'i'.repeat(18_000) }); + await expect(f.store.admit(binding, source, 'extend')).rejects.toMatchObject({ + reason: 'response', + }); + expect(f.store.peek(binding)).toBeNull(); + }); + + it('a cancelled normal-read waiter cannot hide a later independently refused API outcome', async () => { + const f = fixture(); + const { binding } = await f.login(); + const gate = f.blockAccount(); + const controller = new AbortController(); + const work = f.store.admit(binding, source, 'read', controller.signal); + const cancelled = expect(work).rejects.toMatchObject({ reason: 'cancelled' }); + await flush(); + controller.abort(); + await cancelled; + expect(f.store.peek(binding)).not.toBeNull(); + f.setAccount({ actorId: '019a154b-0000-7000-8000-000000000012' }); + gate.resolve(); + await flush(); + expect(f.store.peek(binding)).toBeNull(); + expect(f.diagnostics).toEqual(['identity']); + }); + + it('the owned thirty-second maintenance timer performs a full expiry pass and is stopped', async () => { + vi.useFakeTimers(); + try { + const f = fixture(); + for (let i = 0; i < 16; ++i) await f.start(); + f.advance(BFF_LIMITS.transactionMs); + await vi.advanceTimersByTimeAsync(BFF_LIMITS.sweepMs); + expect(f.store.counts()).toMatchObject({ owners: 0, pending: 0, groups: 0 }); + expect(f.diagnostic.mock.calls).toEqual(Array.from({ length: 16 }, () => ['expired'])); + await vi.advanceTimersByTimeAsync(BFF_LIMITS.sweepMs * 2); + expect(f.diagnostic).toHaveBeenCalledTimes(16); + await f.store.stop(); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it('the maintenance tick reports expired once while actual work retains its closing owner', async () => { + vi.useFakeTimers(); + try { + const f = fixture(); + f.setTokens({ accessExpiresAt: 86_400_000, refreshExpiresAt: 86_400_000 }); + const { binding } = await f.login(); + f.advance(BFF_LIMITS.idleMs - 1); + const held = f.blockAccount(); + const work = f.store.admit(binding, source, 'read'); + const refused = expect(work).rejects.toMatchObject({ reason: 'revoked' }); + await flush(); + expect(f.account).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(BFF_LIMITS.sweepMs); + expect(f.diagnostic).not.toHaveBeenCalled(); + f.advance(1); + await vi.advanceTimersByTimeAsync(BFF_LIMITS.sweepMs); + expect(f.diagnostic.mock.calls).toEqual([['expired']]); + expect(f.store.counts()).toMatchObject({ + owners: 1, + operations: 1, + credentialRecords: 0, + }); + await vi.advanceTimersByTimeAsync(BFF_LIMITS.sweepMs * 2); + expect(f.diagnostic.mock.calls).toEqual([['expired']]); + held.resolve(); + await refused; + await flush(); + expect(Object.values(f.store.counts()).every((count) => count === 0)).toBe(true); + // Keep the independent failed-read observation as well as the transition. + expect(f.diagnostic.mock.calls).toEqual([['expired'], ['revoked']]); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('current callback visitor provenance', () => { + it('uses the callback IP for AccountSite while retaining original pending-source credit', async () => { + const f = fixture(); + const login = await f.start(); + for (let i = 0; i < 15; ++i) await f.start(); + const callbackSource = '203.0.113.2'; + const gate = f.blockRedeem(); + const completing = f.complete(login, host, callbackSource); + await flush(); + await expect(f.start()).rejects.toMatchObject({ reason: 'source_capacity' }); + await expect(f.start({ source: callbackSource })).resolves.toHaveProperty('state'); + gate.resolve(); + await completing; + expect(f.account).toHaveBeenCalledTimes(1); + expect(f.account.mock.calls[0]![1]).toEqual({ host, source: callbackSource }); + }); + + it('refuses a malformed callback source before consuming a valid transaction', async () => { + const f = fixture(); + const login = await f.start(); + for (const invalid of ['::ffff:203.0.113.1', '', undefined, null]) + await expect( + f.store.complete({ + host, + source: invalid as string, + browser: login.browser, + state: login.state, + proof: login.proof, + responseUrl: 'https://' + host + '/api/auth/callback/keycloak', + }), + ).rejects.toMatchObject({ + reason: 'configuration', + clearTransaction: null, + }); + expect(f.provider.redeem).not.toHaveBeenCalled(); + await expect(f.complete(login)).resolves.toHaveProperty('handle'); + expect(f.provider.redeem).toHaveBeenCalledTimes(1); + }); +}); + +describe('independent lifetime and logical custody controls', () => { + const hour = 60 * 60_000; + function longProvider() { + const f = fixture({ initialTime: 12_345.5 }); + f.setTokens({ accessExpiresAt: 24 * hour, refreshExpiresAt: 24 * hour }); + return f; + } + it('keeps the accepted literal resource and lifetime limits', () => { + expect(BFF_LIMITS).toEqual({ + owners: 1024, + pending: 128, + source: 16, + subject: 8, + transactionMs: 300_000, + idleMs: 1_800_000, + absoluteMs: 28_800_000, + sweepMs: 30_000, + refreshAheadMs: 60_000, + credentialBytes: 32_768, + }); + }); + it('expires at thirty idle minutes even when the provider allows twenty-four hours', async () => { + const f = longProvider(); + const { binding } = await f.login(); + f.advance(1_799_999); + expect(f.store.peek(binding)).not.toBeNull(); + f.advance(1); + expect(f.store.peek(binding)).toBeNull(); + }); + it.each(['passive', 'peek', 'transport', 'deadline', 'response', 'provider_refused'] as const)( + '%s never advances the independent idle deadline', + async (activity) => { + const f = longProvider(); + const { binding } = await f.login(); + f.advance(20 * 60_000); + if (activity === 'peek') f.store.peek(binding); + else if (activity === 'passive') await f.store.admit(binding, source, 'passive'); + else { + f.account.mockRejectedValueOnce(new BffProviderError(activity)); + await expect(f.store.admit(binding, source, 'read')).rejects.toMatchObject({ + reason: activity, + }); + } + f.advance(10 * 60_000); + expect(f.store.peek(binding)).toBeNull(); + }, + ); + it('anchors both idle and absolute lifetime at successful publication after delayed provider/API work', async () => { + const f = fixture(); + f.setTokens({ accessExpiresAt: 24 * hour, refreshExpiresAt: 24 * hour }); + const started = await f.start(); + f.advance(120_000); + const held = f.blockAccount(); + const pending = f.complete(started); + await vi.waitFor(() => expect(f.account).toHaveBeenCalledTimes(1)); + f.advance(60_000); + held.resolve(); + const finished = await pending; + const binding = { host, browser: finished.browser, handle: finished.handle }; + const publishedAt = 180_000; + expect(finished.session.expiresAt).toBe(1_800_000_000_000 + publishedAt + 1_800_000); + for (let i = 0; i < 31; ++i) { + f.advance(15 * 60_000); + await f.store.admit(binding, source, 'read'); + } + expect(f.store.peek(binding)).toMatchObject({ + expiresAt: 1_800_000_000_000 + publishedAt + 28_800_000, + limitedByAbsolute: true, + }); + expect(f.provider.refresh).not.toHaveBeenCalled(); + f.advance(15 * 60_000); + expect(f.store.peek(binding)).toBeNull(); + }); + + it('passes the current refreshed ID token, exact root return and independent state to logout', async () => { + const f = fixture(); + f.setTokens({ idToken: 'initial-id-token' }); + const { binding } = await f.login(); + f.setTokens({ idToken: 'current-id-token', refreshToken: 'current-refresh-token' }); + const projection = await f.store.admit(binding, source, 'extend'); + const logout = f.store.signout(binding, projection.csrfToken); + expect(f.provider.logoutUrl).toHaveBeenCalledExactlyOnceWith( + 'current-id-token', + 'https://' + host + '/', + logout.state, + ); + expect(f.store.counts()).toMatchObject({ credentialRecords: 0, logoutStates: 1 }); + }); + it('successful ordinary authority admission advances idle without refreshing fresh credentials', async () => { + const f = longProvider(); + const { binding } = await f.login(); + f.advance(10 * 60_000); + await f.store.admit(binding, source, 'read'); + f.advance(1_799_999); + expect(f.store.peek(binding)).not.toBeNull(); + f.advance(1); + expect(f.store.peek(binding)).toBeNull(); + expect(f.provider.refresh).not.toHaveBeenCalled(); + }); + it('refreshes at sixty seconds before access expiry, but not sixty seconds plus one millisecond', async () => { + const f = fixture({ initialTime: 1234 }); + f.setTokens({ refreshExpiresAt: 24 * hour }); + const { binding } = await f.login(); + f.advance(839_999); + await f.store.admit(binding, source, 'read'); + expect(f.provider.refresh).not.toHaveBeenCalled(); + f.advance(1); + await f.store.admit(binding, source, 'read'); + expect(f.provider.refresh).toHaveBeenCalledTimes(1); + }); + it('a successful shared refresh advances idle even after its only waiter cancels', async () => { + const f = longProvider(); + const { binding } = await f.login(); + f.advance(10 * 60_000); + const held = f.blockRefresh(); + const controller = new AbortController(); + const attempted = f.store.admit(binding, source, 'extend', controller.signal); + await vi.waitFor(() => expect(f.provider.refresh).toHaveBeenCalledTimes(1)); + controller.abort(); + await expect(attempted).rejects.toMatchObject({ reason: 'cancelled' }); + f.advance(60_000); + held.resolve(); + await vi.waitFor(() => expect(f.store.counts().operations).toBe(0)); + f.advance(1_799_999); + expect(f.store.peek(binding)).not.toBeNull(); + f.advance(1); + expect(f.store.peek(binding)).toBeNull(); + }); + it('maintenance uses active idle lifetime rather than the expired login transaction deadline', async () => { + const f = longProvider(); + const { binding } = await f.login(); + f.advance(10 * 60_000); + f.store.sweep(); + expect(f.store.peek(binding)).not.toBeNull(); + f.advance(20 * 60_000); + f.store.sweep(); + expect(f.store.counts()).toMatchObject({ owners: 0, credentialRecords: 0 }); + }); + it('starts a fresh five-minute logout-return window when an older session signs out', async () => { + const f = longProvider(); + const { binding, finished } = await f.login(); + f.advance(4 * 60_000); + const logout = f.store.signout(binding, finished.session.csrfToken); + f.advance(2 * 60_000); + expect(f.store.consumeLogout({ ...binding, ...logout })).toBe(true); + expect(f.store.counts()).toMatchObject({ owners: 0, logoutStates: 0 }); + }); + it('rejects logout return at the exact five-minute boundary and reclaims its index', async () => { + const f = longProvider(); + const { binding, finished } = await f.login(); + f.advance(4 * 60_000); + const logout = f.store.signout(binding, finished.session.csrfToken); + f.advance(300_000); + expect(f.store.consumeLogout({ ...binding, ...logout })).toBe(false); + f.store.sweep(); + expect(f.store.counts()).toMatchObject({ owners: 0, logoutStates: 0 }); + }); + it('rejects a callback at the exact five-minute transaction boundary', async () => { + const f = longProvider(); + const started = await f.start(); + f.advance(300_000); + await expect(f.complete(started)).rejects.toMatchObject({ reason: 'expired' }); + expect(f.provider.redeem).not.toHaveBeenCalled(); + expect(f.store.counts()).toMatchObject({ owners: 0, loginStates: 0 }); + }); + it('cannot restore idle lifetime when post-refresh AccountSite finishes after expiry', async () => { + const f = longProvider(); + const { binding } = await f.login(); + const held = f.blockAccount(); + const pending = f.store.admit(binding, source, 'extend'); + const refused = expect(pending).rejects.toMatchObject({ reason: 'expired' }); + await vi.waitFor(() => expect(f.account).toHaveBeenCalledTimes(2)); + f.advance(1_800_000); + held.resolve(); + await refused; + expect(f.store.peek(binding)).toBeNull(); + expect(f.store.counts()).toMatchObject({ owners: 0, credentialRecords: 0 }); + }); + it('releases every failed begin reservation before another source slot is requested', async () => { + const f = fixture(); + vi.mocked(f.provider.begin).mockRejectedValue(new BffProviderError('transport')); + for (let i = 0; i < 17; ++i) { + await expect(f.start()).rejects.toMatchObject({ reason: 'transport' }); + expect(f.store.counts()).toMatchObject({ owners: 0, pending: 0, groups: 0, loginStates: 0 }); + } + expect(f.provider.begin).toHaveBeenCalledTimes(17); + vi.mocked(f.provider.begin).mockImplementation(async (callbackUri) => ({ + transaction: { callbackUri, state: secret(), nonce: secret(), verifier: secret() }, + redirectUrl: 'https://identity.example.test/authorize', + })); + await f.login(); + expect(f.store.counts().owners).toBe(1); + }); + it('idle same-browser replacement releases old owners and keeps one subject credit', async () => { + const f = fixture(); + let current = await f.login(); + for (let i = 0; i < 12; ++i) { + const old = current.binding; + current = await f.login({ browser: old.browser, handle: old.handle }); + expect(f.store.peek(old)).toBeNull(); + expect(f.store.counts()).toMatchObject({ owners: 1, subjects: 1, credentialRecords: 1 }); + } + }); + it('a concurrent second submission of the same callback never redeems its code twice', async () => { + const f = fixture(); + const started = await f.start(); + const held = f.blockRedeem(); + const first = f.complete(started); + await vi.waitFor(() => expect(f.provider.redeem).toHaveBeenCalledTimes(1)); + await expect(f.complete(started)).rejects.toMatchObject({ reason: 'transaction' }); + held.resolve(); + await first; + expect(f.provider.redeem).toHaveBeenCalledTimes(1); + expect(f.store.counts()).toMatchObject({ owners: 1, loginStates: 0 }); + }); + it('a logout return is single-use even while revoked AccountSite work remains unsettled', async () => { + const f = fixture(); + const { binding, finished } = await f.login(); + const held = f.blockAccount(); + const read = f.store.admit(binding, source, 'read'); + const refused = expect(read).rejects.toMatchObject({ reason: 'revoked' }); + await vi.waitFor(() => expect(f.account).toHaveBeenCalledTimes(2)); + const logout = f.store.signout(binding, finished.session.csrfToken); + expect(f.store.consumeLogout({ ...binding, ...logout })).toBe(true); + expect(f.store.consumeLogout({ ...binding, ...logout })).toBe(false); + expect(f.store.counts()).toMatchObject({ + owners: 1, + operations: 1, + credentialRecords: 0, + logoutStates: 0, + }); + held.resolve(); + await refused; + expect(Object.values(f.store.counts()).every((count) => count === 0)).toBe(true); + }); + it('equal external subject strings in different issuers do not share a subject capacity key', async () => { + const f = fixture(); + for (let i = 0; i < 8; ++i) await f.login(); + await expect(f.login()).rejects.toMatchObject({ reason: 'subject_capacity' }); + f.setIdentity({ issuer: 'https://second-identity.example.test/realms/learnstack' }); + await f.login(); + expect(f.store.counts()).toMatchObject({ owners: 9, subjects: 9 }); + }); + it('one browser proof may name independent host groups without rotating either host binding', async () => { + const f = fixture(); + const first = await f.login(); + const second = await f.login({ host: secondHost, browser: first.binding.browser }); + expect(second.binding.browser).toBe(first.binding.browser); + expect(f.store.peek(first.binding)).not.toBeNull(); + expect(f.store.peek(second.binding)).not.toBeNull(); + expect(f.store.counts()).toMatchObject({ owners: 2, groups: 2 }); + }); + it('observes only scalar owner-held custody and indexes through publication, logout and release', async () => { + const f = fixture(); + const started = await f.start(); + expect(f.store.counts()).toMatchObject({ + credentialRecords: 0, + transactionRecords: 1, + proofRecords: 1, + csrfRecords: 1, + loginStates: 1, + logoutStates: 0, + }); + const finished = await f.complete(started); + expect(f.store.counts()).toMatchObject({ + credentialRecords: 1, + transactionRecords: 0, + proofRecords: 0, + csrfRecords: 1, + loginStates: 0, + logoutStates: 0, + }); + const binding = { host, browser: finished.browser, handle: finished.handle }; + const logout = f.store.signout(binding, finished.session.csrfToken); + expect(f.store.counts()).toMatchObject({ + credentialRecords: 0, + transactionRecords: 0, + proofRecords: 1, + csrfRecords: 0, + loginStates: 0, + logoutStates: 1, + }); + expect(f.store.consumeLogout({ ...binding, ...logout })).toBe(true); + expect(Object.values(f.store.counts()).every((count) => count === 0)).toBe(true); + }); + it('clears owner-held credentials immediately while cancelled actual work keeps its owner until settlement', async () => { + const f = fixture(); + const { binding } = await f.login(); + const held = f.blockRefresh(); + const pending = f.store.admit(binding, source, 'extend'); + const refused = expect(pending).rejects.toMatchObject({ reason: 'stopped' }); + await vi.waitFor(() => expect(f.provider.refresh).toHaveBeenCalledTimes(1)); + const stopped = f.store.stop(); + expect(f.store.counts()).toMatchObject({ owners: 1, credentialRecords: 0, operations: 1 }); + held.resolve(); + await refused; + await stopped; + expect(Object.values(f.store.counts()).every((count) => count === 0)).toBe(true); + }); + it('projects an identical integral epoch deadline despite independent UTC sampling jitter', async () => { + let samples = 0; + const f = fixture({ initialTime: 1234.125, utcJitter: () => (++samples % 2 ? 0.3 : -0.4) }); + const { binding } = await f.login(); + const first = f.store.peek(binding)!.expiresAt; + expect(Number.isInteger(first)).toBe(true); + for (let i = 0; i < 10; ++i) { + f.advance(0.125); + expect(f.store.peek(binding)!.expiresAt).toBe(first); + } + }); +}); diff --git a/frontend/apps/web/src/server/auth/session-store.ts b/frontend/apps/web/src/server/auth/session-store.ts new file mode 100644 index 00000000..b943e3c1 --- /dev/null +++ b/frontend/apps/web/src/server/auth/session-store.ts @@ -0,0 +1,685 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto'; + +import { canonicalAddress, normalizeHost } from '../ingress.js'; +import { BffProviderError } from './provider-error.js'; +import type { LoginTransaction, ProviderCredentials } from './provider.js'; +import { BffSessionError } from './session-contract.js'; +import type { + AccountWitness, + BffSessionStore, + LoginCompletion, + LoginStart, + LogoutStart, + SessionActivity, + SessionBinding, + SessionDependencies, + SessionFailure, + SessionProjection, +} from './session-contract.js'; + +export const BFF_LIMITS = Object.freeze({ + owners: 1024, + pending: 128, + source: 16, + subject: 8, + transactionMs: 5 * 60_000, + idleMs: 30 * 60_000, + absoluteMs: 8 * 60 * 60_000, + sweepMs: 30_000, + refreshAheadMs: 60_000, + credentialBytes: 32 * 1024, +}); +const opaque = () => randomBytes(32).toString('base64url'); +export const isBffOpaque = (value: unknown): value is string => + typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/.test(value); +export function bffSecretEquals(left: unknown, right: unknown): boolean { + return ( + isBffOpaque(left) && + isBffOpaque(right) && + timingSafeEqual(Buffer.from(left), Buffer.from(right)) + ); +} + +type Group = { + host: string; + browser: string; + epoch: string; + members: Set; + current?: Owner; + publishing?: Owner; +}; +type Owner = { + id: string; + group: Group; + epoch: string; + source: string; + phase: 'starting' | 'pending' | 'exchange' | 'active' | 'logout' | 'closing'; + pending: boolean; + deadline: number; + controller: AbortController; + work: Set>; + transaction?: LoginTransaction; + proof?: string; + returnPath: '/' | '/studio' | '/portal'; + replacement?: Owner; + subject?: Readonly<{ key: string }>; + credentials?: ProviderCredentials; + account?: AccountWitness; + absolute: number; + activity: number; + utcOffset: number; + csrf: string; + refresh?: Promise; + logoutState?: string; + logoutProof?: string; +}; + +/** One native owner. No request constructs it; no credential-returning API exists. */ +export class InMemoryBffSessionStore implements BffSessionStore { + readonly #owners = new Map(); + readonly #states = new Map(); + readonly #logoutStates = new Map(); + readonly #groups = new Map(); + readonly #dependencies: SessionDependencies; + readonly #timer: ReturnType; + #stopped = false; + #stopping?: Promise; + + constructor(dependencies: SessionDependencies) { + this.#dependencies = dependencies; + this.#timer = setInterval(() => this.sweep(), BFF_LIMITS.sweepMs); + this.#timer.unref(); + } + + #now() { + return this.#dependencies.clock?.monotonic() ?? performance.now(); + } + #utc() { + return this.#dependencies.clock?.utc() ?? Date.now(); + } + #groupKey(host: string, browser: string) { + return JSON.stringify([host, browser]); + } + #checkAddress(host: string) { + if (typeof host !== 'string' || normalizeHost(host) !== host) + throw new BffSessionError('configuration'); + if (this.#stopped) throw new BffSessionError('stopped'); + } + #checkSource(host: string, source: string) { + this.#checkAddress(host); + if (typeof source !== 'string' || canonicalAddress(source) !== source) + throw new BffSessionError('configuration'); + } + #effective(entry: Owner) { + return Math.min( + entry.absolute, + entry.activity + BFF_LIMITS.idleMs, + entry.credentials?.refreshExpiresAt ?? entry.deadline, + ); + } + #live(entry: Owner) { + return ( + !this.#stopped && + entry.phase === 'active' && + entry.group.current === entry && + entry.epoch === entry.group.epoch && + this.#now() < this.#effective(entry) + ); + } + #assert(entry: Owner, phase: Owner['phase']) { + if (this.#stopped) throw new BffSessionError('stopped'); + if (entry.phase !== phase || entry.epoch !== entry.group.epoch) + throw new BffSessionError('revoked'); + const deadline = phase === 'active' ? this.#effective(entry) : entry.deadline; + if (this.#now() >= deadline) throw new BffSessionError('expired'); + } + #find(binding: SessionBinding) { + const entry = isBffOpaque(binding.handle) ? this.#owners.get(binding.handle) : undefined; + return entry && + entry.group.host === binding.host && + bffSecretEquals(entry.group.browser, binding.browser) && + this.#live(entry) + ? entry + : undefined; + } + #release(entry: Owner) { + if (entry.phase !== 'closing' || entry.work.size || this.#owners.get(entry.id) !== entry) + return; + this.#owners.delete(entry.id); + if (entry.transaction) this.#states.delete(entry.transaction.state); + if (entry.logoutState) this.#logoutStates.delete(entry.logoutState); + entry.source = ''; + entry.subject = undefined; + entry.transaction = undefined; + entry.logoutState = undefined; + entry.logoutProof = undefined; + entry.replacement = undefined; + entry.csrf = ''; + entry.group.members.delete(entry); + if (entry.group.current === entry) entry.group.current = undefined; + if (entry.group.publishing === entry) entry.group.publishing = undefined; + if (!entry.group.members.size) + this.#groups.delete(this.#groupKey(entry.group.host, entry.group.browser)); + } + #close(entry: Owner) { + entry.phase = 'closing'; + entry.credentials = undefined; + entry.account = undefined; + entry.proof = undefined; + entry.controller.abort(); + this.#release(entry); + } + #tracked(entry: Owner, action: () => Promise): Promise { + // Assign ownership before any action or continuation can execute. + const work = Promise.resolve().then(action); + entry.work.add(work); + const settled = () => { + entry.work.delete(work); + this.#release(entry); + }; + void work.then(settled, settled); + return work; + } + #exclusive(entry: Owner, signal: AbortSignal | undefined, action: () => Promise) { + const abort = () => this.#close(entry); + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); + return this.#tracked(entry, action).finally(() => signal?.removeEventListener('abort', abort)); + } + #reason(error: unknown): SessionFailure { + return error instanceof BffSessionError || error instanceof BffProviderError + ? error.reason + : 'response'; + } + #diagnostic(reason: SessionFailure) { + // Diagnostics cannot acquire lifecycle ownership or replace the original outcome. + try { + this.#dependencies.diagnostic?.(reason); + } catch { + /* fixed observer boundary */ + } + } + #project(entry: Owner): SessionProjection { + if (!this.#live(entry) || !entry.account) throw new BffSessionError('revoked'); + return Object.freeze({ + state: 'authenticated', + displayName: entry.account.displayName, + // Anchor the clock conversion once: projecting an unchanged monotonic + // deadline must not manufacture an extension from wall-clock sampling noise. + expiresAt: Math.round(entry.utcOffset + this.#effective(entry)), + limitedByAbsolute: this.#effective(entry) === entry.absolute, + csrfToken: entry.csrf, + }); + } + #account( + entry: Owner, + credentials: ProviderCredentials, + account: AccountWitness, + previous?: AccountWitness, + ) { + const identity = credentials.identity; + if ( + !account || + typeof account.displayName !== 'string' || + account.displayName.length > 1024 || + (account.organizationId !== null && + (typeof account.organizationId !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test( + account.organizationId, + ) || + account.organizationId === '00000000-0000-0000-0000-000000000000')) || + account.actorId !== identity.actorId || + account.tenantId !== identity.tenantId || + (identity.organizationId !== null && account.organizationId !== identity.organizationId) || + (previous && + (account.actorId !== previous.actorId || + account.tenantId !== previous.tenantId || + account.organizationId !== previous.organizationId)) + ) + throw new BffSessionError('identity'); + // Only closed fields are retained; additive provider/API objects cannot enter projections. + entry.account = Object.freeze({ + actorId: account.actorId, + tenantId: account.tenantId, + organizationId: account.organizationId, + displayName: account.displayName, + }); + } + #bundle(next: ProviderCredentials, previous?: ProviderCredentials) { + if ( + !next || + (!next.refreshToken && !previous?.refreshToken) || + !Number.isFinite(next.accessExpiresAt) || + !Number.isFinite(next.refreshExpiresAt) || + next.accessExpiresAt <= this.#now() || + next.refreshExpiresAt <= this.#now() || + (previous && + Object.keys(previous.identity).some( + (key) => + previous.identity[key as keyof typeof previous.identity] !== + next.identity[key as keyof typeof next.identity], + )) + ) + throw new BffSessionError('identity'); + const bundle = Object.freeze({ + accessToken: next.accessToken, + idToken: next.idToken, + identity: Object.freeze({ + issuer: next.identity.issuer, + subject: next.identity.subject, + actorId: next.identity.actorId, + tenantId: next.identity.tenantId, + organizationId: next.identity.organizationId, + }), + accessExpiresAt: next.accessExpiresAt, + refreshExpiresAt: next.refreshExpiresAt, + refreshToken: next.refreshToken ?? previous?.refreshToken, + }); + if (Buffer.byteLength(JSON.stringify(bundle)) > BFF_LIMITS.credentialBytes) + throw new BffSessionError('response'); + return bundle; + } + + /** Finite full scan; unsettled expired owners continue consuming all their credits. */ + sweep() { + const now = this.#now(); + for (const entry of this.#owners.values()) { + const expired = + entry.phase === 'active' ? now >= this.#effective(entry) : now >= entry.deadline; + if (expired && entry.phase !== 'closing') { + this.#close(entry); + this.#diagnostic('expired'); + } + this.#release(entry); + } + } + + async start(input: Parameters[0]): Promise { + this.#checkSource(input.host, input.source); + if (input.signal?.aborted) throw new BffSessionError('cancelled'); + if ( + !['/', '/studio', '/portal'].includes(input.returnPath) || + (input.browser !== undefined && !isBffOpaque(input.browser)) + ) + throw new BffSessionError('configuration'); + this.sweep(); + const pending = [...this.#owners.values()].filter((entry) => entry.pending); + const refusal = + pending.filter((entry) => entry.source === input.source).length >= BFF_LIMITS.source + ? 'source_capacity' + : pending.length >= BFF_LIMITS.pending + ? 'pending_capacity' + : this.#owners.size >= BFF_LIMITS.owners + ? 'global_capacity' + : null; + if (refusal) { + this.#diagnostic(refusal); + throw new BffSessionError(refusal); + } + let browser = input.browser ?? opaque(); + let group = this.#groups.get(this.#groupKey(input.host, browser)); + const replacement = input.handle + ? this.#find({ host: input.host, browser, handle: input.handle }) + : undefined; + if (group?.publishing) throw new BffSessionError('transaction'); + // A binding alone never authorizes eviction of a lost/other primary cookie. + if (group?.current && !replacement) { + browser = opaque(); + group = undefined; + } + if (!group) { + group = { host: input.host, browser, epoch: opaque(), members: new Set() }; + this.#groups.set(this.#groupKey(input.host, browser), group); + } + const now = this.#now(); + const entry: Owner = { + id: opaque(), + group, + epoch: group.epoch, + source: input.source, + phase: 'starting', + pending: true, + deadline: now + BFF_LIMITS.transactionMs, + controller: new AbortController(), + work: new Set(), + returnPath: input.returnPath, + replacement, + absolute: now + BFF_LIMITS.absoluteMs, + activity: now, + utcOffset: this.#utc() - now, + csrf: opaque(), + }; + group.members.add(entry); + this.#owners.set(entry.id, entry); + return this.#exclusive(entry, input.signal, async () => { + try { + this.#assert(entry, 'starting'); + const start = await this.#dependencies.provider.begin( + 'https://' + input.host + '/api/auth/callback/keycloak', + entry.controller.signal, + ); + this.#assert(entry, 'starting'); + if (!isBffOpaque(start.transaction.state) || this.#states.has(start.transaction.state)) + throw new BffSessionError('response'); + entry.transaction = Object.freeze({ ...start.transaction }); + entry.proof = opaque(); + entry.phase = 'pending'; + this.#states.set(start.transaction.state, entry); + return Object.freeze({ + state: start.transaction.state, + proof: entry.proof, + browser, + redirectUrl: start.redirectUrl, + }); + } catch (error) { + const reason = this.#stopped + ? 'stopped' + : input.signal?.aborted + ? 'cancelled' + : this.#reason(error); + this.#close(entry); + this.#diagnostic(reason); + throw new BffSessionError(reason); + } + }); + } + + async complete(input: Parameters[0]): Promise { + this.#checkSource(input.host, input.source); + const entry = isBffOpaque(input.state) ? this.#states.get(input.state) : undefined; + if ( + !entry || + entry.phase !== 'pending' || + entry.group.host !== input.host || + !bffSecretEquals(input.browser, entry.group.browser) || + !bffSecretEquals(input.proof, entry.proof) + ) + throw new BffSessionError('transaction'); + // Single-use before exchange; an unrelated/invalid proof never consumes it. + entry.phase = 'exchange'; + this.#states.delete(input.state); + return this.#exclusive(entry, input.signal, async () => { + try { + this.#assert(entry, 'exchange'); + const credentials = this.#bundle( + await this.#dependencies.provider.redeem( + entry.transaction!, + input.responseUrl, + entry.controller.signal, + ), + ); + this.#assert(entry, 'exchange'); + const account = await this.#dependencies.account( + credentials.accessToken, + { + host: input.host, + source: input.source, + ...(input.traceparent ? { traceparent: input.traceparent } : {}), + }, + entry.controller.signal, + ); + this.#assert(entry, 'exchange'); + this.#account(entry, credentials, account); + this.sweep(); + this.#assert(entry, 'exchange'); + const subject = JSON.stringify([credentials.identity.issuer, credentials.identity.subject]); + const old = + entry.replacement && this.#live(entry.replacement) ? entry.replacement : undefined; + const retained = new Set( + [...this.#owners.values()] + .map((value) => value.subject) + .filter((credit) => credit?.key === subject), + ).size; + if (retained >= BFF_LIMITS.subject && old?.subject?.key !== subject) + throw new BffSessionError('subject_capacity'); + // Reserve a new subject credit before retiring a different subject. A same- + // subject replacement borrows its old credit until actual old I/O settles. + entry.subject = + old?.subject?.key === subject ? old.subject : Object.freeze({ key: subject }); + entry.group.publishing = entry; + const epoch = opaque(); + entry.group.epoch = epoch; + entry.epoch = epoch; + for (const competing of entry.group.members) + if (competing !== entry && competing !== old && competing.pending) this.#close(competing); + if (old) { + this.#close(old); + await Promise.allSettled([...old.work]); + this.#assert(entry, 'exchange'); + } + entry.credentials = credentials; + entry.pending = false; + entry.source = ''; + entry.replacement = undefined; + entry.phase = 'active'; + entry.absolute = this.#now() + BFF_LIMITS.absoluteMs; + entry.activity = this.#now(); + entry.proof = undefined; + entry.transaction = undefined; + entry.group.current = entry; + entry.group.publishing = undefined; + this.#assert(entry, 'active'); + return Object.freeze({ + handle: entry.id, + browser: entry.group.browser, + returnPath: entry.returnPath, + session: this.#project(entry), + }); + } catch (error) { + const reason = this.#stopped + ? 'stopped' + : input.signal?.aborted + ? 'cancelled' + : this.#reason(error); + this.#close(entry); + this.#diagnostic(reason); + throw new BffSessionError(reason, input.state); + } + }); + } + + peek(binding: SessionBinding): SessionProjection | null { + const entry = this.#find(binding); + return entry ? this.#project(entry) : null; + } + + async #wait(work: Promise, signal?: AbortSignal): Promise { + if (!signal) return work; + if (signal.aborted) throw new BffSessionError('cancelled'); + let cancel!: () => void; + const interrupted = new Promise((_, reject) => { + cancel = () => reject(new BffSessionError('cancelled')); + signal.addEventListener('abort', cancel, { once: true }); + }); + try { + return await Promise.race([work, interrupted]); + } finally { + signal.removeEventListener('abort', cancel); + } + } + + async admit( + binding: SessionBinding, + source: string, + activity: SessionActivity, + signal?: AbortSignal, + ): Promise { + this.#checkSource(binding.host, source); + const entry = this.#find(binding); + if (!entry) throw new BffSessionError('revoked'); + if (signal?.aborted) throw new BffSessionError('cancelled'); + if (activity === 'passive') return this.#project(entry); + if (activity !== 'read' && activity !== 'extend') throw new BffSessionError('configuration'); + if ( + !entry.refresh && + (activity === 'extend' || + entry.credentials!.accessExpiresAt - this.#now() <= BFF_LIMITS.refreshAheadMs) + ) { + const previous = entry.credentials!; + const account = entry.account!; + const work = this.#tracked(entry, async () => { + try { + this.#assert(entry, 'active'); + const next = this.#bundle( + await this.#dependencies.provider.refresh( + previous.refreshToken!, + previous.identity, + entry.controller.signal, + ), + previous, + ); + this.#assert(entry, 'active'); + const witnessed = await this.#dependencies.account( + next.accessToken, + { + host: binding.host, + source, + ...(binding.traceparent ? { traceparent: binding.traceparent } : {}), + }, + entry.controller.signal, + ); + this.#assert(entry, 'active'); + this.#account(entry, next, witnessed, account); + entry.credentials = next; + entry.activity = this.#now(); + this.#assert(entry, 'active'); + } catch (error) { + const reason = this.#reason(error); + if (entry.phase !== 'logout') this.#close(entry); + this.#diagnostic(reason); + throw new BffSessionError(reason); + } + }); + entry.refresh = work; + const settled = () => { + if (entry.refresh === work) entry.refresh = undefined; + }; + void work.then(settled, settled); + } + if (entry.refresh) { + await this.#wait(entry.refresh, signal); + this.#assert(entry, 'active'); + entry.activity = this.#now(); + return this.#project(entry); + } + this.#assert(entry, 'active'); + // A normal authenticated read always uses the API's current authority too. + const credentials = entry.credentials!; + const work = this.#tracked(entry, async () => { + try { + this.#assert(entry, 'active'); + const account = await this.#dependencies.account( + credentials.accessToken, + { + host: binding.host, + source, + ...(binding.traceparent ? { traceparent: binding.traceparent } : {}), + }, + entry.controller.signal, + ); + this.#assert(entry, 'active'); + this.#account(entry, credentials, account, entry.account); + } catch (error) { + const reason = this.#reason(error); + // An ordinary read consumes no provider grant. A failed authority check + // denies this response, but only a proven identity refusal revokes the + // live session. Refresh above retains its stricter uncertainty fence. + if (reason === 'identity' && entry.phase !== 'logout') this.#close(entry); + this.#diagnostic(reason); + throw new BffSessionError(reason); + } + }); + await this.#wait(work, signal); + this.#assert(entry, 'active'); + entry.activity = this.#now(); + return this.#project(entry); + } + + signout(binding: SessionBinding, csrf: string): LogoutStart { + this.#checkAddress(binding.host); + const entry = this.#find(binding); + if (!entry || !bffSecretEquals(csrf, entry.csrf)) throw new BffSessionError('revoked'); + const idToken = entry.credentials!.idToken; + entry.group.epoch = opaque(); + entry.group.current = undefined; + for (const pending of entry.group.members) + if (pending !== entry && pending.pending) this.#close(pending); + entry.phase = 'logout'; + entry.controller.abort(); + entry.csrf = ''; + entry.returnPath = '/'; + entry.deadline = this.#now() + BFF_LIMITS.transactionMs; + entry.logoutState = opaque(); + entry.logoutProof = opaque(); + this.#logoutStates.set(entry.logoutState, entry); + let redirectUrl: string | null = null; + try { + redirectUrl = this.#dependencies.provider.logoutUrl( + idToken, + 'https://' + binding.host + '/', + entry.logoutState, + ); + } catch (error) { + this.#diagnostic(this.#reason(error)); + } finally { + entry.credentials = undefined; + entry.account = undefined; + } + this.#diagnostic('revoked'); + return Object.freeze({ state: entry.logoutState, proof: entry.logoutProof, redirectUrl }); + } + + consumeLogout(input: Parameters[0]): boolean { + const entry = isBffOpaque(input.state) ? this.#logoutStates.get(input.state) : undefined; + if ( + this.#stopped || + !entry || + entry.phase !== 'logout' || + this.#now() >= entry.deadline || + entry.group.host !== input.host || + !bffSecretEquals(input.browser, entry.group.browser) || + !bffSecretEquals(input.proof, entry.logoutProof) + ) + return false; + this.#logoutStates.delete(input.state); + this.#close(entry); + return true; + } + + stop(): Promise { + if (this.#stopping) return this.#stopping; + let completed!: () => void; + this.#stopping = new Promise((resolve) => { + completed = resolve; + }); + this.#stopped = true; + clearInterval(this.#timer); + const entries = [...this.#owners.values()]; + const work = entries.flatMap((entry) => [...entry.work]); + for (const entry of entries) this.#close(entry); + void Promise.allSettled(work).then(() => { + for (const entry of entries) this.#release(entry); + completed(); + }); + return this.#stopping; + } + + /** Scalar observation only: tests/health never acquire tokens or indexes. */ + counts() { + return Object.freeze({ + owners: this.#owners.size, + pending: [...this.#owners.values()].filter((entry) => entry.pending).length, + groups: this.#groups.size, + subjects: new Set([...this.#owners.values()].map((entry) => entry.subject).filter(Boolean)) + .size, + operations: [...this.#owners.values()].reduce((count, entry) => count + entry.work.size, 0), + credentialRecords: [...this.#owners.values()].filter((entry) => entry.credentials).length, + transactionRecords: [...this.#owners.values()].filter((entry) => entry.transaction).length, + proofRecords: [...this.#owners.values()].filter((entry) => entry.proof || entry.logoutProof) + .length, + csrfRecords: [...this.#owners.values()].filter((entry) => entry.csrf).length, + loginStates: this.#states.size, + logoutStates: this.#logoutStates.size, + }); + } +} diff --git a/frontend/apps/web/src/server/ingress.test.ts b/frontend/apps/web/src/server/ingress.test.ts index 283d98a9..cd2b5ac4 100644 --- a/frontend/apps/web/src/server/ingress.test.ts +++ b/frontend/apps/web/src/server/ingress.test.ts @@ -2,7 +2,9 @@ import { execFileSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { IncomingMessage } from 'node:http'; import { createServer, request } from 'node:https'; +import { Socket } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { connect } from 'node:tls'; @@ -33,6 +35,54 @@ const context = { } as const; describe('captured ingress provenance', () => { + it.each(['tenant.example', 'tenant.example:443', 'tenant.example:3001', 'tenant.example.:3000'])( + 'refuses unbound BFF authority %s while preserving anonymous public normalization', + (host) => { + const make = (target: string, method = 'GET') => { + const socket = new Socket(); + Object.defineProperty(socket, 'remoteAddress', { value: '127.0.0.1' }); + const request = new IncomingMessage(socket); + request.rawHeaders = ['Host', host]; + request.headers = { host, 'content-length': '0' }; + request.method = method; + request.url = target; + return request; + }; + for (const target of [ + '/api/auth/signin', + '/api/auth/callback/keycloak?state=x', + '/studio', + '/portal', + ]) { + const request = make(target); + expect(admitIncomingRequest(request, secret, true)).toBe(false); + expect(request.headers[INGRESS_HEADER]).toBeUndefined(); + } + expect(admitIncomingRequest(make('/api/auth/signout', 'POST'), secret, true)).toBe(false); + const publicRead = make('/en/courses'); + expect(admitIncomingRequest(publicRead, secret, true)).toBe(true); + expect(verifyProvenance(String(publicRead.headers[INGRESS_HEADER]), secret)?.host).toBe( + normalizeHost(host), + ); + }, + ); + it.each(['tenant.example:3000', 'Tenant.Example:3000', '[::1]:3000'])( + 'accepts the native BFF authority %s without trusting forwarded ports', + (host) => { + const socket = new Socket(); + Object.defineProperty(socket, 'remoteAddress', { value: '127.0.0.1' }); + const request = new IncomingMessage(socket); + request.rawHeaders = ['Host', host, 'X-Forwarded-Port', '4444']; + request.headers = { host, 'x-forwarded-port': '4444' }; + request.method = 'GET'; + request.url = '/api/auth/signin'; + expect(admitIncomingRequest(request, secret, true)).toBe(true); + expect(request.headers['x-forwarded-port']).toBeUndefined(); + expect(verifyProvenance(String(request.headers[INGRESS_HEADER]), secret)?.host).toBe( + normalizeHost(host), + ); + }, + ); it.each([ ['127.0.0.1', '127.0.0.1'], ['::ffff:127.0.0.1', '127.0.0.1'], diff --git a/frontend/apps/web/src/server/ingress.ts b/frontend/apps/web/src/server/ingress.ts index 48bc3bec..5336f868 100644 --- a/frontend/apps/web/src/server/ingress.ts +++ b/frontend/apps/web/src/server/ingress.ts @@ -4,6 +4,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { isIP } from 'node:net'; import { resolve } from 'node:path'; +import { AUTH_INPUT_LIMIT, bffPostTarget } from './auth/http-paths.js'; + export const INGRESS_HEADER = 'x-learnstack-ingress-provenance'; export const PUBLIC_HTTPS_PORT = 3000; export const MAX_TARGET_BYTES = 8192; @@ -173,7 +175,7 @@ export function verifyProvenance( } /** - * Next 15.5.27 normalizes `.rsc` on the full URL before parsing, then deletes + * Next 15.5.28 normalizes `.rsc` on the full URL before parsing, then deletes * `_rsc`, serializing URLSearchParams even when absent. Retain the signed raw * target for route identity and inert query data; this grants no suffix aliases. */ @@ -198,7 +200,11 @@ function privateInboundHeader(name: string): boolean { } /** Called before Next, including upgrades. Never signs a forwarded peer. */ -export function admitIncomingRequest(request: IncomingMessage, secret: string): boolean { +export function admitIncomingRequest( + request: IncomingMessage, + secret: string, + bffEnabled = false, +): boolean { const hosts: string[] = []; for (let i = 0; i < request.rawHeaders.length; i += 2) { if (request.rawHeaders[i]?.toLowerCase() === 'host') @@ -208,8 +214,40 @@ export function admitIncomingRequest(request: IncomingMessage, secret: string): const peer = canonicalAddress(request.socket.remoteAddress ?? ''); const method = request.method ?? ''; const target = request.url ?? ''; - if (!host || !peer || (method !== 'GET' && method !== 'HEAD') || !validTarget(target)) + if ( + !host || + !peer || + (method !== 'GET' && method !== 'HEAD' && !(bffEnabled && bffPostTarget(method, target))) || + !validTarget(target) + ) return false; + const path = target.split('?', 1)[0]; + const bffAuthority = + path === '/api/auth' || + path?.startsWith('/api/auth/') || + path === '/studio' || + path === '/portal'; + // Public Host syntax normalization remains unchanged. Credential flows must + // use the actual native HTTPS authority, not a caller-selected callback port + // or a trailing-dot origin that cannot pass the exact POST Origin check. + if ( + bffEnabled && + bffAuthority && + (hosts[0]?.toLowerCase() !== host || !host.endsWith(':' + PUBLIC_HTTPS_PORT)) + ) + return false; + if (bffPostTarget(method, target)) { + // Next may clone/buffer a middleware body before its route parser runs. Only + // known-length bounded forms reach it; chunked/ambiguous bodies stay outside. + const length = request.headers['content-length']; + if ( + request.headers['transfer-encoding'] !== undefined || + typeof length !== 'string' || + !/^[0-9]+$/.test(length) || + Number(length) > AUTH_INPUT_LIMIT + ) + return false; + } for (const name of Object.keys(request.headers)) { if (privateInboundHeader(name)) delete request.headers[name]; } 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 f62223a0..da738378 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,7 @@ // @vitest-environment node import { execFileSync } from 'node:child_process'; import { EventEmitter } from 'node:events'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -302,7 +302,13 @@ describe('native public admission lifetime and concurrent ownership', () => { 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']) { + mkdirSync(join(root, 'auth')); + for (const name of [ + 'public-admission-runtime', + 'public-api-limits', + 'ingress', + 'auth/http-paths', + ]) { 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 }, diff --git a/frontend/apps/web/src/server/public-admission-runtime.ts b/frontend/apps/web/src/server/public-admission-runtime.ts index 2cd46684..40209847 100644 --- a/frontend/apps/web/src/server/public-admission-runtime.ts +++ b/frontend/apps/web/src/server/public-admission-runtime.ts @@ -1,6 +1,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { IncomingMessage, ServerResponse } from 'node:http'; +import { bffPostTarget } from './auth/http-paths.js'; import { canonicalAddress, normalizeHost, validTarget } from './ingress.js'; import type { IngressContext } from './ingress.js'; import { MAX_PUBLIC_RESPONSE_BYTES } from './public-api-limits.js'; @@ -96,7 +97,7 @@ function snapshot(value: unknown): unknown { } /** Native-only construction. The active set owns shutdown, never request lookup. */ -export function createPublicAdmissionRuntime() { +export function createPublicAdmissionRuntime(bffEnabled = false) { const storage = new AsyncLocalStorage(); const active = new Set(); let stopped = false; @@ -182,7 +183,9 @@ export function createPublicAdmissionRuntime() { stopped || normalizeHost(binding.host) !== binding.host || canonicalAddress(binding.peer) !== binding.peer || - (binding.method !== 'GET' && binding.method !== 'HEAD') || + (binding.method !== 'GET' && + binding.method !== 'HEAD' && + !(bffEnabled && bffPostTarget(binding.method, binding.target))) || !validTarget(binding.target) ) return invariant(); @@ -236,10 +239,10 @@ export function createPublicAdmissionRuntime() { } /** Install once before Next loads its separately bundled middleware and RSC facades. */ -export function installPublicAdmissionRuntime() { +export function installPublicAdmissionRuntime(bffEnabled = false) { const key = Symbol.for('learnstack.public-admission.v1'); if (Object.getOwnPropertyDescriptor(globalThis, key)) return invariant(); - const runtime = createPublicAdmissionRuntime(); + const runtime = createPublicAdmissionRuntime(bffEnabled); Object.defineProperty(globalThis, key, { value: runtime.holder, enumerable: false, diff --git a/frontend/apps/web/src/server/public-request.ts b/frontend/apps/web/src/server/public-request.ts index 1b1fae90..5ea638c8 100644 --- a/frontend/apps/web/src/server/public-request.ts +++ b/frontend/apps/web/src/server/public-request.ts @@ -1,5 +1,6 @@ import 'server-only'; +import { bffSessionEnabled } from './auth/session-owner'; import { createConfiguredPublicClient, publicTraceparent } from './configured-public-client'; import type { ConfiguredPublicClient } from './configured-public-client'; import { INGRESS_HEADER, verifyProvenance } from './ingress'; @@ -42,7 +43,7 @@ const loadPublicRequest = requestMemo(async (incoming): Promise vi.useRealTimers()); +const roots: string[] = []; +afterEach(() => { + vi.useRealTimers(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); describe('stock browser Flight fallback observation', () => { it('exposes only a closed phase when a private browser operation fails', async () => { @@ -38,6 +47,9 @@ describe('stock browser Flight fallback observation', () => { expect(browserFailureDetail({ admissionStage: privateValue })).toBe(''); expect(browserFailureDetail(new Error(privateValue))).toBe(''); expect(browserFailureDetail(null)).toBe(''); + expect( + browserFailureDetail({ admissionStage: 'browser-devtools', kind: 'deadline-missing' }), + ).toBe(''); }); it('preserves successful browser operations and rejects unregistered phases', async () => { @@ -49,7 +61,7 @@ describe('stock browser Flight fallback observation', () => { }); it('reports browser death before reading a debugging port as a launch failure', async () => { - const readPort = vi.fn(() => 12345); + const readPort = vi.fn(() => ({ kind: 'ready' as const, port: 12345 })); const failure = await waitForBrowserDevTools({ assertActive: () => undefined, isLive: () => false, @@ -59,25 +71,123 @@ describe('stock browser Flight fallback observation', () => { expect(readPort).not.toHaveBeenCalled(); }); - it('reports a live browser without a debugging port at the unchanged readiness deadline', async () => { + it.each(['missing', 'empty', 'malformed'] as const)( + 'reports a live browser with %s readiness at the unchanged deadline', + async (kind) => { + vi.useFakeTimers(); + const waiting = waitForBrowserDevTools({ + assertActive: () => undefined, + isLive: () => true, + readPort: () => ({ kind }), + }).catch((error: Error) => error); + await vi.advanceTimersByTimeAsync(20_000); + expect(browserFailureDetail(await waiting)).toBe(' browser-devtools deadline-' + kind); + }, + ); + + it("preserves a caller's shorter ten-second readiness deadline", async () => { vi.useFakeTimers(); + let complete = false; const waiting = waitForBrowserDevTools({ assertActive: () => undefined, isLive: () => true, - readPort: () => null, - }).catch((error: Error) => error); - await vi.advanceTimersByTimeAsync(20_000); - expect(browserFailureDetail(await waiting)).toBe(' browser-devtools'); + readPort: () => ({ kind: 'missing' }), + timeout: 10_000, + }).catch((error: Error) => { + complete = true; + return error; + }); + await vi.advanceTimersByTimeAsync(9999); + expect(complete).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(browserFailureDetail(await waiting)).toBe(' browser-devtools deadline-missing'); }); it('accepts the debugging port of a live owned browser', async () => { const assertActive = vi.fn(); await expect( - waitForBrowserDevTools({ assertActive, isLive: () => true, readPort: () => 12345 }), + waitForBrowserDevTools({ + assertActive, + isLive: () => true, + readPort: () => ({ kind: 'ready', port: 12345 }), + }), ).resolves.toBe(12345); expect(assertActive).toHaveBeenCalledOnce(); }); + it('reads actual readiness files with closed observations and no check/read race', () => { + const root = mkdtempSync(join(tmpdir(), 'learnstack-admission-port-')); + roots.push(root); + const file = join(root, 'DevToolsActivePort'); + expect(readBrowserDevToolsPort(file)).toEqual({ kind: 'missing' }); + for (const value of ['', '\n/devtools/private-canary']) { + writeFileSync(file, value); + expect(readBrowserDevToolsPort(file)).toEqual({ kind: 'empty' }); + } + for (const value of [ + 'PRIVATE-CANARY', + '65536\n/devtools/private-canary', + '0x1234', + '12345junk', + ]) { + writeFileSync(file, value); + expect(readBrowserDevToolsPort(file)).toEqual({ kind: 'malformed' }); + } + writeFileSync(file, '12345\n/devtools/private-canary'); + expect(readBrowserDevToolsPort(file)).toEqual({ kind: 'ready', port: 12345 }); + rmSync(file); + expect(readBrowserDevToolsPort(file)).toEqual({ kind: 'missing' }); + mkdirSync(file); + expect(readBrowserDevToolsPort(file)).toEqual({ kind: 'unreadable' }); + }); + + it('waits through partial file publication until the live browser is ready', async () => { + vi.useFakeTimers(); + const readPort = vi + .fn() + .mockReturnValueOnce({ kind: 'missing' }) + .mockReturnValueOnce({ kind: 'empty' }) + .mockReturnValueOnce({ kind: 'malformed' }) + .mockReturnValue({ kind: 'ready', port: 12345 }); + const waiting = waitForBrowserDevTools({ + assertActive: () => undefined, + isLive: () => true, + readPort, + }); + await vi.advanceTimersByTimeAsync(150); + await expect(waiting).resolves.toBe(12345); + expect(readPort).toHaveBeenCalledTimes(4); + }); + + it.each(['owner-inactive', 'port-unreadable'] as const)( + 'keeps %s failures closed without private error text', + async (kind) => { + const refuse = () => { + throw new Error('PRIVATE-CANARY'); + }; + const failure = await waitForBrowserDevTools({ + assertActive: kind === 'owner-inactive' ? refuse : () => undefined, + isLive: () => true, + readPort: kind === 'port-unreadable' ? refuse : () => ({ kind: 'missing' }), + }).catch((error: Error) => error); + expect(browserFailureDetail(failure)).toBe(' browser-devtools ' + kind); + expect(String(failure)).not.toContain('PRIVATE-CANARY'); + expect(failure).not.toHaveProperty('cause'); + }, + ); + + it('observes browser death at the deadline boundary without reporting a live timeout', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const waiting = waitForBrowserDevTools({ + assertActive: () => undefined, + isLive: () => Date.now() < 20_000, + readPort: () => ({ kind: 'missing' }), + }).catch((error: Error) => error); + await vi.advanceTimersByTimeAsync(20_000); + expect(browserFailureDetail(await waiting)).toBe(' browser-launch'); + }); + it('waits for an initially empty target list to expose the owned page', async () => { vi.useFakeTimers(); const fetch = vi diff --git a/frontend/apps/web/src/test/admission-hmr.test.ts b/frontend/apps/web/src/test/admission-hmr.test.ts index e801e279..b0a91b90 100644 --- a/frontend/apps/web/src/test/admission-hmr.test.ts +++ b/frontend/apps/web/src/test/admission-hmr.test.ts @@ -8,13 +8,20 @@ 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'])( + it.each([ + 'clean', + 'old-site', + 'old-source', + 'active-context', + 'retained-snapshot', + 'bad-status', + ] as const)( '%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 page = join(app, 'src/app/(public)/[locale]/courses/page.tsx'); + mkdirSync(join(app, 'src/app/(public)/[locale]/courses'), { recursive: true }); + writeFileSync(page, 'original disposable catalog source'); const child = { pid: 42, exitCode: null }; const checkpoints: string[] = []; let calls = 0; @@ -23,7 +30,7 @@ describe('development admission proof controls', () => { try { const run = verifyHmrAdmission({ app, - configuration: { tenants: [{ name: 'First institution' }] }, + configuration: { locale: 'en', tenants: [{ name: 'First institution' }] }, checkpoint: async (name) => { checkpoints.push(name); }, @@ -70,9 +77,27 @@ describe('development admission proof controls', () => { 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(); + } else { + const stage = { + 'old-site': 'hmr-refresh-site', + 'old-source': 'hmr-refresh-source', + 'active-context': 'hmr-initial-release', + 'retained-snapshot': 'hmr-initial-release', + 'bad-status': 'hmr-initial-status', + }[mode as Exclude]; + await expect(run).rejects.toMatchObject({ + message: 'Development admission proof failed', + admissionStage: stage, + }); + const changed = mode === 'old-site' || mode === 'old-source'; + expect(calls).toBe(changed ? 2 : 1); + expect(checkpoints).toEqual( + changed ? ['hmr-before', 'hmr-change', 'hmr-restore'] : ['hmr-before'], + ); + expect(watcherClosed).toBe(changed); + } expect(stopped).toBe(true); - expect(readFileSync(page, 'utf8')).toBe('original disposable studio source'); + expect(readFileSync(page, 'utf8')).toBe('original disposable catalog source'); } finally { rmSync(app, { recursive: true, force: true }); } diff --git a/frontend/apps/web/src/test/bff-auth-admission.test.ts b/frontend/apps/web/src/test/bff-auth-admission.test.ts new file mode 100644 index 00000000..754aaca7 --- /dev/null +++ b/frontend/apps/web/src/test/bff-auth-admission.test.ts @@ -0,0 +1,371 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from 'vitest'; + +import { bffFixture, bffHost, bffOpaque, bffRequest } from './bff-fixture'; +import { + AUTH_INPUT_LIMIT, + BFF_COOKIES, + bffCookie, + bffCookies, + clearMalformedBffCookies, +} from '../server/auth/cookies'; +import { + BFF_POST_PATHS, + bffActivity, + bffForm, + bffQuery, + bffRoute, +} from '../server/auth/http-input'; + +const postPaths = ['/api/auth/signin/keycloak', '/api/auth/signout', '/api/auth/extend']; +const readPaths = ['/api/auth/signin', '/api/auth/csrf', '/api/auth/session', '/api/auth/error']; +const methods = [ + 'GET', + 'HEAD', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'OPTIONS', + 'CONNECT', + 'TRACE', + 'get', + 'post', +]; + +const fixtures: ReturnType[] = []; +const fixture = () => { + const f = bffFixture(); + fixtures.push(f); + return f; +}; +afterEach(async () => { + await Promise.all(fixtures.splice(0).map((f) => f.store.stop())); +}); + +describe('Bff_Auth_Admission_And_Csrf_Are_Closed', () => { + it('admits only the exact method/route set', () => { + expect(BFF_POST_PATHS).toEqual(postPaths); + const paths = [ + ...readPaths, + ...postPaths, + '/api/auth/callback/keycloak', + '/api/auth/refresh', + '/api/auth/providers', + '/api/auth/signin/arbitrary', + '/api/auth', + '/studio', + ]; + for (const path of [...paths, ...paths.map((path) => path + '/')]) + for (const method of methods) + expect(bffRoute(method, path), method + ' ' + path).toBe( + (method === 'POST' && postPaths.includes(path)) || + (method === 'GET' && path === '/api/auth/callback/keycloak') || + (['GET', 'HEAD'].includes(method) && readPaths.includes(path)), + ); + }); + it.each( + postPaths.flatMap((path) => + methods.filter((method) => method !== 'POST').map((method) => [method, path]), + ), + )('refuses %s %s with valid session/CSRF before any operation', async (method, path) => { + const f = fixture(); + const current = await f.login(); + const before = f.store.peek(current.binding)!; + const response = await f.http.handle( + bffRequest(path!, { + method, + cookie: current.cookie, + form: 'csrfToken=' + before.csrfToken, + }), + ); + expect(response!.status).toBe(404); + expect(response!.headers.getSetCookie()).toEqual([]); + expect(f.store.peek(current.binding)).toEqual(before); + expect(f.provider.begin).toHaveBeenCalledTimes(1); + expect(f.provider.refresh).not.toHaveBeenCalled(); + expect(f.provider.logoutUrl).not.toHaveBeenCalled(); + expect(f.account).toHaveBeenCalledTimes(1); + }); + it.each( + readPaths.flatMap((path) => [false, true].map((authenticated) => ({ path, authenticated }))), + )( + 'HEAD is passive without cookie allocation or rendering: %j', + async ({ path, authenticated }) => { + const f = fixture(); + const current = authenticated ? await f.login() : null; + f.advance(840_000); + const before = current && f.store.peek(current.binding); + const counts = f.store.counts(); + let renders = 0; + const response = await f.http.handle( + bffRequest(path, { method: 'HEAD', cookie: current?.cookie }), + () => { + ++renders; + return '
owned
'; + }, + ); + expect(response!.status).toBe(path.endsWith('/error') ? 400 : 200); + expect(await response!.text()).toBe(''); + expect(response!.headers.getSetCookie()).toEqual([]); + expect(renders).toBe(0); + expect(f.store.counts()).toEqual(counts); + if (current) expect(f.store.peek(current.binding)).toEqual(before); + expect(f.account).toHaveBeenCalledTimes(authenticated ? 1 : 0); + expect(f.provider.refresh).not.toHaveBeenCalled(); + }, + ); + it.each(['GET', 'HEAD', 'POST'])( + 'unknown %s auth actions remain masked without owner/provider work', + async (method) => { + const f = fixture(); + const response = await f.http.handle(bffRequest('/api/auth/providers', { method })); + expect(response!.status).toBe(404); + expect(response!.headers.get('cache-control')).toBe('no-store'); + expect(f.store.counts().owners).toBe(0); + expect(f.provider.begin).not.toHaveBeenCalled(); + if (method === 'HEAD') expect(await response!.text()).toBe(''); + }, + ); + it.each([ + 'csrfToken=x&csrfToken=y', + 'unknown=x', + 'csrfToken=%FF', + 'csrfToken=%', + 'csrfToken=x&&returnTo=%2F', + 'csrfToken', + 'csrfToken=x&returnTo=%C0%AF', + ])('refuses undeclared/duplicate/malformed scalar form: %s', async (form) => { + await expect( + bffForm(bffRequest('/api/auth/signin/keycloak', { method: 'POST', form }), [ + 'csrfToken', + 'returnTo', + ]), + ).rejects.toThrow('BFF input refused'); + }); + it('accepts exact UTF-8 byte limit and refuses its next byte', async () => { + const good = 'value=' + 'x'.repeat(AUTH_INPUT_LIMIT - 6); + expect((await bffForm(bffRequest('/', { form: good }), ['value'])).get('value')?.length).toBe( + AUTH_INPUT_LIMIT - 6, + ); + await expect(bffForm(bffRequest('/', { form: good + 'x' }), ['value'])).rejects.toThrow(); + }); + it.each(['application/json', 'text/plain', 'application/x-www-form-urlencoded;charset=latin1'])( + 'refuses unsupported media %s', + async (media) => { + await expect( + bffForm(bffRequest('/', { form: 'value=x', headers: { 'content-type': media } }), [ + 'value', + ]), + ).rejects.toThrow(); + }, + ); + it.each(['state=x&state=y', 'state=%FF', 'state=%', 'state=x&other=y'])( + 'rejects ambiguous query %s', + (query) => { + expect(() => bffQuery('/?' + query, ['state'])).toThrow(); + }, + ); + it('the actual stream is cancelled on request abort, without waiting for more attacker body', async () => { + let cancel = 0; + const controller = new AbortController(); + const body = new ReadableStream({ + cancel() { + ++cancel; + }, + }); + const request = { ...bffRequest('/', { form: 'value=x', signal: controller.signal }), body }; + const pending = bffForm(request, ['value']); + controller.abort(); + await expect(pending).rejects.toThrow(); + expect(cancel).toBe(1); + }); + it.each( + postPaths.flatMap((path) => + [ + null, + 'https://foreign.example', + 'null', + 'http://' + bffHost, + 'https://' + bffHost + '/', + ].map((origin) => ({ path, origin })), + ), + )('rejects wrong Origin with otherwise valid CSRF/session: %j', async ({ path, origin }) => { + const f = fixture(); + const current = path.endsWith('/signin/keycloak') ? null : await f.login(); + f.advance(840_000); + const before = current && f.store.peek(current.binding); + const token = before?.csrfToken ?? bffOpaque(); + const counts = f.store.counts(); + const request = bffRequest(path, { + method: 'POST', + cookie: current?.cookie ?? BFF_COOKIES.csrf + '=' + token, + form: 'csrfToken=' + token, + }); + if (origin === null) request.headers.delete('origin'); + else request.headers.set('origin', origin); + const response = await f.http.handle(request); + expect(response!.status).toBe(400); + expect(response!.headers.getSetCookie()).toEqual([]); + expect(f.store.counts()).toEqual(counts); + if (current) expect(f.store.peek(current.binding)).toEqual(before); + expect(f.provider.begin).toHaveBeenCalledTimes(current ? 1 : 0); + expect(f.provider.refresh).not.toHaveBeenCalled(); + expect(f.provider.logoutUrl).not.toHaveBeenCalled(); + expect(f.account).toHaveBeenCalledTimes(current ? 1 : 0); + }); + it('same-origin alone cannot pass either presession or authenticated CSRF', async () => { + const f = fixture(); + const token = bffOpaque(); + expect( + (await f.http.handle( + bffRequest('/api/auth/signin/keycloak', { + method: 'POST', + cookie: BFF_COOKIES.csrf + '=' + token, + form: 'csrfToken=' + bffOpaque(), + }), + ))!.status, + ).toBe(403); + const active = await f.login(); + for (const path of ['signout', 'extend']) + expect( + (await f.http.handle( + bffRequest('/api/auth/' + path, { + method: 'POST', + cookie: active.cookie, + form: 'csrfToken=' + bffOpaque(), + }), + ))!.status, + ).toBe(403); + expect(f.store.peek(active.binding)).not.toBeNull(); + expect(f.provider.refresh).not.toHaveBeenCalled(); + }); + it('browser Authorization and redirect selectors never reach provider or store admission', async () => { + const f = fixture(); + for (const request of [ + bffRequest('/api/auth/csrf', { headers: { authorization: 'Bearer private-canary' } }), + bffRequest('/api/auth/signin?returnTo=https%3A%2F%2Fevil.example'), + bffRequest('/api/auth/signin?callbackUrl=%2Fstudio'), + ]) { + const response = await f.http.handle(request); + expect(response!.status).toBe(400); + expect(await response!.text()).not.toContain('private-canary'); + expect(response!.headers.getSetCookie()).toEqual([]); + } + expect(f.provider.begin).not.toHaveBeenCalled(); + }); + it.each([ + 'https://foreign.example/studio', + '//foreign.example/studio', + 'javascript:alert(1)', + '/studio/nested', + '/studio?next=https://foreign.example', + '/studio#section', + '/%2f%2fforeign.example', + '/%73tudio', + '/\\foreign.example', + '\r\nhttps://foreign.example', + ])('POST sign-in rejects returnTo %j after valid Origin and CSRF', async (destination) => { + const f = fixture(); + const token = bffOpaque(); + const counts = f.store.counts(); + const response = await f.http.handle( + bffRequest('/api/auth/signin/keycloak', { + method: 'POST', + cookie: BFF_COOKIES.csrf + '=' + token, + form: new URLSearchParams({ csrfToken: token, returnTo: destination }).toString(), + }), + ); + expect(response!.status).toBe(400); + expect(await response!.json()).toEqual({ code: 'invalid_request' }); + expect(response!.headers.has('location')).toBe(false); + expect(response!.headers.getSetCookie()).toEqual([]); + expect(f.store.counts()).toEqual(counts); + expect(f.provider.begin).not.toHaveBeenCalled(); + expect(f.account).not.toHaveBeenCalled(); + }); + it.each(['/', '/studio', '/portal'])( + 'POST sign-in accepts only the exact local returnTo %s with valid Origin and CSRF', + async (destination) => { + const f = fixture(); + const token = bffOpaque(); + const response = await f.http.handle( + bffRequest('/api/auth/signin/keycloak', { + method: 'POST', + cookie: BFF_COOKIES.csrf + '=' + token, + form: new URLSearchParams({ csrfToken: token, returnTo: destination }).toString(), + }), + ); + expect(response!.status).toBe(303); + expect(f.provider.begin).toHaveBeenCalledOnce(); + expect(f.store.counts().pending).toBe(1); + }, + ); + it('all emitted cookies are single opaque host cookies without credentials, Domain or chunks', () => { + const token = bffOpaque(); + for (const name of [ + BFF_COOKIES.session, + BFF_COOKIES.browser, + BFF_COOKIES.csrf, + BFF_COOKIES.login + token, + BFF_COOKIES.logout + token, + ]) { + const cookie = bffCookie(name, token, 300); + expect(cookie).toContain('; Path=/; Max-Age=300; HttpOnly; Secure; SameSite=Lax'); + expect(cookie).not.toMatch(/Domain=|private-|Bearer/); + } + }); + it.each([ + '__Host-ls-session=x', + '__Host-ls-session.0=' + bffOpaque(), + '__Host-ls-unknown=' + bffOpaque(), + '__Host-ls-session=' + bffOpaque() + '; __Host-ls-session=' + bffOpaque(), + '__Host-ls-login-x=' + bffOpaque(), + ])('refuses malformed/chunked/duplicate auth cookies %s', (cookie) => { + expect(() => bffCookies(cookie)).toThrow(); + }); + it('bounds the whole incoming auth family and ignores unrelated cookies without forwarding them', () => { + const value = bffOpaque(); + const members = Array.from( + { length: 130 }, + () => BFF_COOKIES.login + bffOpaque() + '=' + value, + ); + expect(() => bffCookies(members.join('; '))).not.toThrow(); + expect(() => + bffCookies( + [ + ...members, + ...Array.from({ length: 40 }, () => BFF_COOKIES.logout + bffOpaque() + '=' + value), + ].join('; '), + ), + ).toThrow(); + expect([...bffCookies('foreign=arbitrary%FF; ' + BFF_COOKIES.session + '=' + value)]).toEqual([ + [BFF_COOKIES.session, value], + ]); + }); + it.each([ + ['HEAD', { accept: 'text/html' }, 'passive'], + ['GET', { rsc: '1' }, 'read'], + ['GET', { rsc: '1', 'next-router-prefetch': '1' }, 'passive'], + ['GET', { 'sec-purpose': 'prefetch;prerender', accept: 'text/html' }, 'passive'], + ['GET', { 'sec-fetch-dest': 'document' }, 'read'], + ['GET', { accept: 'text/html' }, 'passive'], + ['GET', { accept: 'text/html', 'sec-fetch-dest': 'empty' }, 'passive'], + ['GET', {}, 'passive'], + ] as const)('activity classification %s %j is %s', (method, headers, expected) => { + expect(bffActivity(method, new Headers(headers))).toBe(expected); + }); + it('malformed-cookie recovery bounds deletion bytes and never reflects invalid names or values', () => { + expect(clearMalformedBffCookies('foreign=private; __Host-ls-invalid name=private')).toEqual([]); + const edits = clearMalformedBffCookies( + Array.from( + { length: 500 }, + (_, index) => '__Host-ls-malformed-' + index + '=private-cookie-canary', + ).join('; '), + ); + expect(edits.length).toBeGreaterThan(0); + expect(Buffer.byteLength(edits.join(''))).toBeLessThanOrEqual(AUTH_INPUT_LIMIT); + expect(edits.join('')).not.toContain('private'); + expect(clearMalformedBffCookies(null)).toEqual([]); + }); +}); diff --git a/frontend/apps/web/src/test/bff-boundaries.test.ts b/frontend/apps/web/src/test/bff-boundaries.test.ts new file mode 100644 index 00000000..572a31dc --- /dev/null +++ b/frontend/apps/web/src/test/bff-boundaries.test.ts @@ -0,0 +1,364 @@ +// @vitest-environment node +import { execFileSync } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { readFileSync, realpathSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { bffHost, bffOpaque, bffSource } from './bff-fixture'; +import { createBffRequestRuntime } from '../server/auth/request-runtime'; + +const context = { host: bffHost, peer: bffSource, method: 'GET', target: '/studio' }; +function lifetime() { + return { + request: Object.assign(new EventEmitter(), { aborted: false, headers: {} }), + response: Object.assign(new EventEmitter(), { destroyed: false, writableEnded: false }), + }; +} +const projection = () => ({ + state: 'authenticated' as const, + displayName: 'School', + expiresAt: Date.now() + 30_000, + limitedByAbsolute: false, + csrfToken: bffOpaque(), +}); +describe('Bff_Credentials_And_Projections_Are_Confined', () => { + it('isolates overlapping equal bindings and closes escaped readers on actual response completion', async () => { + const runtime = createBffRequestRuntime(); + const first = lifetime(); + const second = lifetime(); + const a = projection(); + const b = projection(); + let escaped: ReturnType; + await runtime.run(context, first.request, first.response, async () => { + expect(() => runtime.holder.read(context)).toThrow(); + runtime.holder.publish(context, a); + escaped = runtime.holder.read(context); + expect(escaped.session).toEqual(a); + expect(Object.isFrozen(escaped.session)).toBe(true); + await runtime.run(context, second.request, second.response, async () => { + runtime.holder.publish(context, b); + expect(runtime.holder.read(context).session).toEqual(b); + expect(() => escaped.assertActive()).toThrow(); + second.response.emit('finish'); + expect(() => runtime.holder.read(context)).toThrow(); + }); + escaped.assertActive(); + first.response.emit('finish'); + expect(() => escaped.assertActive()).toThrow(); + }); + expect(runtime.counts()).toBe(0); + runtime.stop(); + }); + it.each(['aborted', 'finish', 'close'])( + 'actual %s closes request authority and removes native listeners', + (event) => { + const runtime = createBffRequestRuntime(); + const { request, response } = lifetime(); + runtime.run(context, request, response, () => { + runtime.holder.publish(context, null); + (event === 'aborted' ? request : response).emit(event); + expect(() => runtime.holder.read(context)).toThrow(); + expect( + request.listenerCount('aborted') + + response.listenerCount('finish') + + response.listenerCount('close'), + ).toBe(0); + }); + expect(runtime.counts()).toBe(0); + }, + ); + it('copies only safe fields and denies re-publication, wrong bindings and publication after stop', () => { + const runtime = createBffRequestRuntime(); + const { request, response } = lifetime(); + const value = { ...projection(), token: 'private-canary' }; + runtime.run(context, request, response, () => { + runtime.holder.publish(context, value); + value.displayName = 'mutated'; + expect(runtime.holder.read(context).session?.displayName).toBe('School'); + expect(JSON.stringify(runtime.holder.read(context))).not.toContain('private-canary'); + expect(() => runtime.holder.publish(context, null)).toThrow(); + expect(() => runtime.holder.read({ ...context, host: 'other.example.test:3000' })).toThrow(); + runtime.stop(); + expect(() => runtime.holder.read(context)).toThrow(); + }); + expect(() => runtime.run(context, request, response, () => null)).toThrow(); + }); + it('captures only the activity scalar before Next removes or mutates Flight headers', () => { + const runtime = createBffRequestRuntime(); + for (const prefetch of [false, true]) { + const owner = lifetime(); + Object.assign(owner.request.headers, { + rsc: '1', + ...(prefetch ? { 'next-router-prefetch': '1' } : {}), + }); + runtime.run(context, owner.request, owner.response, () => { + delete (owner.request.headers as Record).rsc; + delete (owner.request.headers as Record)['next-router-prefetch']; + expect(runtime.holder.activity(context)).toBe(prefetch ? 'passive' : 'read'); + runtime.holder.publish(context, null); + expect(() => runtime.holder.activity(context)).toThrow(); + owner.response.emit('finish'); + }); + } + expect(runtime.counts()).toBe(0); + runtime.stop(); + }); + it.each([ + { csrfToken: 'not-an-opaque-secret' }, + { displayName: 'x'.repeat(1025) }, + { expiresAt: Number.NaN }, + { expiresAt: Number.POSITIVE_INFINITY }, + { limitedByAbsolute: 'true' }, + { state: 'anonymous' }, + undefined, + ])('invalid projection publication closes request authority: %j', (patch) => { + const runtime = createBffRequestRuntime(); + const { request, response } = lifetime(); + runtime.run(context, request, response, () => { + const invalid = patch === undefined ? undefined : { ...projection(), ...patch }; + expect(() => + runtime.holder.publish(context, invalid as ReturnType), + ).toThrow(); + expect(() => runtime.holder.read(context)).toThrow(); + }); + expect(runtime.counts()).toBe(0); + expect( + request.listenerCount('aborted') + + response.listenerCount('finish') + + response.listenerCount('close'), + ).toBe(0); + }); + it.each([{ peer: '203.0.113.72' }, { method: 'HEAD' }, { target: '/portal' }])( + 'denies a different request binding %j without changing the correct snapshot', + (patch) => { + const runtime = createBffRequestRuntime(); + const { request, response } = lifetime(); + runtime.run(context, request, response, () => { + const session = projection(); + runtime.holder.publish(context, session); + expect(() => runtime.holder.read({ ...context, ...patch })).toThrow(); + expect(runtime.holder.read(context).session).toEqual(session); + response.emit('finish'); + }); + expect(runtime.counts()).toBe(0); + }, + ); + it.each(['aborted', 'destroyed', 'writableEnded'] as const)( + 'never invokes work for a request/response that is already %s', + (flag) => { + const runtime = createBffRequestRuntime(); + const { request, response } = lifetime(); + if (flag === 'aborted') request.aborted = true; + else response[flag] = true; + let entered = false; + expect(() => + runtime.run(context, request, response, () => { + entered = true; + }), + ).toThrow(); + expect(entered).toBe(false); + expect(runtime.counts()).toBe(0); + expect( + request.listenerCount('aborted') + + response.listenerCount('finish') + + response.listenerCount('close'), + ).toBe(0); + }, + ); + it('synchronous work failure relinquishes native listeners and active authority', () => { + const runtime = createBffRequestRuntime(); + const { request, response } = lifetime(); + const failure = new Error('owned-work-failure'); + expect(() => + runtime.run(context, request, response, () => { + throw failure; + }), + ).toThrow(failure); + expect(runtime.counts()).toBe(0); + expect( + request.listenerCount('aborted') + + response.listenerCount('finish') + + response.listenerCount('close'), + ).toBe(0); + }); + it('resolves the pinned RP graph to one copy of each protocol and signing dependency', () => { + const require = createRequire(import.meta.url); + const client = require.resolve('openid-client'); + const oauth = realpathSync(createRequire(client).resolve('oauth4webapi')); + const directJose = realpathSync(require.resolve('jose')); + const transitiveJose = realpathSync(createRequire(client).resolve('jose')); + const manifest = (entry: string, name: string): { name: string; version: string } => { + let directory = dirname(entry); + while (dirname(directory) !== directory) { + try { + const value = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')); + if (value.name === name) return value; + } catch { + /* parent directory may not be a package boundary */ + } + directory = dirname(directory); + } + throw new Error('Reachable dependency has no package manifest'); + }; + const declared = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), + ); + expect(declared.dependencies['openid-client']).toBe('6.8.8'); + expect(declared.devDependencies.jose).toBe('6.2.13'); + expect(manifest(client, 'openid-client').version).toBe('6.8.8'); + expect(manifest(oauth, 'oauth4webapi').version).toBe('3.8.8'); + expect(manifest(directJose, 'jose').version).toBe('6.2.13'); + expect(transitiveJose).toBe(directJose); + const lock = readFileSync(new URL('../../../../pnpm-lock.yaml', import.meta.url), 'utf8'); + for (const [name, version] of [ + ['openid-client', '6.8.8'], + ['oauth4webapi', '3.8.8'], + ['jose', '6.2.13'], + ]) { + const versions = [...lock.matchAll(new RegExp('^ ' + name + '@([^:]+):', 'gm'))].map( + (match) => match[1], + ); + expect(new Set(versions)).toEqual(new Set([version])); + } + const locked = (source: string, name: string) => + source + .split('\n') + .some((line) => ['', "'", '"'].some((quote) => line.startsWith(' ' + quote + name + '@'))); + for (const name of ['next-auth', '@auth/core', 'better-auth']) { + for (const section of [declared.dependencies, declared.devDependencies]) + expect(section).not.toHaveProperty(name); + expect(locked(lock, name)).toBe(false); + for (const quote of ['', "'", '"']) + expect(locked(lock + '\n ' + quote + name + '@0.41.3' + quote + ':\n', name)).toBe(true); + } + }); + it('the actual separately compiled native module and pinned NextRequest share only the immutable projection', () => { + const moduleUrl = new URL('../../.server/auth/request-runtime.js', import.meta.url).href; + const program = [ + "import assert from 'node:assert/strict';", + "import { EventEmitter } from 'node:events';", + "import { NextRequest } from 'next/server.js';", + 'const { installBffRequestRuntime } = await import(process.argv[1]);', + 'const runtime = installBffRequestRuntime();', + 'const context = { host: "institution.example.test:3000", peer: "203.0.113.1", method: "GET", target: "/studio" };', + 'const request = Object.assign(new EventEmitter(), { aborted: false, headers: {} }); const response = Object.assign(new EventEmitter(), { destroyed: false, writableEnded: false });', + 'const next = new NextRequest("https://" + context.host + context.target); assert.equal(next.method, context.method);', + 'await runtime.run(context, request, response, async () => { runtime.holder.publish(context, null); await Promise.resolve(); assert.equal(runtime.holder.read(context).session, null); response.emit("finish"); assert.throws(() => runtime.holder.read(context)); });', + 'assert.equal(runtime.counts(), 0); runtime.stop();', + ].join('\n'); + expect( + execFileSync(process.execPath, ['--input-type=module', '-e', program, moduleUrl], { + encoding: 'utf8', + timeout: 10_000, + }), + ).toBe(''); + }); + it('the minimal production-mode Next protocol harness preserves native session authority through HTTP', () => { + const script = new URL('../../scripts/verify-bff-protocol.mjs', import.meta.url); + expect( + execFileSync(process.execPath, [script.pathname], { + encoding: 'utf8', + timeout: 150_000, + maxBuffer: 65536, + }), + ).toBe('BFF native Next protocol: passed\n'); + }, 160_000); + it('the same actual Next proof rejects a copy that drops native activity at the middleware handoff', () => { + const script = new URL('../../scripts/verify-bff-protocol.mjs', import.meta.url); + let failure: unknown; + try { + execFileSync(process.execPath, [script.pathname, '--without-native-activity'], { + encoding: 'utf8', + timeout: 150_000, + maxBuffer: 65536, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ + status: 1, + signal: null, + stdout: '', + stderr: 'BFF native Next protocol failed: rsc\n', + }); + }, 160_000); + it('the same actual Next proof rejects a nonzero native stop instead of publishing success', () => { + const script = new URL('../../scripts/verify-bff-protocol.mjs', import.meta.url); + let failure: unknown; + try { + execFileSync(process.execPath, [script.pathname, '--without-successful-native-stop'], { + encoding: 'utf8', + timeout: 150_000, + maxBuffer: 65536, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ + status: 1, + signal: null, + stdout: '', + stderr: 'BFF native Next protocol failed: native-stop\n', + }); + }, 160_000); + it('the actual production middleware and localized shells use the native BFF without exposing credentials', () => { + const script = new URL('../../scripts/verify-bff-protocol.mjs', import.meta.url); + expect( + execFileSync(process.execPath, [script.pathname, '--ui'], { + encoding: 'utf8', + timeout: 150_000, + maxBuffer: 65536, + }), + ).toBe('BFF production UI: passed\n'); + }, 160_000); + it('development callback/logout canaries stay out of actual console and persisted framework traces', () => { + const script = new URL('../../scripts/verify-bff-protocol.mjs', import.meta.url); + expect( + execFileSync(process.execPath, [script.pathname, '--dev'], { + encoding: 'utf8', + timeout: 150_000, + maxBuffer: 65536, + }), + ).toBe('BFF development diagnostics: passed\n'); + }, 160_000); + it('the same development canary kills removal of native trace suppression even with logging false', () => { + const script = new URL('../../scripts/verify-bff-protocol.mjs', import.meta.url); + let failure: unknown; + try { + execFileSync(process.execPath, [script.pathname, '--without-native-diagnostics'], { + encoding: 'utf8', + timeout: 150_000, + maxBuffer: 65536, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ + status: 1, + signal: null, + stdout: '', + stderr: 'BFF native Next protocol failed: dev-trace\n', + }); + }, 160_000); + it('auth() is a passive server-only projection lookup and has no credential/provider/idle/cookie writer', () => { + const source = readFileSync(new URL('../server/auth/auth.ts', import.meta.url), 'utf8'); + expect(source).toContain("import 'server-only'"); + expect(source).toContain('read(binding)'); + for (const forbidden of [ + 'session-store', + 'session-owner', + "from './provider", + '.admit(', + '.refresh(', + '.set(', + 'cookies(', + ]) + expect(source).not.toContain(forbidden); + }); +}); diff --git a/frontend/apps/web/src/test/bff-fixture.ts b/frontend/apps/web/src/test/bff-fixture.ts new file mode 100644 index 00000000..a25af1f6 --- /dev/null +++ b/frontend/apps/web/src/test/bff-fixture.ts @@ -0,0 +1,120 @@ +import { randomBytes } from 'node:crypto'; + +import { vi } from 'vitest'; + +import type { BffHttpRequest } from '../server/auth/http-input'; +import { createBffHttpProtocol } from '../server/auth/http-protocol'; +import type { ProviderIdentity } from '../server/auth/provider'; +import type { SessionDependencies } from '../server/auth/session-contract'; +import { InMemoryBffSessionStore } from '../server/auth/session-store'; + +export const bffHost = 'institution.example.test:3000'; +export const bffSource = '203.0.113.71'; +export const bffOpaque = () => randomBytes(32).toString('base64url'); +export function bffFixture(options: { account?: SessionDependencies['account'] } = {}) { + let now = 0; + const identity: ProviderIdentity = { + issuer: 'https://identity.example.test/realms/learnstack', + subject: 'opaque-subject', + actorId: '019a154b-0000-7000-8000-000000000011', + tenantId: '10000000-0000-4000-8000-000000000001', + organizationId: null, + }; + const credentials = () => ({ + accessToken: 'private-access-canary', + idToken: 'private-id-canary', + refreshToken: 'private-refresh-canary', + identity, + accessExpiresAt: now + 900_000, + refreshExpiresAt: now + 1800_000, + }); + const provider = { + begin: vi.fn(async (callbackUri: string) => ({ + transaction: { state: bffOpaque(), nonce: bffOpaque(), verifier: bffOpaque(), callbackUri }, + redirectUrl: 'https://identity.example.test/authorize', + })), + redeem: vi.fn(async () => credentials()), + refresh: vi.fn(async () => credentials()), + logoutUrl: vi.fn(() => 'https://identity.example.test/logout'), + }; + const account = vi.fn(async () => ({ + actorId: identity.actorId, + tenantId: identity.tenantId, + organizationId: null, + displayName: 'Institution', + })); + const store = new InMemoryBffSessionStore({ + provider, + account: options.account ?? account, + clock: { monotonic: () => now, utc: () => 1_800_000_000_000 + now }, + }); + const http = createBffHttpProtocol(store); + async function login() { + const started = await store.start({ host: bffHost, source: bffSource, returnPath: '/studio' }); + const finished = await store.complete({ + host: bffHost, + source: bffSource, + browser: started.browser, + state: started.state, + proof: started.proof, + responseUrl: + 'https://' + bffHost + '/api/auth/callback/keycloak?state=' + started.state + '&code=owned', + }); + const binding = { host: bffHost, browser: finished.browser, handle: finished.handle }; + return { + started, + finished, + binding, + cookie: '__Host-ls-session=' + finished.handle + '; __Host-ls-browser=' + finished.browser, + }; + } + return { + store, + http, + provider, + account, + login, + advance: (ms: number) => { + now += ms; + }, + }; +} +export function bffRequest( + target: string, + options: { + method?: string; + cookie?: string; + form?: string; + headers?: HeadersInit; + signal?: AbortSignal; + host?: string; + source?: string; + } = {}, +): BffHttpRequest { + const headers = new Headers(options.headers); + const host = options.host ?? bffHost; + if (options.cookie) headers.set('cookie', options.cookie); + if (options.form !== undefined) { + if (!headers.has('content-type')) + headers.set('content-type', 'application/x-www-form-urlencoded'); + if (!headers.has('origin')) headers.set('origin', 'https://' + host); + } + const form = options.form; + return { + host, + source: options.source ?? bffSource, + target, + method: options.method ?? 'GET', + headers, + signal: options.signal ?? new AbortController().signal, + body: + form === undefined + ? null + : new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(form)); + controller.close(); + }, + }), + }; +} diff --git a/frontend/apps/web/src/test/bff-proof-support.test.ts b/frontend/apps/web/src/test/bff-proof-support.test.ts new file mode 100644 index 00000000..943bee18 --- /dev/null +++ b/frontend/apps/web/src/test/bff-proof-support.test.ts @@ -0,0 +1,234 @@ +// @vitest-environment node +import { execFileSync } from 'node:child_process'; + +import { describe, expect, it, vi } from 'vitest'; + +import { waitForBrowserDevTools } from '../../scripts/admission-browser.mjs'; +import { + assertBffCookieAttributes, + bffDevToolsFailureStage, + bffProofCopy, + createBffCookieScanner, + rememberBffCookies, + withProofWaiters, +} from '../../scripts/bff-proof-support.mjs'; +import en from '../i18n/messages/en/auth.json'; +import tr from '../i18n/messages/tr/auth.json'; + +describe('BFF proof observation ownership', () => { + it('retains actual branded readiness observations without exporting forged or private error fields', async () => { + vi.useFakeTimers(); + try { + for (const kind of ['missing', 'empty', 'malformed'] as const) { + const waiting = waitForBrowserDevTools({ + assertActive() {}, + isLive: () => true, + readPort: () => ({ kind }), + timeout: 1, + }).catch((error: unknown) => error); + await vi.runAllTimersAsync(); + expect(bffDevToolsFailureStage(await waiting)).toBe('browser-devtools-' + kind); + } + for (const [options, expected] of [ + [ + { + assertActive() { + throw new Error('PRIVATE-CANARY'); + }, + }, + 'browser-devtools-inactive', + ], + [{ isLive: () => false }, 'browser-not-live'], + [ + { + readPort() { + throw new Error('PRIVATE-CANARY'); + }, + }, + 'browser-devtools-unreadable', + ], + ] as const) { + const settings = { + assertActive() {}, + isLive: () => true, + readPort: () => ({ kind: 'missing' as const }), + timeout: 1, + }; + Object.assign(settings, options); + const error = await waitForBrowserDevTools(settings).catch((failure: unknown) => failure); + expect(bffDevToolsFailureStage(error)).toBe(expected); + } + expect( + bffDevToolsFailureStage( + Object.assign(new Error('PRIVATE-CANARY'), { + admissionStage: 'browser-devtools', + }), + ), + ).toBe('browser-devtools'); + expect(bffDevToolsFailureStage(null)).toBe('browser-devtools'); + } finally { + vi.useRealTimers(); + } + }); + it.each(['stdout', 'stderr'])( + 'the actual owned-child %s observer catches cookies learned after the child settles', + (stream) => { + const hostUrl = new URL('../../scripts/bff-proof-host.mjs', import.meta.url).href; + const fixtureUrl = new URL('../../scripts/fixture-support.mjs', import.meta.url).href; + const supportUrl = new URL('../../scripts/bff-proof-support.mjs', import.meta.url).href; + const program = ` + import assert from 'node:assert/strict'; + import { randomBytes } from 'node:crypto'; + import { mkdtempSync } from 'node:fs'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + const { ownBffProofChild } = await import(process.argv[1]); + const { createFixtureOwner, fixtureEnvironment } = await import(process.argv[2]); + const { rememberBffCookies, assertBffPrivateOutput } = await import(process.argv[3]); + const owner = createFixtureOwner(); + const root = owner.ownRoot(mkdtempSync(join(tmpdir(), 'learnstack-bff-observer-'))); + try { + for (const leaking of [false, true]) { + // A valid opaque cookie may begin with a Node option prefix. + const cookie = '-' + randomBytes(32).toString('base64url').slice(1); + const cookieCanaries = []; + const scanners = []; + const child = ownBffProofChild({ owner, app: root, env: fixtureEnvironment({ root }), + args: ['-e', 'process.${stream}.write(process.argv[1]);', '--', leaking ? cookie : 'clean child output'], + privateValues: [], cookieCanaries, scanners }); + assert.deepEqual(await child.completed, { code: 0, signal: null }); + assert.doesNotThrow(() => assertBffPrivateOutput(scanners)); + rememberBffCookies(['__Host-ls-session=' + cookie], cookieCanaries); + if (leaking) assert.throws(() => assertBffPrivateOutput(scanners), /BFF proof private output/); + else assert.doesNotThrow(() => assertBffPrivateOutput(scanners)); + } + } finally { await owner.dispose(); } + `; + expect( + execFileSync( + process.execPath, + ['--input-type=module', '-e', program, hostUrl, fixtureUrl, supportUrl], + { + encoding: 'utf8', + timeout: 5000, + }, + ), + ).toBe(''); + }, + ); + it('checks literal raw cookie attributes independently of browser SameSite defaults', () => { + const value = 'A'.repeat(43); + const header = + '__Host-ls-session=' + value + '; Path=/; Max-Age=28800; HttpOnly; Secure; SameSite=Lax'; + expect(() => assertBffCookieAttributes([header])).not.toThrow(); + expect(() => + assertBffCookieAttributes([header.replace(value, '').replace('28800', '0')]), + ).not.toThrow(); + for (const invalid of [ + header.replace('; SameSite=Lax', ''), + header.replace('SameSite=Lax', 'SameSite=None'), + header.replace('; Secure', ''), + header.replace('; HttpOnly', ''), + header.replace('Path=/', 'Path=/nested'), + header + '; Domain=example.test', + ]) + expect(() => assertBffCookieAttributes([invalid])).toThrow(); + expect(() => assertBffCookieAttributes(['ordinary=public'])).not.toThrow(); + }); + it('uses the chosen committed locale and rejects unknown copy instead of falling back', () => { + for (const [locale, catalogue] of [ + ['en', en], + ['tr', tr], + ['tr-TR', tr], + ] as const) { + expect(bffProofCopy(locale, 'signin.continue')).toBe(catalogue.signin.continue); + expect(bffProofCopy(locale, 'shell.studio')).toBe(catalogue.shell.studio); + expect(bffProofCopy(locale, 'shell.account')).toBe(catalogue.shell.account); + expect(bffProofCopy(locale, 'shell.portal')).toBe(catalogue.shell.portal); + expect(bffProofCopy(locale, 'session.extended')).toBe(catalogue.session.extended); + } + expect(bffProofCopy('en', 'signin.continue')).not.toBe(bffProofCopy('tr', 'signin.continue')); + expect(bffProofCopy('en', 'session.extended')).not.toBe(bffProofCopy('tr', 'session.extended')); + expect(() => bffProofCopy('de', 'signin.continue')).toThrow('closed proof locale'); + expect(() => bffProofCopy('en', 'missing.copy')).toThrow('closed proof copy'); + }); + it('does not leave a late rejected waiter unhandled when the browser action fails first', () => { + const moduleUrl = new URL('../../scripts/bff-proof-support.mjs', import.meta.url).href; + const program = ` + import assert from 'node:assert/strict'; + const { withProofWaiters } = await import(process.argv[1]); + const first = new Error('private first failure'); + let fail; + const pending = new Promise((_, reject) => { fail = reject; }); + assert.equal(await withProofWaiters([pending], () => { throw first; }).catch(e => e), first); + fail(new Error('private late waiter failure')); + await new Promise(resolve => setImmediate(resolve)); + `; + expect( + execFileSync( + process.execPath, + ['--unhandled-rejections=strict', '--input-type=module', '-e', program, moduleUrl], + { + encoding: 'utf8', + timeout: 5000, + }, + ), + ).toBe(''); + }); + + it('preserves an observation failure and returns actual successful waiter results', async () => { + const first = new Error('private observation failure'); + await expect(withProofWaiters([Promise.reject(first)], () => undefined)).rejects.toBe(first); + await expect( + withProofWaiters([Promise.resolve(3), Promise.resolve('seen')], () => undefined), + ).resolves.toEqual([3, 'seen']); + }); + + it('learns session, browser, CSRF and transaction secrets for existing output scanners', () => { + const canaries: string[] = []; + const scanner = createBffCookieScanner(canaries); + const values = ['A', 'B', 'C', 'D', 'E'].map((value) => value.repeat(43)); + rememberBffCookies( + [ + '__Host-ls-session=' + values[0] + '; HttpOnly; Secure', + '__Host-ls-browser=' + values[1] + '; HttpOnly; Secure', + '__Host-ls-csrf=' + values[2] + '; HttpOnly; Secure', + '__Host-ls-login-' + values[3] + '=' + values[4] + '; HttpOnly; Secure', + '__Host-ls-session=; Max-Age=0', + 'ordinary=public', + ], + canaries, + ); + expect(new Set(canaries)).toEqual(new Set(values)); + for (const value of values) expect(scanner.contains(value)).toBe(true); + scanner.push(values[2]!.slice(0, 20)); + scanner.push(values[2]!.slice(20)); + scanner.finish(); + expect(scanner.leaked).toBe(true); + rememberBffCookies(['__Host-ls-session=' + values[0]], canaries); + expect(canaries).toHaveLength(5); + }); + + it('rechecks output emitted before the response disclosed its cookie, beyond the old rolling window', () => { + const canaries: string[] = []; + const scanner = createBffCookieScanner(canaries); + const value = 'C'.repeat(43); + scanner.push(value + '\n'); + scanner.push('x'.repeat(70_000)); + scanner.finish(); + expect(scanner.leaked).toBe(false); + rememberBffCookies(['__Host-ls-session=' + value], canaries); + expect(scanner.leaked).toBe(true); + expect(scanner.tail).toContain(value); + }); + + it('refuses output overflow rather than claiming it scanned discarded bytes', () => { + const scanner = createBffCookieScanner([]); + scanner.push(Buffer.alloc(256 * 1024, 'x')); + expect(scanner.leaked).toBe(false); + scanner.push('x'); + scanner.finish(); + expect(scanner.leaked).toBe(true); + expect(Buffer.byteLength(scanner.tail)).toBe(256 * 1024); + }); +}); diff --git a/frontend/apps/web/src/test/bff-session-routes.test.ts b/frontend/apps/web/src/test/bff-session-routes.test.ts new file mode 100644 index 00000000..e796a4a2 --- /dev/null +++ b/frontend/apps/web/src/test/bff-session-routes.test.ts @@ -0,0 +1,469 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { bffFixture, bffHost, bffOpaque, bffRequest } from './bff-fixture'; +import { BFF_COOKIES, bffCookies } from '../server/auth/cookies'; +import { BffProviderError } from '../server/auth/provider-error'; +import { BffSessionError } from '../server/auth/session-contract'; + +const fixtures: ReturnType[] = []; +const fixture = () => { + const f = bffFixture(); + fixtures.push(f); + return f; +}; +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(fixtures.splice(0).map((f) => f.store.stop())); +}); +const jar = (response: Response) => + response.headers + .getSetCookie() + .map((value) => value.split(';', 1)[0]!) + .join('; '); + +it.each([ + ['source_capacity', 429], + ['subject_capacity', 429], + ['pending_capacity', 503], + ['global_capacity', 503], + ['stopped', 503], + ['deadline', 503], + ['transport', 503], + ['provider_refused', 503], + ['configuration', 503], + ['response', 503], + ['transaction', 400], + ['expired', 400], + ['revoked', 400], + ['cancelled', 400], + ['identity', 400], + ['invalid_grant', 400], +] as const)( + 'distinguishes closed %s owner failures from malformed input (%i)', + async (reason, status) => { + const f = fixture(); + vi.spyOn(f.store, 'start').mockRejectedValueOnce(new BffSessionError(reason)); + const token = bffOpaque(); + const response = await f.http.handle( + bffRequest('/api/auth/signin/keycloak', { + method: 'POST', + cookie: BFF_COOKIES.csrf + '=' + token, + form: 'csrfToken=' + token, + }), + ); + expect(response!.status).toBe(status); + expect(await response!.json()).toEqual({ code: reason }); + expect(response!.headers.getSetCookie()).toEqual([]); + expect(response!.headers.get('retry-after')).toBe(status === 400 ? null : '30'); + }, +); + +it('withholds unexpected server failure details with 503 while malformed form remains 400', async () => { + const f = fixture(); + const start = vi + .spyOn(f.store, 'start') + .mockRejectedValueOnce(new Error('private-server-canary')); + const token = bffOpaque(); + const post = (form: string) => + bffRequest('/api/auth/signin/keycloak', { + method: 'POST', + cookie: BFF_COOKIES.csrf + '=' + token, + form, + }); + const badInput = await f.http.handle(post('csrfToken=' + token + '&unexpected=value')); + expect(badInput!.status).toBe(400); + expect(await badInput!.json()).toEqual({ code: 'invalid_request' }); + expect(start).not.toHaveBeenCalled(); + const unavailable = await f.http.handle(post('csrfToken=' + token)); + expect(unavailable!.status).toBe(503); + expect(await unavailable!.json()).toEqual({ code: 'unavailable' }); + expect(unavailable!.headers.getSetCookie()).toEqual([]); + expect(unavailable!.headers.get('retry-after')).toBe('30'); + expect(start).toHaveBeenCalledTimes(1); +}); +const expectCookies = (response: Response, expected: Record) => { + const cookies = response.headers.getSetCookie(); + expect(cookies.map((cookie) => cookie.split('=', 1)[0]!).sort()).toEqual( + Object.keys(expected).sort(), + ); + for (const cookie of cookies) { + const [pair, ...attributes] = cookie.split('; '); + const equal = pair!.indexOf('='); + const maxAge = expected[pair!.slice(0, equal)]!; + expect(pair!.slice(equal + 1)).toMatch(maxAge === 0 ? /^$/ : /^[\w-]{43}$/); + expect(attributes).toEqual([ + 'Path=/', + 'Max-Age=' + maxAge, + 'HttpOnly', + 'Secure', + 'SameSite=Lax', + ]); + } +}; + +describe('closed BFF routes with the actual owner', () => { + it('public authorization input stays anonymous without clearing a valid session or reaching its API', async () => { + const f = fixture(); + const current = await f.login(); + expect( + await f.http.project( + bffRequest('/en/courses', { + cookie: current.cookie, + headers: { authorization: 'Bearer private-header-canary', 'sec-fetch-dest': 'document' }, + }), + ), + ).toEqual({ session: null, cookies: [], unavailable: false }); + expect(f.account).toHaveBeenCalledTimes(1); + expect(f.store.peek(current.binding)).not.toBeNull(); + }); + it.each([ + ['__Host-ls-csrf=private-malformed-canary', '__Host-ls-csrf'], + ['__Host-ls-session.0=private-malformed-canary', '__Host-ls-session.0'], + ['__Host-ls-login-invalid=private-malformed-canary', '__Host-ls-login-invalid'], + ['__Host-ls-csrf=private-one; __Host-ls-csrf=private-two', '__Host-ls-csrf'], + ])( + 'public malformed cookie recovery clears only the invalid owned name: %s', + async (bad, name) => { + const f = fixture(); + const current = await f.login(); + const projected = await f.http.project( + bffRequest('/en/courses', { + cookie: current.cookie + '; foreign=private-foreign-canary; ' + bad, + headers: { 'sec-fetch-dest': 'document' }, + }), + ); + expect(projected).toEqual({ + session: null, + cookies: [name + '=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax'], + unavailable: false, + }); + expect(JSON.stringify(projected)).not.toContain('private-'); + expect(f.store.peek(current.binding)).not.toBeNull(); + expect(f.account).toHaveBeenCalledTimes(1); + }, + ); + it.each<{ method: string; headers: Record }>([ + { method: 'HEAD', headers: { 'sec-fetch-dest': 'document' } }, + { method: 'GET', headers: { 'sec-fetch-dest': 'document', 'next-router-prefetch': '1' } }, + { method: 'GET', headers: { accept: 'text/html' } }, + ])('passive malformed-cookie input performs no recovery writes: %j', async (options) => { + const f = fixture(); + const current = await f.login(); + const before = f.store.peek(current.binding); + const calls = f.account.mock.calls.length; + expect( + await f.http.project( + bffRequest('/en/courses', { + ...options, + cookie: current.cookie + '; __Host-ls-csrf=private-malformed-canary', + }), + ), + ).toEqual({ session: null, cookies: [], unavailable: false }); + expect(f.store.peek(current.binding)).toEqual(before); + expect(f.account).toHaveBeenCalledTimes(calls); + expect(f.provider.refresh).not.toHaveBeenCalled(); + }); + + it.each(['transport', 'deadline', 'provider_refused', 'response'] as const)( + 'a plain AccountSite %s failure preserves its owner but yields no authenticated projection', + async (reason) => { + const f = fixture(); + const current = await f.login(); + f.account.mockRejectedValueOnce(new BffSessionError(reason)); + const request = bffRequest('/studio', { + cookie: current.cookie, + headers: { 'sec-fetch-dest': 'document' }, + }); + expect(await f.http.project(request)).toEqual({ + session: null, + cookies: [], + unavailable: true, + }); + expect(f.store.peek(current.binding)).not.toBeNull(); + expect((await f.http.project(request)).session?.state).toBe('authenticated'); + expect(f.provider.refresh).not.toHaveBeenCalled(); + }, + ); + it('identity refusal revokes the owner instead of reporting a recoverable account outage', async () => { + const f = fixture(); + const current = await f.login(); + f.account.mockRejectedValueOnce(new BffSessionError('identity')); + expect( + await f.http.project( + bffRequest('/studio', { + cookie: current.cookie, + headers: { 'sec-fetch-dest': 'document' }, + }), + ), + ).toEqual({ session: null, cookies: [], unavailable: false }); + expect(f.store.peek(current.binding)).toBeNull(); + }); + it.each([ + { cookie: '__Host-ls-csrf=private-malformed-canary' }, + { headers: { authorization: 'Bearer private-header-canary' } }, + ])('auth routes retain strict input refusal without cookie mutation: %j', async (options) => { + const f = fixture(); + const response = await f.http.handle(bffRequest('/api/auth/signin', options)); + expect(response!.status).toBe(400); + expect(response!.headers.getSetCookie()).toEqual([]); + expect(f.provider.begin).not.toHaveBeenCalled(); + }); + it('keeps native form Origin usable while provider-directed redirects remain no-referrer', async () => { + const f = fixture(); + const document = await f.http.handle(bffRequest('/api/auth/signin'), () => '
'); + expect(document!.headers.get('referrer-policy')).toBe('same-origin'); + const csrf = await f.http.handle(bffRequest('/api/auth/csrf')); + expect(csrf!.headers.get('referrer-policy')).toBe('no-referrer'); + }); + it('presession CSRF → redirect → bound callback → safe session, then local-first logout and own return', async () => { + const f = fixture(); + const csrf = await f.http.handle(bffRequest('/api/auth/csrf')); + expectCookies(csrf!, { [BFF_COOKIES.csrf]: 300 }); + const token = (await csrf!.json()).csrfToken; + const start = await f.http.handle( + bffRequest('/api/auth/signin/keycloak', { + method: 'POST', + cookie: jar(csrf!), + form: 'csrfToken=' + token + '&returnTo=%2Fstudio', + }), + ); + expect(start!.status).toBe(303); + expect(start!.headers.get('referrer-policy')).toBe('no-referrer'); + expect(f.store.counts().pending).toBe(1); + const cookies = jar(start!); + const entries = bffCookies(cookies); + const name = [...entries.keys()].find((key) => key.startsWith(BFF_COOKIES.login))!; + const state = name.slice(BFF_COOKIES.login.length); + expectCookies(start!, { [BFF_COOKIES.browser]: 28_800, [name]: 300 }); + const callback = await f.http.handle( + bffRequest('/api/auth/callback/keycloak?state=' + state + '&code=owned', { cookie: cookies }), + ); + expect(callback!.headers.get('location')).toBe('/studio'); + expectCookies(callback!, { + [BFF_COOKIES.session]: 28_800, + [BFF_COOKIES.browser]: 28_800, + [name]: 0, + }); + const active = jar(callback!) + .split('; ') + .filter((value) => !value.endsWith('=')) + .join('; '); + const session = await f.http.handle(bffRequest('/api/auth/session', { cookie: active })); + const projection = await session!.json(); + expect(Object.keys(projection).sort()).toEqual([ + 'csrfToken', + 'displayName', + 'expiresAt', + 'limitedByAbsolute', + 'state', + ]); + expect(JSON.stringify(projection)).not.toContain('private-'); + expect(f.provider.refresh).not.toHaveBeenCalled(); + const logout = await f.http.handle( + bffRequest('/api/auth/signout', { + method: 'POST', + cookie: active, + form: 'csrfToken=' + projection.csrfToken, + }), + ); + expect(logout!.status).toBe(303); + expect(logout!.headers.get('referrer-policy')).toBe('no-referrer'); + expect(logout!.headers.get('location')).toBe('https://identity.example.test/logout'); + expect((await f.http.handle(bffRequest('/api/auth/session', { cookie: active })))!.status).toBe( + 200, + ); + expect( + await (await f.http.handle(bffRequest('/api/auth/session', { cookie: active })))!.json(), + ).toEqual({ state: 'anonymous' }); + const proofCookie = logout!.headers + .getSetCookie() + .find((value) => value.startsWith(BFF_COOKIES.logout))! + .split(';', 1)[0]!; + const logoutState = proofCookie.slice(BFF_COOKIES.logout.length).split('=')[0]!; + expectCookies(logout!, { [BFF_COOKIES.session]: 0, [BFF_COOKIES.logout + logoutState]: 300 }); + const browser = entries.get(BFF_COOKIES.browser)!; + const returned = await f.http.handle( + bffRequest('/?state=' + logoutState, { + cookie: BFF_COOKIES.browser + '=' + browser + '; ' + proofCookie, + }), + ); + expect(returned!.headers.get('location')).toBe('/'); + expect(returned!.headers.getSetCookie()).toHaveLength(1); + expect(returned!.headers.getSetCookie()[0]).toContain(BFF_COOKIES.logout + logoutState + '=;'); + expectCookies(returned!, { [BFF_COOKIES.logout + logoutState]: 0 }); + expect(f.store.counts().owners).toBe(0); + }); + it('a valid failed callback clears only its own transaction, leaving an existing primary intact', async () => { + const f = fixture(); + const existing = await f.login(); + const started = await f.store.start({ + ...existing.binding, + source: '203.0.113.1', + returnPath: '/', + }); + f.provider.redeem.mockRejectedValueOnce(new BffProviderError('invalid_grant')); + const response = await f.http.handle( + bffRequest('/api/auth/callback/keycloak?state=' + started.state + '&error=access_denied', { + cookie: existing.cookie + '; ' + BFF_COOKIES.login + started.state + '=' + started.proof, + }), + ); + expect(response!.status).toBe(400); + expect(response!.headers.getSetCookie()).toEqual([ + expect.stringContaining(BFF_COOKIES.login + started.state + '=;'), + ]); + expect(f.store.peek(existing.binding)?.state).toBe('authenticated'); + }); + it('mismatched state/proof touches no cookie and does not consume a valid transaction', async () => { + const f = fixture(); + const start = await f.store.start({ host: bffHost, source: '203.0.113.1', returnPath: '/' }); + for (const [state, proof] of [ + [bffOpaque(), start.proof], + [start.state, bffOpaque()], + ]) { + const response = await f.http.handle( + bffRequest('/api/auth/callback/keycloak?state=' + state + '&code=owned', { + cookie: + BFF_COOKIES.browser + + '=' + + start.browser + + '; ' + + BFF_COOKIES.login + + state + + '=' + + proof, + }), + ); + expect(response!.status).toBe(400); + expect(response!.headers.getSetCookie()).toEqual([]); + expect(f.store.counts().pending).toBe(1); + expect(f.provider.redeem).not.toHaveBeenCalled(); + } + }); + it('an old logout return cannot clear a newer primary or its browser binding', async () => { + const f = fixture(); + const old = await f.login(); + const csrf = old.finished.session.csrfToken; + const logout = await f.http.handle( + bffRequest('/api/auth/signout', { + method: 'POST', + cookie: old.cookie, + form: 'csrfToken=' + csrf, + }), + ); + const nextStart = await f.store.start({ + host: bffHost, + source: '203.0.113.1', + browser: old.binding.browser, + returnPath: '/', + }); + const next = await f.store.complete({ + host: bffHost, + source: '203.0.113.1', + browser: nextStart.browser, + state: nextStart.state, + proof: nextStart.proof, + responseUrl: + 'https://' + + bffHost + + '/api/auth/callback/keycloak?state=' + + nextStart.state + + '&code=owned', + }); + const proof = logout!.headers + .getSetCookie() + .find((value) => value.startsWith(BFF_COOKIES.logout))! + .split(';')[0]!; + const state = proof.slice(BFF_COOKIES.logout.length).split('=')[0]!; + const response = await f.http.handle( + bffRequest('/?state=' + state, { + cookie: + BFF_COOKIES.browser + + '=' + + next.browser + + '; ' + + proof + + '; ' + + BFF_COOKIES.session + + '=' + + next.handle, + }), + ); + expect(response!.headers.getSetCookie()).toHaveLength(1); + expect( + f.store.peek({ host: bffHost, browser: next.browser, handle: next.handle }), + ).not.toBeNull(); + }); + it('inert root state queries remain public and never select a missing logout transaction', async () => { + const f = fixture(); + for (const target of ['/?state=inert', '/?state=' + bffOpaque()]) + expect(await f.http.handle(bffRequest(target))).toBeNull(); + }); + it('extension refreshes fresh access and returns the actual effective deadline', async () => { + const f = fixture(); + const current = await f.login(); + f.advance(60_000); + const response = await f.http.handle( + bffRequest('/api/auth/extend', { + method: 'POST', + cookie: current.cookie, + form: 'csrfToken=' + current.finished.session.csrfToken, + }), + ); + expect(response!.status).toBe(200); + expect(f.provider.refresh).toHaveBeenCalledTimes(1); + expect((await response!.json()).expiresAt).toBe(current.finished.session.expiresAt + 60_000); + }); + it.each(['HEAD', 'GET'])( + '%s passive session polling never refreshes or extends expiry', + async (method) => { + const f = fixture(); + const current = await f.login(); + f.advance(901_000); + const response = await f.http.handle( + bffRequest('/api/auth/session', { method, cookie: current.cookie }), + ); + expect(response!.status).toBe(200); + expect(f.provider.refresh).not.toHaveBeenCalled(); + expect(f.store.peek(current.binding)?.expiresAt).toBe(current.finished.session.expiresAt); + if (method === 'HEAD') expect(await response!.text()).toBe(''); + }, + ); + it('an expired access token refreshes on navigation; prefetch does not', async () => { + const f = fixture(); + const current = await f.login(); + f.advance(901_000); + await f.http.project( + bffRequest('/studio', { + cookie: current.cookie, + headers: { rsc: '1', 'next-router-prefetch': '1' }, + }), + ); + expect(f.provider.refresh).not.toHaveBeenCalled(); + expect( + ( + await f.http.project( + bffRequest('/studio', { cookie: current.cookie, headers: { rsc: '1' } }), + ) + ).session, + ).not.toBeNull(); + expect(f.provider.refresh).toHaveBeenCalledTimes(1); + }); + it('public rendering becomes anonymous after failed refresh without exposing provider data', async () => { + const f = fixture(); + const current = await f.login(); + f.advance(901_000); + f.provider.refresh.mockRejectedValueOnce(new Error('secret-provider-canary')); + expect( + ( + await f.http.project( + bffRequest('/en/courses', { + cookie: current.cookie, + headers: { 'sec-fetch-dest': 'document' }, + }), + ) + ).session, + ).toBeNull(); + expect(f.store.peek(current.binding)).toBeNull(); + }); +}); diff --git a/frontend/apps/web/src/test/demo-genericity-sources.ts b/frontend/apps/web/src/test/demo-genericity-sources.ts index fc45d374..54236c20 100644 --- a/frontend/apps/web/src/test/demo-genericity-sources.ts +++ b/frontend/apps/web/src/test/demo-genericity-sources.ts @@ -232,7 +232,17 @@ export function productionDemoSources( const serverConfig = all.get('frontend/apps/web/tsconfig.server.json'); if (serverConfig) { const config = JSON.parse(serverConfig); - const expected = ['src/server/ingress.ts', 'src/server/public-admission-runtime.ts']; + const expected = [ + 'src/server/auth/account-client.ts', + 'src/server/auth/configuration.ts', + 'src/server/auth/http-protocol.ts', + 'src/server/auth/logout-response.ts', + 'src/server/auth/provider-core.ts', + 'src/server/auth/request-runtime.ts', + 'src/server/auth/session-runtime.ts', + 'src/server/ingress.ts', + 'src/server/public-admission-runtime.ts', + ]; if ( config.compilerOptions?.rootDir !== 'src/server' || config.compilerOptions?.outDir !== '.server' || @@ -241,10 +251,25 @@ export function productionDemoSources( refused('tsconfig.server.json'); for (const input of expected) generated.set( - `frontend/apps/web/.server/${posix.basename(input, '.ts')}.js`, + `frontend/apps/web/.server/${posix.relative('src/server', input).replace(/\.ts$/, '.js')}`, `frontend/apps/web/${input}`, ); } + const sdkConfig = all.get('frontend/packages/sdk/tsconfig.native.json'); + if (sdkConfig) { + const config = JSON.parse(sdkConfig); + if ( + config.compilerOptions?.rootDir !== 'src' || + config.compilerOptions?.outDir !== 'dist' || + JSON.stringify(config.include) !== JSON.stringify(['src/authenticated-server.ts']) + ) + refused('tsconfig.native.json'); + for (const extension of ['js', 'd.ts']) + generated.set( + 'frontend/packages/sdk/dist/authenticated-server.' + extension, + 'frontend/packages/sdk/src/authenticated-server.ts', + ); + } const candidates = (base: string) => [ base, ...['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs', '.json'].flatMap((ext) => [ diff --git a/frontend/apps/web/src/test/demo-genericity.test.ts b/frontend/apps/web/src/test/demo-genericity.test.ts index 8b110364..cb3f1138 100644 --- a/frontend/apps/web/src/test/demo-genericity.test.ts +++ b/frontend/apps/web/src/test/demo-genericity.test.ts @@ -91,6 +91,8 @@ describe('P02d-7 exact demo identity boundary', () => { for (const path of [ 'frontend/apps/web/src/middleware.ts', 'frontend/apps/web/src/server/ingress.ts', + 'frontend/apps/web/src/server/auth/session-runtime.ts', + 'frontend/apps/web/src/server/auth/session-store.ts', 'frontend/apps/web/src/server/configured-public-client.ts', 'frontend/apps/web/src/components/public/lesson.tsx', 'frontend/apps/web/src/i18n/messages/en/public.json', @@ -98,6 +100,7 @@ describe('P02d-7 exact demo identity boundary', () => { 'frontend/apps/web/scripts/public-server.mjs', 'frontend/apps/web/scripts/development-hmr.mjs', 'frontend/apps/web/scripts/stop-test-child.mjs', + 'frontend/apps/web/scripts/verify-bff-protocol.mjs', 'scripts/demo.mjs', 'scripts/require-node-test-cases.mjs', ]) @@ -111,6 +114,18 @@ describe('P02d-7 exact demo identity boundary', () => { const identity = vocabulary.values.find((row) => row.kind === 'tenant-name')!.value; const id = vocabulary.values.find((row) => row.kind === 'id')!.value; + it('scans the native BFF proof entry instead of exempting planted demo literals', () => { + const entry = 'frontend/apps/web/scripts/verify-bff-protocol.mjs'; + for (const value of ['independent', identity]) { + const files = productionDemoSources( + new Map([[entry, `export const name = ${JSON.stringify(value)};`]]), + ); + expect(files.has(entry)).toBe(true); + expect(demoIdentityFindings(entry, files.get(entry)!, vocabulary)).toEqual( + value === identity ? [identity] : [], + ); + } + }); it.each([ 'src/components/public/lesson.tsx', 'src/server/configured-public-client.ts', @@ -504,20 +519,41 @@ describe('P02d-7 exact demo identity boundary', () => { it('admits only the validated native generated-output mapping and known stylesheet', () => { const entry = 'scripts/demo.mjs'; const source = 'frontend/apps/web/src/server/ingress.ts'; + const nested = 'frontend/apps/web/src/server/auth/session-runtime.ts'; + const helper = 'frontend/apps/web/src/server/auth/owned-helper.ts'; const config = { compilerOptions: { rootDir: 'src/server', outDir: '.server' }, - include: ['src/server/ingress.ts', 'src/server/public-admission-runtime.ts'], + include: [ + 'src/server/ingress.ts', + 'src/server/public-admission-runtime.ts', + 'src/server/auth/account-client.ts', + 'src/server/auth/configuration.ts', + 'src/server/auth/http-protocol.ts', + 'src/server/auth/logout-response.ts', + 'src/server/auth/provider-core.ts', + 'src/server/auth/request-runtime.ts', + 'src/server/auth/session-runtime.ts', + ], }; const files = new Map([ [ entry, - "import '../frontend/apps/web/.server/ingress.js'; import '../frontend/apps/web/src/app/globals.css';", + "import '../frontend/apps/web/.server/ingress.js'; import '../frontend/apps/web/.server/auth/session-runtime.js'; import '../frontend/apps/web/src/app/globals.css';", ], [source, 'export const clean = true;'], + [nested, "import './owned-helper.js';"], + [helper, 'export const owned = true;'], ['frontend/apps/web/src/app/globals.css', ':root { color: black }'], ['frontend/apps/web/tsconfig.server.json', JSON.stringify(config)], ]); - expect(productionDemoSources(files).has(source)).toBe(true); + const selected = productionDemoSources(files); + for (const input of [source, nested, helper]) expect(selected.has(input)).toBe(true); + const unknown = new Map(files); + unknown.set( + 'frontend/apps/web/tsconfig.server.json', + JSON.stringify({ ...config, include: [...config.include, 'src/server/unmodeled.ts'] }), + ); + expect(() => productionDemoSources(unknown)).toThrow('Unmodeled'); files.set( 'frontend/apps/web/tsconfig.server.json', JSON.stringify({ diff --git a/frontend/apps/web/src/test/fixture-cleanup.test.ts b/frontend/apps/web/src/test/fixture-cleanup.test.ts index c7e19a96..d13ba98a 100644 --- a/frontend/apps/web/src/test/fixture-cleanup.test.ts +++ b/frontend/apps/web/src/test/fixture-cleanup.test.ts @@ -66,7 +66,9 @@ describe('owned production fixture cleanup', () => { expect(leader.exitCode).toBe(0); expect(absent(pid)).toBe(false); // Positive surviving-descendant control. } - await stopTestChild(leader); + const stopped = await stopTestChild(leader); + expect(stopped).toEqual({ forced: true }); + expect(Object.isFrozen(stopped)).toBe(true); await exit(leader); expect(absent(pid)).toBe(true); expect(absent(-leader.pid!)).toBe(true); @@ -125,7 +127,7 @@ describe('owned production fixture cleanup', () => { try { const first = stopTestChild(child); expect(stopTestChild(child)).toBe(first); - await first; + expect(await first).toEqual({ forced: false }); expect(kill.mock.calls.filter(([, signal]) => signal === 'SIGTERM')).toHaveLength(1); expect(kill.mock.calls.filter(([, signal]) => signal === 'SIGKILL')).toHaveLength(0); } finally { @@ -133,7 +135,9 @@ describe('owned production fixture cleanup', () => { } }); it('accepts a failed spawn with no owned process', async () => { - await expect(stopTestChild({ pid: undefined } as ChildProcess)).resolves.toBeUndefined(); + await expect(stopTestChild({ pid: undefined } as ChildProcess)).resolves.toEqual({ + forced: false, + }); }); it.each([false, true])( 'requires absence rather than treating permission refusal as cleanup, persistent = %s', @@ -152,7 +156,7 @@ describe('owned production fixture cleanup', () => { const stopped = stopTestChild(child); const checked = persistent ? expect(stopped).rejects.toThrow('Owned fixture process group did not stop') - : expect(stopped).resolves.toBeUndefined(); + : expect(stopped).resolves.toEqual({ forced: false }); await vi.advanceTimersByTimeAsync(persistent ? 5020 : 20); await checked; expect(probes).toBeGreaterThan(1); diff --git a/frontend/apps/web/src/test/fixture-support.test.ts b/frontend/apps/web/src/test/fixture-support.test.ts index 956b8927..d5e85ed8 100644 --- a/frontend/apps/web/src/test/fixture-support.test.ts +++ b/frontend/apps/web/src/test/fixture-support.test.ts @@ -25,11 +25,17 @@ afterEach(() => { describe('fixture private-output scanner', () => { const secret = 'private-fixture-credential'; const envelope = 'v1.' + 'A'.repeat(12000) + '.' + 'B'.repeat(43); + const credential = [ + Buffer.from('{"alg":"RS256","typ":"JWT"}').toString('base64url'), + 'B'.repeat(12000), + 'C'.repeat(128), + ].join('.'); it.each([ ['secret', secret], ['carrier', 'X-LeArNsTaCk-Hop-Secret'], ['envelope', envelope], + ['credential', credential], ])('remembers an early %s after bounded output rolls over', (_name, privateValue) => { const scanner = createPrivateScanner([secret]); scanner.push(privateValue); @@ -44,6 +50,7 @@ describe('fixture private-output scanner', () => { ['secret', secret], ['carrier', 'x-learnstack-host'], ['envelope', envelope], + ['credential', credential], ])('detects a %s split at supported boundaries', (_name, privateValue) => { // Envelope controls cover the beginning, large payload and MAC boundary; // short secrets/carriers cover every split. @@ -121,6 +128,9 @@ describe('fixture positive environment', () => { DEBUG: 'next:*', NEXT_PUBLIC_SECRET: 'unsafe', LEARNSTACK_PUBLIC_HOP_SECRET: 'inherited', + LEARNSTACK_BFF_ENABLED: 'true', + LEARNSTACK_BFF_ISSUER: 'https://unowned.example/realms/learnstack', + KEYCLOAK_WEB_CLIENT_SECRET: 'private-inherited', })) vi.stubEnv(key, value); const env = fixtureEnvironment({ root, nodePath: '/explicit/modules' }); @@ -166,6 +176,24 @@ describe('fixture positive environment', () => { fixtureEnvironment({ root, privateValues: { NEXT_PUBLIC_SECRET: 'unsafe' } }), ).toThrow('Unsupported fixture environment'); }); + it('enables BFF only with a complete explicitly supplied proof group', () => { + const bffValues = { + LEARNSTACK_BFF_ENABLED: 'true', + LEARNSTACK_BFF_ISSUER: 'https://identity.example/realms/learnstack', + KEYCLOAK_WEB_CLIENT_SECRET: 'owned-private', + }; + expect(fixtureEnvironment({ root, bffValues })).toMatchObject(bffValues); + for (const key of Object.keys(bffValues)) { + const missing = { ...bffValues }; + Reflect.deleteProperty(missing, key); + expect(() => fixtureEnvironment({ root, bffValues: missing })).toThrow( + 'Incomplete fixture BFF', + ); + } + expect(() => + fixtureEnvironment({ root, bffValues: { ...bffValues, NODE_OPTIONS: 'unsafe' } }), + ).toThrow('Incomplete fixture BFF'); + }); }); describe('fixture resource ownership', () => { @@ -178,8 +206,8 @@ describe('fixture resource ownership', () => { let stopped!: () => void; vi.mocked(stopTestChild).mockImplementation( () => - new Promise((resolve) => { - stopped = resolve; + new Promise>((resolve) => { + stopped = () => resolve({ forced: false }); }), ); const cleanup = owner.dispose(); @@ -203,7 +231,7 @@ describe('fixture resource ownership', () => { owner.ownChild({} as ChildProcess); vi.mocked(stopTestChild) .mockRejectedValueOnce(new Error('planted cleanup refusal')) - .mockResolvedValueOnce(undefined); + .mockResolvedValueOnce({ forced: false }); const closeAllConnections = vi.fn(); const close = vi.fn((done: (error?: NodeJS.ErrnoException) => void) => done()); const server = { closeAllConnections, close } as unknown as Server; @@ -224,7 +252,7 @@ describe('fixture resource ownership', () => { output.on('data', (chunk) => scanner.push(chunk)); output.on('end', () => scanner.finish()); owner.ownChild({ stdout: output } as unknown as ChildProcess); - vi.mocked(stopTestChild).mockResolvedValue(undefined); + vi.mocked(stopTestChild).mockResolvedValue({ forced: false }); const pending = owner.dispose(); await Promise.resolve(); await Promise.resolve(); @@ -256,8 +284,8 @@ describe('fixture resource ownership', () => { let stopped!: () => void; vi.mocked(stopTestChild).mockImplementation( () => - new Promise((resolve) => { - stopped = resolve; + new Promise>((resolve) => { + stopped = () => resolve({ forced: false }); }), ); if (trigger === 'close' || trigger === 'disconnect') control.emit(event); diff --git a/frontend/apps/web/src/test/public-boundaries.test.ts b/frontend/apps/web/src/test/public-boundaries.test.ts index 4067a978..3ef78ffa 100644 --- a/frontend/apps/web/src/test/public-boundaries.test.ts +++ b/frontend/apps/web/src/test/public-boundaries.test.ts @@ -1,4 +1,4 @@ -import { readdirSync, readFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -11,18 +11,24 @@ import { describe, expect, it } from 'vitest'; import { ADAPTER, + ACCOUNT_ADAPTER, + BFF_BROWSER_ADAPTER, + SDK_AUTHENTICATED, ADMISSION_RUNTIME, ADMISSION_FACADE, admissionBoundaryFindings, buildSourceGraph, + browserStorageFindings, cacheFindings, clientBoundaryFindings, + closedHttpSurfaceFindings, hasDirective, hasServerOnlyMarker, I18N_REQUEST, INGRESS, MIDDLEWARE, navigationFindings, + omittedProductionDirectory, privateServerFiles, publicLayoutPolicyFindings, PUBLIC_LAYOUT, @@ -57,8 +63,8 @@ function productionSources(): SourceCensus { for (const entry of readdirSync(directory, { withFileTypes: true })) { const path = join(directory, entry.name); if (entry.isDirectory()) { - // Runtime/test fixtures never enter the production graph. - if (!['test', '__tests__', '__fixtures__', 'fixtures'].includes(entry.name)) visit(path); + const name = relative(frontend, path).split('\\').join('/'); + if (!omittedProductionDirectory(name)) visit(path); } else if ( /\.(?:tsx?|json)$/.test(entry.name) && !/\.(?:test|spec|d)\.(?:tsx?|json)$/.test(entry.name) @@ -71,6 +77,17 @@ function productionSources(): SourceCensus { } }; visit(join(frontend, 'apps/web/src')); + // Next also discovers root routes, middleware and instrumentation hooks. + for (const directory of ['app', 'pages']) { + const path = join(frontend, 'apps/web', directory); + if (existsSync(path)) visit(path); + } + for (const hook of ['middleware', 'instrumentation', 'instrumentation-client']) + for (const extension of ['ts', 'tsx', 'js', 'jsx', 'mts', 'cts', 'mjs', 'cjs']) { + const name = 'apps/web/' + hook + '.' + extension; + const path = join(frontend, name); + if (existsSync(path)) sources[name] = readFileSync(path, 'utf8'); + } for (const entry of readdirSync(join(frontend, 'packages'), { withFileTypes: true })) { if (!entry.isDirectory() || entry.name === 'config') continue; visit(join(frontend, 'packages', entry.name, 'src')); @@ -78,6 +95,10 @@ function productionSources(): SourceCensus { return sources; } const sources = productionSources(); +const httpSources = (): SourceCensus => ({ + ...sources, + 'apps/web/next.config.ts': readFileSync(join(frontend, 'apps/web/next.config.ts'), 'utf8'), +}); 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 @@ -145,6 +166,164 @@ function resolutionCensus() { /** ADR-0053 § Architecture Tests; Standards 07 § SDK / Public Site Renderer. */ describe('ADR-0053 production public boundaries', () => { + it('closes actual matcher, Next routing hooks, framework entry points and Server Actions', () => { + const current = httpSources(); + clean(closedHttpSurfaceFindings(current), 'Fix: retain the approved closed HTTP surface.'); + const config = 'apps/web/next.config.ts'; + for (const [file, source] of [ + [MIDDLEWARE, sources[MIDDLEWARE]!.replace('?!_next/', '?!api/relay/|_next/')], + [ + 'apps/web/src/app/api/relay/route.ts', + 'export function GET() { return new Response("relay"); }', + ], + [ + 'apps/web/app/api/relay/route.ts', + 'export function GET() { return new Response("relay"); }', + ], + ['apps/web/pages/api/relay.ts', 'export default function handler() {}'], + ['apps/web/src/pages/api/relay.ts', 'export default function handler() {}'], + ['apps/web/middleware.ts', sources[MIDDLEWARE]!], + ['apps/web/instrumentation.ts', 'export function register() {}'], + ['apps/web/src/instrumentation.ts', 'export function register() {}'], + ['apps/web/instrumentation-client.ts', 'console.log("browser hook");'], + ['apps/web/src/instrumentation-client.ts', 'console.log("browser hook");'], + ['apps/web/src/app/not-found.tsx', 'export default function NotFound() { return null; }'], + [ + 'apps/web/src/app/global-not-found.tsx', + 'export default function NotFound() { return null; }', + ], + [ + 'apps/web/src/app/(studio)/not-found.tsx', + 'export default function NotFound() { return null; }', + ], + ['apps/web/src/app/extra/page.tsx', 'export default function Page() { return null; }'], + ['apps/web/src/app/sitemap.ts', 'export default function sitemap() { return []; }'], + [ + config, + current[config]!.replace( + 'reactStrictMode: true,', + 'async rewrites() { return []; }, reactStrictMode: true,', + ), + ], + [ + config, + current[config]!.replace('reactStrictMode: true,', '...routing, reactStrictMode: true,'), + ], + [config, current[config]! + '\nnextConfig.rewrites = async () => [];'], + [config, current[config]!.replace("from 'next-intl/plugin'", "from './routing'")], + [ + config, + current[config]!.replace('import createNextIntlPlugin', 'import type createNextIntlPlugin'), + ], + [config, current[config]!.replace('import createNextIntlPlugin', 'import anotherPlugin')], + [ + config, + current[config]!.replace( + 'export default', + 'Object.assign(nextConfig, { rewrites: async () => [] });\nexport default', + ), + ], + [ + config, + current[config]!.replace( + 'export default', + 'const alias = nextConfig; alias.rewrites = async () => [];\nexport default', + ), + ], + [config, current[config]!.replace('(nextConfig);', '({ ...nextConfig, ...routing });')], + [MIDDLEWARE, sources[MIDDLEWARE]! + '\nconfig.matcher = ["/studio"];'], + [MIDDLEWARE, sources[MIDDLEWARE]! + '\nObject.assign(config, { matcher: ["/studio"] });'], + [MIDDLEWARE, sources[MIDDLEWARE]!.replace('export const config', 'const config')], + [probe, '"use server"; export async function write() {}'], + [probe, 'export async function write() { "use server"; return 1; }'], + [ + 'apps/web/src/app/api/healthz/route.ts', + current['apps/web/src/app/api/healthz/route.ts']! + '\nexport function POST() {}', + ], + ]) + expect( + closedHttpSurfaceFindings({ ...current, [file!]: source! }).map((item) => item.file), + ).toContain(file); + // The matcher exemption and relay handler must both be rejected, even together. + const bypass = closedHttpSurfaceFindings({ + ...current, + [MIDDLEWARE]: sources[MIDDLEWARE]!.replace('?!_next/', '?!api/relay/|_next/'), + 'apps/web/src/app/api/relay/route.ts': + 'export function GET() { return new Response("relay"); }', + }); + expect(bypass.map((item) => item.file)).toEqual( + expect.arrayContaining([MIDDLEWARE, 'apps/web/src/app/api/relay/route.ts']), + ); + clean( + closedHttpSurfaceFindings({ + ...current, + [probe]: 'const words = "use server"; export function helper() { return words; }', + }), + 'Ordinary text and helpers do not create actions.', + ); + }); + it('keeps imported helpers in fixture-named production directories visible', () => { + expect(omittedProductionDirectory('apps/web/src/test')).toBe(true); + for (const directory of ['fixtures', '__fixtures__', '__tests__', 'test']) { + const name = 'apps/web/src/lib/' + directory; + expect(omittedProductionDirectory(name)).toBe(false); + const helper = name + '/transport.ts'; + const graph = buildSourceGraph({ + ...sources, + [probe]: `import "@/lib/${directory}/transport";`, + [helper]: 'export const transport = fetch;', + }); + expect(reachable(graph, [probe])).toContain(helper); + expect(transportFindings(graph).map((item) => item.file)).toContain(helper); + } + }); + it('refuses browser storage and cookie access including receiver aliases', () => { + clean(browserStorageFindings(production), 'Fix: retain server-side credential custody.'); + for (const source of [ + 'localStorage.setItem("token", "value");', + 'window.sessionStorage.getItem("token");', + 'const storage = globalThis.localStorage; storage.setItem("token", "value");', + 'document.cookie = "token=value";', + 'const doc = document; export const cookie = doc["cookie"];', + 'indexedDB.open("credentials");', + 'globalThis.indexedDB.deleteDatabase("credentials");', + 'const storage = window.indexedDB; storage.open("credentials");', + 'cookieStore.set("token", "value");', + 'window.cookieStore.get("token");', + 'const { cookieStore: storage } = globalThis; storage.set("token", "value");', + ]) + expect(browserStorageFindings(buildSourceGraph({ [probe]: source }))).not.toEqual([]); + clean( + browserStorageFindings( + buildSourceGraph({ + [probe]: + 'export function read(document: {cookie: string}, localStorage: Map, indexedDB: Map, cookieStore: Map) { return document.cookie + localStorage.get("safe") + indexedDB.get("safe") + cookieStore.get("safe"); }', + }), + ), + 'Local, explicitly injected data is not browser storage.', + ); + }); + it('refuses global fetch passed to another function instead of called directly', () => { + for (const argument of ['fetch', 'globalThis.fetch', 'window["fetch"]']) { + const source = `function dispatch(transport: typeof fetch) { return transport("/path"); } dispatch(${argument});`; + expect(transportFindings(buildSourceGraph({ [probe]: source }))).not.toEqual([]); + } + const alias = 'const transport = globalThis.fetch; dispatch(transport);'; + expect( + transportFindings(buildSourceGraph({ [probe]: alias })).filter((item) => + item.reason.includes('pass global fetch'), + ), + ).toHaveLength(1); + clean( + transportFindings( + buildSourceGraph({ + [probe]: + 'export function install(transport: typeof fetch) { return dispatch(transport); }', + }), + ), + 'Passing an explicitly injected transport is allowed.', + ); + }); it('the production census pins the actual public/private/SDK module graph', () => { expect([...production.files.keys()]).toEqual( expect.arrayContaining([ @@ -224,7 +403,11 @@ describe('ADR-0053 production public boundaries', () => { for (const [key, value] of Object.entries(entry.exports as Record)) { const specifier = entry.name + (key === '.' ? '' : key.slice(1)); expect(production.resolveModule(specifier, MIDDLEWARE)).toBe( - directory + '/' + value.slice(2), + directory + + '/' + + (directory === 'packages/sdk' && key === './authenticated-server' + ? 'src/authenticated-server.ts' + : value.slice(2)), ); } }); @@ -289,7 +472,7 @@ describe('ADR-0053 production public boundaries', () => { ); expect(clientBoundaryFindings(graph).some((item) => item.file === probe)).toBe(true); } - }); + }, 20_000); // Rebuilds the complete TypeScript graph for every planted installer variant. it('Public_Renderer_Uses_Trusted_Ingress_And_Server_Only_Transport', () => { const missingMarker: Finding[] = hasServerOnlyMarker(production.files.get(ADAPTER)!) ? [] @@ -316,7 +499,7 @@ describe('ADR-0053 production public boundaries', () => { 'Fix: retain the dynamic/no-store public layout and request-local public data.', ); }); - it('global fetch and all three hop-header setters have exactly one production adapter', () => { + it('global fetch remains public-only; hop setters stay in the two exact SDK adapters', () => { clean(transportFindings(production), 'Fix: use the configured public adapter.'); // Allowed code must contain actual transport/setters: renaming it removes the exception. const renamed = Object.fromEntries( @@ -326,6 +509,77 @@ describe('ADR-0053 production public boundaries', () => { transportFindings(buildSourceGraph(renamed)).filter((item) => item.file === probe), ).toHaveLength(4); }); + it('confines authenticated hop setters without granting global fetch or a client SDK escape', () => { + const renamed = Object.fromEntries( + Object.entries(sources).map(([name, source]) => [ + name === ACCOUNT_ADAPTER ? probe : name, + source, + ]), + ); + expect( + transportFindings(buildSourceGraph(renamed)).filter((item) => item.file === probe), + ).toHaveLength(3); + expect( + transportFindings( + graphWith( + ACCOUNT_ADAPTER, + sources[ACCOUNT_ADAPTER]! + '\nfetch("https://unowned.invalid");', + ), + ).some((item) => item.file === ACCOUNT_ADAPTER && item.reason.includes('global fetch')), + ).toBe(true); + const client = graphWith( + probe, + '\'use client\'; import { createAuthenticatedServerSdk } from "@learnstack/sdk/authenticated-server"; export const unsafe = createAuthenticatedServerSdk;', + ); + expect(clientBoundaryFindings(client).some((item) => item.file === probe)).toBe(true); + expect(privateServerFiles(production)).toContain(SDK_AUTHENTICATED); + // Native implementation modules must not become bundled middleware/RSC dependencies. + const bundled = reachable(production, renderRoots); + for (const name of [ + 'account-client', + 'configuration', + 'session-runtime', + 'session-store', + 'provider-core', + 'provider-transport', + 'logout-response', + 'request-runtime', + 'http-protocol', + ]) { + expect(bundled).not.toContain('apps/web/src/server/auth/' + name + '.ts'); + const dirty = graphWith( + MIDDLEWARE, + sources[MIDDLEWARE]! + `\nimport "@/server/auth/${name}";`, + ); + expect(reachable(dirty, renderRoots)).toContain('apps/web/src/server/auth/' + name + '.ts'); + } + }); + it('confines browser fetch to the exact same-origin no-store BFF extension action', () => { + const source = sources[BFF_BROWSER_ADAPTER]!; + expect(source).toContain("fetch('/api/auth/extend'"); + for (const [from, to] of [ + ["'/api/auth/extend'", "'/api/v1/account/site'"], + ["credentials: 'same-origin'", "credentials: 'include'"], + ["redirect: 'error'", "redirect: 'follow'"], + ["cache: 'no-store'", "cache: 'force-cache'"], + ['headers: {', "headers: { Authorization: 'Bearer private',"], + ]) { + expect(source).toContain(from); + expect( + transportFindings(graphWith(BFF_BROWSER_ADAPTER, source.replace(from!, to!))).some( + (item) => item.file === BFF_BROWSER_ADAPTER && item.reason.includes('global fetch'), + ), + ).toBe(true); + } + expect( + transportFindings( + graphWith(BFF_BROWSER_ADAPTER, source + '\nfetch("/api/auth/extend");'), + ).some((item) => item.file === BFF_BROWSER_ADAPTER && item.reason.includes('global fetch')), + ).toBe(true); + expect(transportFindings(graphWith(probe, source)).some((item) => item.file === probe)).toBe( + true, + ); + }); it('the configured production caller imports the server-only marker', () => { expect(hasServerOnlyMarker(production.files.get(ADAPTER)!)).toBe(true); }); @@ -526,9 +780,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.27 production SWC emission: %s', + 'pinned Next 15.5.28 production SWC emission: %s', async (_name, declaration, retained) => { - expect((require('next/package.json') as { version: string }).version).toBe('15.5.27'); + expect((require('next/package.json') as { version: string }).version).toBe('15.5.28'); const filename = join(frontend, probe); const config = ts.readConfigFile( join(frontend, 'packages/config/tsconfig/base.json'), diff --git a/frontend/apps/web/src/test/public-boundary-analysis.ts b/frontend/apps/web/src/test/public-boundary-analysis.ts index 3e3ea109..5c4842aa 100644 --- a/frontend/apps/web/src/test/public-boundary-analysis.ts +++ b/frontend/apps/web/src/test/public-boundary-analysis.ts @@ -19,6 +19,8 @@ export type SourceGraph = { readonly resolveModule: (specifier: string, from: string) => string | undefined; }; +export const ACCOUNT_ADAPTER = 'apps/web/src/server/auth/account-client.ts'; +export const BFF_BROWSER_ADAPTER = 'apps/web/src/client/bff-session.ts'; 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'; @@ -27,8 +29,243 @@ 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_AUTHENTICATED = 'packages/sdk/src/authenticated-server.ts'; export const SDK_SERVER = 'packages/sdk/src/server.ts'; +/** Only these test roots are outside the production inventory; names are not authority. */ +export function omittedProductionDirectory(path: string): boolean { + return path === 'apps/web/src/test'; +} + +/** Pin the response entry points independently of middleware's runtime dispatcher. */ +export function closedHttpSurfaceFindings(sources: SourceCensus): Finding[] { + const findings: Finding[] = []; + const entries = new Set([ + 'apps/web/src/app/api/healthz/route.ts', + 'apps/web/src/app/(studio)/studio/page.tsx', + 'apps/web/src/app/(portal)/portal/page.tsx', + 'apps/web/src/app/(public)/[locale]/courses/page.tsx', + 'apps/web/src/app/(public)/[locale]/courses/[slug]/page.tsx', + 'apps/web/src/app/(public)/[locale]/courses/[slug]/lessons/[lessonSlug]/page.tsx', + 'apps/web/src/app/(public)/[locale]/status/not-found/page.tsx', + ]); + const files = new Map( + Object.entries(sources).map(([name, source]) => [ + name, + ts.createSourceFile(name, source, ts.ScriptTarget.Latest, true), + ]), + ); + for (const [name, file] of files) { + if ( + /^apps\/web\/(?:app\/|pages\/|src\/pages\/|middleware\.|(?:src\/)?instrumentation(?:-client)?\.)/.test( + name, + ) + ) + findings.push( + finding( + file, + 'Fix: keep framework routing in the approved src/app and src/middleware owners.', + ), + ); + if ( + name.startsWith('apps/web/src/app/') && + /\/(?:page|route|default|not-found|global-not-found|robots|sitemap|manifest|icon|apple-icon|opengraph-image|twitter-image)\.[^.]+$/.test( + name, + ) && + !entries.delete(name) + ) + findings.push(finding(file, 'Fix: keep the approved HTTP entry-point census closed.')); + walk(file, (node) => { + if ( + (ts.isSourceFile(node) || ts.isBlock(node)) && + node.statements.some( + (statement, index, statements) => + ts.isExpressionStatement(statement) && + ts.isStringLiteral(statement.expression) && + statement.expression.text === 'use server' && + statements + .slice(0, index) + .every( + (prior) => ts.isExpressionStatement(prior) && ts.isStringLiteral(prior.expression), + ), + ) + ) + findings.push( + finding(node, 'Fix: no Server Action is admitted by the closed BFF surface.'), + ); + }); + if (name === 'apps/web/src/app/api/healthz/route.ts') + walk(file, (node) => { + if ( + ts.isIdentifier(node) && + ['HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'CONNECT', 'TRACE'].includes( + node.text, + ) + ) + findings.push(finding(node, 'Fix: the infrastructure route exports only GET.')); + }); + } + for (const file of entries) + findings.push({ file, line: 1, reason: 'Fix: retain the approved HTTP entry point.' }); + const object = (file: ts.SourceFile | undefined, name: string, exported = false) => { + for (const statement of file?.statements ?? []) + if ( + ts.isVariableStatement(statement) && + (!exported || + statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) + ) + for (const declaration of statement.declarationList.declarations) + if (ts.isIdentifier(declaration.name) && declaration.name.text === name) { + const value = declaration.initializer && unwrapped(declaration.initializer); + if (value && ts.isObjectLiteralExpression(value)) return value; + } + return undefined; + }; + const properties = (value: ts.ObjectLiteralExpression | undefined) => + value?.properties.map((property) => + !ts.isSpreadAssignment(property) && + property.name && + (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) + ? property.name.text + : undefined, + ); + const middleware = files.get(MIDDLEWARE); + const config = object(middleware, 'config', true); + const matcher = config?.properties.find( + (property) => ts.isPropertyAssignment(property) && property.name.getText() === 'matcher', + ); + const runtime = config?.properties.find( + (property) => ts.isPropertyAssignment(property) && property.name.getText() === 'runtime', + ); + if ( + JSON.stringify(properties(config)?.sort()) !== JSON.stringify(['matcher', 'runtime']) || + !matcher || + !ts.isPropertyAssignment(matcher) || + !ts.isArrayLiteralExpression(matcher.initializer) || + matcher.initializer.elements.length !== 1 || + !ts.isStringLiteral(matcher.initializer.elements[0]!) || + matcher.initializer.elements[0]!.text !== '/((?!_next/|api/healthz$|favicon\\.ico$).*)' || + !runtime || + !ts.isPropertyAssignment(runtime) || + !ts.isStringLiteral(runtime.initializer) || + runtime.initializer.text !== 'nodejs' + ) + findings.push({ + file: MIDDLEWARE, + line: 1, + reason: 'Fix: retain the exact native middleware matcher and runtime.', + }); + const next = 'apps/web/next.config.ts'; + const nextFile = files.get(next); + const nextConfig = object(nextFile, 'nextConfig'); + const imports = nextFile?.statements.filter(ts.isImportDeclaration) ?? []; + const typeImport = imports.find( + (item) => ts.isStringLiteral(item.moduleSpecifier) && item.moduleSpecifier.text === 'next', + ); + const pluginImport = imports.find( + (item) => + ts.isStringLiteral(item.moduleSpecifier) && item.moduleSpecifier.text === 'next-intl/plugin', + ); + const types = typeImport?.importClause?.namedBindings; + if ( + nextFile?.statements.length !== 4 || + imports.length !== 2 || + !typeImport?.importClause?.isTypeOnly || + typeImport.importClause.name || + !types || + !ts.isNamedImports(types) || + types.elements.length !== 1 || + types.elements[0]!.name.text !== 'NextConfig' || + types.elements[0]!.propertyName || + !pluginImport?.importClause || + pluginImport.importClause.isTypeOnly || + pluginImport.importClause.name?.text !== 'createNextIntlPlugin' || + pluginImport.importClause.namedBindings + ) + findings.push({ + file: next, + line: 1, + reason: 'Fix: keep the fixed Next type/plugin imports and closed configuration statements.', + }); + const allowed = [ + 'reactStrictMode', + 'poweredByHeader', + 'logging', + 'images', + 'transpilePackages', + 'skipMiddlewareUrlNormalize', + 'skipTrailingSlashRedirect', + 'experimental', + 'webpack', + ]; + if (JSON.stringify(properties(nextConfig)?.sort()) !== JSON.stringify(allowed.sort())) + findings.push({ + file: next, + line: 1, + reason: 'Fix: keep Next configuration free of unapproved routing hooks or spreads.', + }); + const exports = nextFile?.statements.filter(ts.isExportAssignment); + const exported = exports?.length === 1 ? exports[0]!.expression : undefined; + if ( + !exported || + !ts.isCallExpression(exported) || + exported.arguments.length !== 1 || + !ts.isIdentifier(exported.arguments[0]!) || + exported.arguments[0]!.text !== 'nextConfig' || + !ts.isCallExpression(exported.expression) || + !ts.isIdentifier(exported.expression.expression) || + exported.expression.expression.text !== 'createNextIntlPlugin' || + exported.expression.arguments.length !== 1 || + !ts.isStringLiteral(exported.expression.arguments[0]!) || + exported.expression.arguments[0]!.text !== './src/i18n/request.ts' + ) + findings.push({ + file: next, + line: 1, + reason: 'Fix: export only the checked Next configuration through its fixed i18n plugin.', + }); + for (const file of [middleware, nextFile]) + if (file) + walk(file, (node) => { + const owner = file === nextFile ? 'nextConfig' : 'config'; + const declaration = (file === nextFile ? nextConfig : config)?.parent; + const declarationName = + declaration && ts.isVariableDeclaration(declaration) ? declaration.name : undefined; + const pluginArgument = + file === nextFile && exported && ts.isCallExpression(exported) + ? exported.arguments[0] + : undefined; + if ( + ts.isIdentifier(node) && + node.text === owner && + node !== declarationName && + node !== pluginArgument + ) + findings.push( + finding(node, 'Fix: keep the checked configuration private until its approved export.'), + ); + const name = ts.isPropertyAccessExpression(node) + ? node.name.text + : ts.isElementAccessExpression(node) && ts.isStringLiteral(node.argumentExpression) + ? node.argumentExpression.text + : undefined; + if ( + (file === nextFile && name === 'rewrites') || + (file === middleware && + name === 'matcher' && + ts.isBinaryExpression(node.parent) && + node.parent.left === node) + ) + findings.push( + finding( + node, + 'Fix: do not replace the checked routing configuration after declaration.', + ), + ); + }); + return findings; +} + /** Native ownership is not a cache exception in the bundled rendering graph. */ export function admissionBoundaryFindings(graph: SourceGraph): Finding[] { const findings: Finding[] = []; @@ -329,7 +566,13 @@ export function resolutionCensusFindings( return ( !subpath || subpath.includes('*') || - (value !== `./src/${subpath}.ts` && value !== `./src/${subpath}.tsx`) + (value !== `./src/${subpath}.ts` && + value !== `./src/${subpath}.tsx` && + !( + directory === 'packages/sdk' && + subpath === 'authenticated-server' && + value === './dist/authenticated-server.js' + )) ); }) || (entry.main !== undefined && entry.main !== (exports as Record)['.']) @@ -549,10 +792,21 @@ export function transportFindings(graph: SourceGraph): Finding[] { for (const [name, file] of graph.files) { if (name === ADAPTER) continue; walk(file, (node) => { - if (ts.isCallExpression(node) && globalFetch(node.expression, graph)) + if ( + ts.isCallExpression(node) && + globalFetch(node.expression, graph) && + !(name === BFF_BROWSER_ADAPTER && fixedBffExtension(node, file)) + ) findings.push( finding(node, 'Fix: call the configured public SDK adapter instead of global fetch.'), ); + if (ts.isCallExpression(node) && node.arguments.some((value) => globalFetch(value, graph))) + findings.push( + finding( + node, + 'Fix: inject transport; do not pass global fetch outside the configured adapter.', + ), + ); if ( (ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isPropertyAssignment(node)) && node.initializer && @@ -588,13 +842,13 @@ export function transportFindings(graph: SourceGraph): Finding[] { for (const pair of pairs.elements) if (ts.isArrayLiteralExpression(pair)) { const header = constantString(pair.elements[0], graph.checker); - if (header && HOP_HEADERS.has(header.toLowerCase())) + if (name !== ACCOUNT_ADAPTER && header && HOP_HEADERS.has(header.toLowerCase())) findings.push( finding(pair, 'Fix: construct API-hop headers only in the configured adapter.'), ); } } - if (key && HOP_HEADERS.has(key.toLowerCase())) + if (name !== ACCOUNT_ADAPTER && key && HOP_HEADERS.has(key.toLowerCase())) findings.push( finding(node, 'Fix: construct API-hop headers only in the configured adapter.'), ); @@ -603,6 +857,78 @@ export function transportFindings(graph: SourceGraph): Finding[] { return findings; } +/** Browser persistence and script-readable cookies cannot become credential stores. */ +export function browserStorageFindings(graph: SourceGraph): Finding[] { + const findings: Finding[] = []; + for (const file of graph.files.values()) + walk(file, (node) => { + if ( + ['localStorage', 'sessionStorage', 'indexedDB', 'cookieStore', 'document.cookie'].some( + (name) => globalBuiltin(node, name, graph.checker), + ) + ) + findings.push( + finding(node, 'Fix: keep browser credentials in opaque HttpOnly host cookies.'), + ); + }); + return findings; +} + +/** One fixed same-origin browser action, not an arbitrary fetch exception. */ +function fixedBffExtension(node: ts.CallExpression, file: ts.SourceFile): boolean { + if (node.arguments.length !== 2) return false; + const [path, options] = node.arguments; + if ( + !path || + !ts.isStringLiteral(path) || + path.text !== '/api/auth/extend' || + !options || + !ts.isObjectLiteralExpression(options) + ) + return false; + const fields = new Map(); + for (const field of options.properties) { + if ( + !ts.isPropertyAssignment(field) || + !ts.isIdentifier(field.name) || + fields.has(field.name.text) + ) + return false; + fields.set(field.name.text, field.initializer); + } + if ( + [...fields.keys()].sort().join(',') !== 'body,cache,credentials,headers,method,redirect,signal' + ) + return false; + for (const [key, expected] of Object.entries({ + method: 'POST', + credentials: 'same-origin', + cache: 'no-store', + redirect: 'error', + })) { + const field = fields.get(key); + if (!field || !ts.isStringLiteral(field) || field.text !== expected) return false; + } + const headers = fields.get('headers'); + if (!headers || !ts.isObjectLiteralExpression(headers) || headers.properties.length !== 1) + return false; + const header = headers.properties[0]; + if ( + !header || + !ts.isPropertyAssignment(header) || + !ts.isStringLiteral(header.name) || + header.name.text !== 'content-type' || + !ts.isStringLiteral(header.initializer) || + header.initializer.text !== 'application/x-www-form-urlencoded;charset=UTF-8' + ) + return false; + const expression = (name: string) => fields.get(name)?.getText(file).replace(/[\s_]/g, ''); + return ( + expression('body') === 'newURLSearchParams({csrfToken}).toString()' && + expression('signal') === 'AbortSignal.timeout(10000)' + ); +} + function processObject( node: ts.Node, checker: ts.TypeChecker, @@ -652,6 +978,7 @@ export function privateServerFiles(graph: SourceGraph): string[] { name.includes('/src/server/') || (name.startsWith('apps/web/src/i18n/messages/') && name.endsWith('.json')) || name === SDK_SERVER || + name === SDK_AUTHENTICATED || hasServerOnlyMarker(file) ) return true; diff --git a/frontend/apps/web/src/test/ui-catalogue-checks.ts b/frontend/apps/web/src/test/ui-catalogue-checks.ts new file mode 100644 index 00000000..c6d572d6 --- /dev/null +++ b/frontend/apps/web/src/test/ui-catalogue-checks.ts @@ -0,0 +1,166 @@ +import { isStructurallySame, parse, TYPE } from '@formatjs/icu-messageformat-parser'; +import type { MessageFormatElement } from '@formatjs/icu-messageformat-parser'; +import ts from 'typescript'; + +export type Catalogue = Record; + +function flatten(catalogue: Catalogue, prefix = ''): Map { + const entries = new Map(); + 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); + } + } +} + +export 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'); + } + } +} + +/** Bounded census of direct calls, simple aliases and literal Promise.all tuples. */ +export function publicCallsiteKeys( + source: string, + filename = 'consumer.tsx', + property = 't', +): string[] { + const file = ts.createSourceFile( + filename, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + const translatorNames = new Set([property]); + const translationFactories = new Set([ + property === 'copy' ? 'createAuthTranslator' : 'getTranslations', + ]); + const collectImports = (node: ts.Node): void => { + if ( + ts.isImportSpecifier(node) && + (node.propertyName?.text ?? node.name.text) === + (property === 'copy' ? 'createAuthTranslator' : 'getTranslations') + ) { + translationFactories.add(node.name.text); + } + 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) === property || + (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) { + 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.isPropertyAccessExpression(initializer.expression) && + initializer.expression.expression.getText(file) === 'Promise' && + initializer.expression.name.text === 'all' && + initializer.arguments[0] && + ts.isArrayLiteralExpression(initializer.arguments[0]) + ) { + 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)) { + for (const element of node.name.elements) { + if ( + (element.propertyName?.getText(file) ?? element.name.getText(file)) === property && + 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.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)) { + if (isTranslator(node.expression)) { + 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; +} diff --git a/frontend/apps/web/tsconfig.server.json b/frontend/apps/web/tsconfig.server.json index 2b1f613b..020bb059 100644 --- a/frontend/apps/web/tsconfig.server.json +++ b/frontend/apps/web/tsconfig.server.json @@ -9,6 +9,16 @@ "incremental": false, "plugins": [] }, - "include": ["src/server/ingress.ts", "src/server/public-admission-runtime.ts"], + "include": [ + "src/server/ingress.ts", + "src/server/public-admission-runtime.ts", + "src/server/auth/session-runtime.ts", + "src/server/auth/provider-core.ts", + "src/server/auth/account-client.ts", + "src/server/auth/request-runtime.ts", + "src/server/auth/http-protocol.ts", + "src/server/auth/configuration.ts", + "src/server/auth/logout-response.ts" + ], "exclude": ["node_modules", ".next"] } diff --git a/frontend/apps/web/vitest.config.ts b/frontend/apps/web/vitest.config.ts index 9f9b18c2..4a481126 100644 --- a/frontend/apps/web/vitest.config.ts +++ b/frontend/apps/web/vitest.config.ts @@ -19,6 +19,10 @@ export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', + // Native Next proofs build owned copies while lint controls initialize real + // TypeScript/ESLint graphs. Bound competing file workers instead of making + // unrelated five-second controls depend on the workstation's CPU count. + maxWorkers: 4, // Off, and the suite depends on it being off. Both test files import // describe/it/expect/afterEach from 'vitest' explicitly, so the // injected form is a knob nothing uses. It is also load-bearing in diff --git a/frontend/package.json b/frontend/package.json index 7aa9fe2c..0986e4ef 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,9 +22,11 @@ }, "pnpm": { "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" + "next@15.5.28>postcss": "8.5.23", + "vitest@4.1.12>vite": "6.4.4", + "vite@6.4.4>esbuild": "0.25.12", + "chromium-bidi@157.0.8090-0>zod": "4.6.6", + "ajv@8.20.0>fast-uri": "3.1.9" } } } diff --git a/frontend/packages/sdk/package.json b/frontend/packages/sdk/package.json index 004e15a3..5a22075a 100644 --- a/frontend/packages/sdk/package.json +++ b/frontend/packages/sdk/package.json @@ -6,20 +6,22 @@ "main": "./src/index.ts", "exports": { ".": "./src/index.ts", - "./server": "./src/server.ts" + "./server": "./src/server.ts", + "./authenticated-server": "./dist/authenticated-server.js" }, "scripts": { "typecheck": "tsc --noEmit", "generate": "openapi-typescript ${LEARNSTACK_OPENAPI:-../../../backend/openapi/v1.json} -o src/generated/schema.d.ts", "test": "vitest run", - "lint": "ESLINT_USE_FLAT_CONFIG=false eslint src --ext .ts --max-warnings 0" + "lint": "ESLINT_USE_FLAT_CONFIG=false eslint src --ext .ts --max-warnings 0", + "build:native": "node scripts/build-native.mjs" }, "devDependencies": { "@learnstack/config": "workspace:*", "openapi-typescript": "7.13.0", "typescript": "5.6.3", - "vite": "6.4.3", - "vitest": "4.1.11", + "vite": "6.4.4", + "vitest": "4.1.12", "eslint": "9.39.4" } } diff --git a/frontend/packages/sdk/scripts/build-native.mjs b/frontend/packages/sdk/scripts/build-native.mjs new file mode 100644 index 00000000..b09e42c9 --- /dev/null +++ b/frontend/packages/sdk/scripts/build-native.mjs @@ -0,0 +1,21 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, copyFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const root = fileURLToPath(new URL('../', import.meta.url)); +execFileSync( + process.execPath, + [require.resolve('typescript/bin/tsc'), '--project', 'tsconfig.native.json'], + { + cwd: root, + stdio: 'inherit', + }, +); +// TypeScript does not copy input declaration files into its output tree. +mkdirSync(new URL('../dist/generated/', import.meta.url), { recursive: true }); +copyFileSync( + new URL('../src/generated/schema.d.ts', import.meta.url), + new URL('../dist/generated/schema.d.ts', import.meta.url), +); diff --git a/frontend/packages/sdk/src/api-result.ts b/frontend/packages/sdk/src/api-result.ts new file mode 100644 index 00000000..563d40f3 --- /dev/null +++ b/frontend/packages/sdk/src/api-result.ts @@ -0,0 +1,22 @@ +import type { ApiProblem, AppError } from './response-policy.js'; + +/** Shared wire outcomes, with no provider exceptions or diagnostic bodies. */ +export type ApiResult = + | { readonly kind: 'success'; readonly status: 200; readonly data: T } + | { + readonly kind: 'api-error'; + readonly status: number; + readonly error: AppError; + readonly problem: ApiProblem; + } + | { + readonly kind: 'invalid-response'; + readonly reason: + | 'malformed-json' + | 'invalid-problem' + | 'invalid-success' + | 'unexpected-status'; + } + | { readonly kind: 'transport-error' } + | { readonly kind: 'invalid-request' } + | { readonly kind: 'cancelled' }; diff --git a/frontend/packages/sdk/src/authenticated-contract.ts b/frontend/packages/sdk/src/authenticated-contract.ts index 703e586c..36a76aa7 100644 --- a/frontend/packages/sdk/src/authenticated-contract.ts +++ b/frontend/packages/sdk/src/authenticated-contract.ts @@ -1,4 +1,4 @@ -import type { components, operations } from './generated/schema'; +import type { components, operations } from './generated/schema.js'; /** P02b-7 owns session-based transport for this separate authenticated contract. */ export const AUTHENTICATED_GET_CONTRACTS = Object.freeze({ diff --git a/frontend/packages/sdk/src/authenticated-server.test.ts b/frontend/packages/sdk/src/authenticated-server.test.ts new file mode 100644 index 00000000..4c2ad7d5 --- /dev/null +++ b/frontend/packages/sdk/src/authenticated-server.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createAuthenticatedServerSdk } from './authenticated-server'; +import type { AuthenticatedFetch } from './authenticated-server'; +import { createServerSdk, PUBLIC_GET_CONTRACTS, PUBLIC_GET_WRAPPERS } from './server'; + +const site = { + displayName: 'School', + enabledLocales: ['en'], + defaultLocale: 'en', + theme: null, + showPlatformAttribution: true, +}; +const account = { + actorId: '019a154b-0000-7000-8000-000000000011', + tenantId: '10000000-0000-4000-8000-000000000001', + organizationId: null, + site, +}; +const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); +const problem = (status: number, code: string) => ({ + type: 'https://errors.learnstack.dev/' + code, + title: 'Refused', + status, + instance: '/api/v1/account/site', + code, + messageKey: 'lockey_' + code, + correlationId: 'request-123', +}); +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +describe('exact authenticated AccountSite SDK', () => { + it('has one typed GET/no-store capability; public wrappers remain anonymous', async () => { + const transport = vi.fn(async () => response(account)); + const sdk = createAuthenticatedServerSdk(transport); + expect(Object.keys(sdk)).toEqual(['getAccountSite']); + expect((await sdk.getAccountSite()).kind).toBe('success'); + expect(transport).toHaveBeenCalledWith('/api/v1/account/site', { + method: 'GET', + cache: 'no-store', + headers: { Accept: 'application/json' }, + signal: undefined, + }); + expect(Object.values(PUBLIC_GET_CONTRACTS)).not.toContain('GetAccountSite'); + expect(PUBLIC_GET_CONTRACTS).not.toHaveProperty('/api/v1/account/site'); + expect(Object.values(PUBLIC_GET_WRAPPERS)).not.toContain('getAccountSite'); + expect(createServerSdk(transport)).not.toHaveProperty('getAccountSite'); + }); + it('accepts the API actor contract without imposing a UUID version', async () => { + const body = { ...account, actorId: 'FA22AEF7-7175-4CF3-8AD3-A300E2204776' }; + expect(await createAuthenticatedServerSdk(async () => response(body)).getAccountSite()).toEqual( + { + kind: 'success', + status: 200, + data: body, + }, + ); + }); + it.each([ + { ...account, actorId: 'not-a-uuid' }, + { ...account, actorId: '00000000-0000-0000-0000-000000000000' }, + { ...account, tenantId: '00000000-0000-0000-0000-000000000000' }, + { ...account, organizationId: 'bad' }, + { ...account, site: {} }, + {}, + ])('refuses malformed identity/projection %j', async (body) => { + expect(await createAuthenticatedServerSdk(async () => response(body)).getAccountSite()).toEqual( + { kind: 'invalid-response', reason: 'invalid-success' }, + ); + }); + it('does not turn a failure into a public success or expose thrown provider details', async () => { + const sdk = createAuthenticatedServerSdk(async () => { + throw new Error('private-canary'); + }); + expect(await sdk.getAccountSite()).toEqual({ kind: 'transport-error' }); + expect( + await createAuthenticatedServerSdk(async () => response(account, 201)).getAccountSite(), + ).toEqual({ kind: 'invalid-response', reason: 'unexpected-status' }); + }); + it('honors cancellation before any transport call', async () => { + const transport = vi.fn(); + const controller = new AbortController(); + controller.abort(); + expect( + await createAuthenticatedServerSdk(transport).getAccountSite({ signal: controller.signal }), + ).toEqual({ kind: 'cancelled' }); + expect(transport).not.toHaveBeenCalled(); + }); + it('passes the exact live caller signal to its sole no-store GET', async () => { + const controller = new AbortController(); + const transport = vi.fn().mockResolvedValue(response(account)); + expect( + await createAuthenticatedServerSdk(transport).getAccountSite({ signal: controller.signal }), + ).toEqual({ kind: 'success', status: 200, data: account }); + expect(controller.signal.aborted).toBe(false); + expect(transport).toHaveBeenCalledExactlyOnceWith('/api/v1/account/site', { + method: 'GET', + cache: 'no-store', + headers: { Accept: 'application/json' }, + signal: controller.signal, + }); + }); + it.each(['resolve', 'reject'] as const)( + 'cancellation wins when transport headers %s after abort, without consuming JSON', + async (outcome) => { + const controller = new AbortController(); + const held = deferred(); + const received = response(account); + const json = vi.spyOn(received, 'json'); + const transport = vi.fn().mockReturnValue(held.promise); + const pending = createAuthenticatedServerSdk(transport).getAccountSite({ + signal: controller.signal, + }); + expect(transport).toHaveBeenCalledTimes(1); + controller.abort(); + if (outcome === 'resolve') held.resolve(received); + else held.reject(new Error('private-header-transport-canary')); + expect(await pending).toEqual({ kind: 'cancelled' }); + expect(json).not.toHaveBeenCalled(); + }, + ); + it.each(['resolve', 'malformed JSON', 'body transport failure'] as const)( + 'cancellation wins when JSON consumption finishes with %s after abort', + async (outcome) => { + const controller = new AbortController(); + const held = deferred(); + const reached = deferred(); + const received = response(account); + const json = vi.spyOn(received, 'json').mockImplementation(() => { + reached.resolve(); + return held.promise; + }); + const pending = createAuthenticatedServerSdk(async () => received).getAccountSite({ + signal: controller.signal, + }); + await reached.promise; + controller.abort(); + if (outcome === 'resolve') held.resolve(account); + else + held.reject( + outcome === 'malformed JSON' + ? new SyntaxError('private-body-syntax-canary') + : new Error('private-body-transport-canary'), + ); + expect(await pending).toEqual({ kind: 'cancelled' }); + expect(json).toHaveBeenCalledTimes(1); + }, + ); + it.each(['malformed JSON', 'body transport failure'] as const)( + 'distinguishes %s without caller cancellation or raw error exposure', + async (outcome) => { + const received = + outcome === 'malformed JSON' + ? new Response('{') + : new Response( + new ReadableStream({ + start(controller) { + controller.error(new Error('private-body-canary')); + }, + }), + ); + expect(await createAuthenticatedServerSdk(async () => received).getAccountSite()).toEqual( + outcome === 'malformed JSON' + ? { kind: 'invalid-response', reason: 'malformed-json' } + : { kind: 'transport-error' }, + ); + }, + ); + it.each([ + [ + 400, + 'validation_failed', + { code: 'validation_failed', fieldErrors: { locale: ['lockey_required'] } }, + ], + [401, 'unauthorized', { code: 'unauthorized' }], + [403, 'forbidden', { code: 'forbidden' }], + [404, 'not_found', { code: 'not_found' }], + [429, 'rate_limited', { code: 'rate_limited', retryAfter: 17 }], + [503, 'dependency_unavailable', { code: 'dependency_unavailable', retryAfter: 17 }], + ] as const)( + 'delegates a valid %s/%s problem and retry header to the shared response policy', + async (status, code, error) => { + const payload = { + ...problem(status, code), + ...(status === 400 ? { errors: { locale: [{ key: 'lockey_required' }] } } : {}), + }; + const received = new Response( + JSON.stringify({ ...payload, secret: 'private-problem-canary' }), + { + status, + headers: { 'Retry-After': '17' }, + }, + ); + expect(await createAuthenticatedServerSdk(async () => received).getAccountSite()).toEqual({ + kind: 'api-error', + status, + problem: payload, + error, + }); + }, + ); + it('does not turn an unsafe retry header into an authenticated retry hint', async () => { + const received = new Response(JSON.stringify(problem(429, 'rate_limited')), { + status: 429, + headers: { 'Retry-After': '9999999999999999999' }, + }); + expect(await createAuthenticatedServerSdk(async () => received).getAccountSite()).toEqual({ + kind: 'api-error', + status: 429, + problem: problem(429, 'rate_limited'), + error: { code: 'rate_limited' }, + }); + }); + it.each([{}, { ...problem(401, 'unauthorized'), status: 403 }])( + 'refuses an invalid body or mismatched problem status: %j', + async (body) => { + expect( + await createAuthenticatedServerSdk(async () => response(body, 401)).getAccountSite(), + ).toEqual({ kind: 'invalid-response', reason: 'invalid-problem' }); + }, + ); + it.each([201, 204, 302, 304])( + 'rejects unexpected status %s before reading JSON', + async (status) => { + const received = new Response(null, { status }); + const json = vi.spyOn(received, 'json'); + expect(await createAuthenticatedServerSdk(async () => received).getAccountSite()).toEqual({ + kind: 'invalid-response', + reason: 'unexpected-status', + }); + expect(json).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/frontend/packages/sdk/src/authenticated-server.ts b/frontend/packages/sdk/src/authenticated-server.ts new file mode 100644 index 00000000..0429d2d4 --- /dev/null +++ b/frontend/packages/sdk/src/authenticated-server.ts @@ -0,0 +1,77 @@ +import type { ApiResult } from './api-result.js'; +import type { AccountSite } from './authenticated-contract.js'; +import { appError, isSuccess, parseProblem } from './response-policy.js'; + +export type AuthenticatedFetch = (path: string, init: RequestInit) => Promise; +export type AuthenticatedServerSdk = { + getAccountSite(options?: Readonly<{ signal?: AbortSignal }>): Promise>; +}; +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +function identifier(value: unknown) { + return ( + typeof value === 'string' && + uuid.test(value) && + value !== '00000000-0000-0000-0000-000000000000' + ); +} +export function isAccountSite(value: unknown): value is AccountSite { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const object = value as Record; + return ( + identifier(object.actorId) && + identifier(object.tenantId) && + (object.organizationId === null || identifier(object.organizationId)) && + isSuccess('GetPublicSite', object.site) + ); +} + +/** One exact authenticated GET. Credentials and trusted hop options belong to its injected caller. */ +export function createAuthenticatedServerSdk( + transport: AuthenticatedFetch, +): AuthenticatedServerSdk { + return Object.freeze({ + async getAccountSite( + options?: Readonly<{ signal?: AbortSignal }>, + ): Promise> { + const signal = options?.signal; + if (signal?.aborted) return { kind: 'cancelled' }; + let response: Response; + try { + response = await transport('/api/v1/account/site', { + method: 'GET', + cache: 'no-store', + headers: { Accept: 'application/json' }, + signal, + }); + } catch { + return { kind: signal?.aborted ? 'cancelled' : 'transport-error' }; + } + if (signal?.aborted) return { kind: 'cancelled' }; + if (response.status !== 200 && response.status < 400) + return { kind: 'invalid-response', reason: 'unexpected-status' }; + let body: unknown; + try { + body = await response.json(); + } catch (error) { + if (signal?.aborted) return { kind: 'cancelled' }; + return error instanceof SyntaxError + ? { kind: 'invalid-response', reason: 'malformed-json' } + : { kind: 'transport-error' }; + } + if (signal?.aborted) return { kind: 'cancelled' }; + if (response.status === 200) + return isAccountSite(body) + ? { kind: 'success', status: 200, data: body } + : { kind: 'invalid-response', reason: 'invalid-success' }; + const problem = parseProblem(body, response.status); + return problem + ? { + kind: 'api-error', + status: response.status, + problem, + error: appError(problem, response.headers.get('Retry-After')), + } + : { kind: 'invalid-response', reason: 'invalid-problem' }; + }, + }); +} diff --git a/frontend/packages/sdk/src/response-policy.ts b/frontend/packages/sdk/src/response-policy.ts index 6beb4fbd..fecf048c 100644 --- a/frontend/packages/sdk/src/response-policy.ts +++ b/frontend/packages/sdk/src/response-policy.ts @@ -1,4 +1,4 @@ -import type { components, operations } from './generated/schema'; +import type { components, operations } from './generated/schema.js'; export type PublicGetOperation = | 'GetPublicSite' diff --git a/frontend/packages/sdk/src/server.ts b/frontend/packages/sdk/src/server.ts index fd95356e..edb697a5 100644 --- a/frontend/packages/sdk/src/server.ts +++ b/frontend/packages/sdk/src/server.ts @@ -1,7 +1,8 @@ +import type { ApiResult } from './api-result'; import type { AUTHENTICATED_GET_CONTRACTS, GetContractOwnership } from './authenticated-contract'; import type { operations, paths } from './generated/schema'; import { appError, isSuccess, parseProblem } from './response-policy'; -import type { ApiProblem, AppError, PublicGetOperation, PublicSuccess } from './response-policy'; +import type { PublicGetOperation, PublicSuccess } from './response-policy'; export type { ApiProblem, AppError } from './response-policy'; @@ -17,26 +18,7 @@ export class PublicRequestRefusalError extends Error { } } -export type ApiResult = - | { readonly kind: 'success'; readonly status: 200; readonly data: T } - | { - readonly kind: 'api-error'; - readonly status: number; - readonly error: AppError; - readonly problem: ApiProblem; - } - | { - readonly kind: 'invalid-response'; - readonly reason: - | 'malformed-json' - | 'invalid-problem' - | 'invalid-success' - | 'unexpected-status'; - } - | { readonly kind: 'transport-error' } - /** Local SDK/transport construction or validation refusal; not an HTTP 400. */ - | { readonly kind: 'invalid-request' } - | { readonly kind: 'cancelled' }; +export type { ApiResult } from './api-result'; type Query = operations[T]['parameters']['query']; type Path = operations[T]['parameters']['path']; diff --git a/frontend/packages/sdk/tsconfig.native.json b/frontend/packages/sdk/tsconfig.native.json new file mode 100644 index 00000000..9a5d256e --- /dev/null +++ b/frontend/packages/sdk/tsconfig.native.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "noEmit": false, + "declaration": true, + "incremental": false + }, + "include": ["src/authenticated-server.ts"] +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 68c277c1..db8b6827 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -5,9 +5,11 @@ settings: excludeLinksFromLockfile: false 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 + next@15.5.28>postcss: 8.5.23 + vitest@4.1.12>vite: 6.4.4 + vite@6.4.4>esbuild: 0.25.12 + chromium-bidi@157.0.8090-0>zod: 4.6.6 + ajv@8.20.0>fast-uri: 3.1.9 importers: @@ -35,11 +37,14 @@ importers: specifier: workspace:* version: link:../../packages/ui next: - specifier: 15.5.27 - version: 15.5.27(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + specifier: 15.5.28 + version: 15.5.28(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) next-intl: specifier: 4.14.9 - version: 4.14.9(@types/react@19.2.14)(next@15.5.27(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) + version: 4.14.9(@types/react@19.2.14)(next@15.5.28(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) + openid-client: + specifier: 6.8.8 + version: 6.8.8 react: specifier: 19.0.0 version: 19.0.0 @@ -73,7 +78,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.7.0 - version: 4.7.0(vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7)) + version: 4.7.0(vite@6.4.4(@types/node@22.19.19)(jiti@1.21.7)) autoprefixer: specifier: ^10.4.20 version: 10.5.0(postcss@8.5.23) @@ -81,8 +86,11 @@ importers: specifier: ^9.16.0 version: 9.39.4(jiti@1.21.7) eslint-config-next: - specifier: 15.5.27 - version: 15.5.27(eslint@9.39.4(jiti@1.21.7))(typescript@5.6.3) + specifier: 15.5.28 + version: 15.5.28(eslint@9.39.4(jiti@1.21.7))(typescript@5.6.3) + jose: + specifier: 6.2.13 + version: 6.2.13 jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -96,11 +104,11 @@ importers: 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) + specifier: 6.4.4 + version: 6.4.4(@types/node@22.19.19)(jiti@1.21.7) vitest: - specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(jsdom@26.1.0)(vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7)) + specifier: 4.1.12 + version: 4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(jsdom@26.1.0)(vite@6.4.4(@types/node@22.19.19)(jiti@1.21.7)) packages/config: dependencies: @@ -141,11 +149,11 @@ importers: 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) + specifier: 6.4.4 + version: 6.4.4(@types/node@22.19.19)(jiti@1.21.7) vitest: - specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(jsdom@26.1.0)(vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7)) + specifier: 4.1.12 + version: 4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(jsdom@26.1.0)(vite@6.4.4(@types/node@22.19.19)(jiti@1.21.7)) packages/ui: dependencies: @@ -735,56 +743,56 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@next/env@15.5.27': - resolution: {integrity: sha512-WC5hvVqiuFOcRyz6gKPeJkiVvSMf3scNeAV49CdWtVqxmUlzh8UKR6YHt9yiNkh8LngtzqSlVz07/Ma3ZAzchA==} + '@next/env@15.5.28': + resolution: {integrity: sha512-xxW5EBI8fMPuVTPBW7LL980IRbuDXlbkUAwp9DDhtb4Lnich3nJ13uYpvrX4VE74hWPf6AvHHAMmGCidcHjbfg==} - '@next/eslint-plugin-next@15.5.27': - resolution: {integrity: sha512-a94xlzvOLaULh4Fzcvj9B2hwIOtPHJW75gghkXiJ8s1Jk0eo00UknrDVhx04Y+8ARu9ds13vy/jHg5u/PdVGqw==} + '@next/eslint-plugin-next@15.5.28': + resolution: {integrity: sha512-EsR/G+skBFfu8Z35P6V/yuu4IV8zkZXIssGOgIRD0ZXqB4CTEuEHQC1ySOQi31u4xjmgHVAdG1RT3QAasRmcZA==} - '@next/swc-darwin-arm64@15.5.27': - resolution: {integrity: sha512-fBA9QurmK4+uj4wNhmRTuO5zMVRaBHCvp6H93zTG/5i6Yi6TewbvbesXB8YQNj8T99Cwrow87hHiFV6wL/hyMA==} + '@next/swc-darwin-arm64@15.5.28': + resolution: {integrity: sha512-dPoQYXcfSIqx5o+TOXVXO7kONK5qbtNRDeiTfII0bT3puGIT6VF5e0QwmxNZfEZ5Zety5f3m9P/p4xqwfiLlPg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.27': - resolution: {integrity: sha512-FGZwJwry8qZNHxD/nfwrCwCHHrqx6epqItkkxbYcBQEQ2uw5S43bY83fQB5zbAsZE7TELMjoYkbhsQ4oKM7wwg==} + '@next/swc-darwin-x64@15.5.28': + resolution: {integrity: sha512-RgCJ7buBivTH+6sddqaVm+/v8kwMHlFkobvdt3DfETutws5HrxnToMIWSN9X3MZ3aAi7BbKrGZZWUDQLEycXqw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.27': - resolution: {integrity: sha512-GQulAaJYNF1RYuatlsw9/uzbKz4if2V7Ic9nVO+P0ljYPm4yBRTFIl6g5YkKTw40lMLbpy9edLoH44g3jSHMYA==} + '@next/swc-linux-arm64-gnu@15.5.28': + resolution: {integrity: sha512-OHXQe13olE59aKe++zHavGcJPh5SLHXi6Anret3BBeq1+qhSuOJDNAli/EDDfxs6UTqA4Qddo4uyYry2slyjiQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.5.27': - resolution: {integrity: sha512-URFFsUmu8Pi0/1qdnUI7tx7keZw1Mt5zHfrIX1RfDG9Nih8DYxK54WZsDJUX2ZTlii1jgotL7qcbwKnSAES6xQ==} + '@next/swc-linux-arm64-musl@15.5.28': + resolution: {integrity: sha512-nTB7bB6vfn4ezCJNYP43ukt2o8L3+WxKF/sTcTxepdXsJh3SM9RhlU7wrZYbH7L4HO5M87dqDTwm8587vAkCiw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.5.27': - resolution: {integrity: sha512-aJp74z7uvU5jQhyH7lyk+4qSRcTdKbKT9NQE3jiO9kWYkoHZjKi0zzHDbCoZ+v2sJDEiJ/z58R0UlWMGWNF3HA==} + '@next/swc-linux-x64-gnu@15.5.28': + resolution: {integrity: sha512-WXCfVI32rW3xCusoUX1RpZKtyChWU2Zwe27JUDmM3KrXwcDNc9vV4zEq0GKFAURnuQ/Ouo/XqPvB0yh445XZCg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.5.27': - resolution: {integrity: sha512-ILl2bQ8MbpvyMpY8AXljb3BXs0MJSC8HXZ6QV5AWpVS7ZELqNPtrHvhUk+SNarvjvICXX8bCS287TCi+gUFVYg==} + '@next/swc-linux-x64-musl@15.5.28': + resolution: {integrity: sha512-Miq+NDb/HWsXuxseYxMM6fuEOQXgTziarkKIRpVKCn8W69cEfhQaQr+90pO+u4q2CbJR2wzXmUuCFyZaJs/Nng==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.5.27': - resolution: {integrity: sha512-1MB9Rh5y8TOPN2x7naNTJNkCZQav9c70AI7EBYXxsEewwOffC4DjjwBwKf20NBvcA+u+5jDv7nVmHxunlP2d7Q==} + '@next/swc-win32-arm64-msvc@15.5.28': + resolution: {integrity: sha512-5LR79lemgDlgxvG7MQJ+zHCkVcrUoQuO7CiIvq3I58vNW4goSOE2b+19Z6FujRWMouvni4gvudYFuFuNxolGWA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.27': - resolution: {integrity: sha512-XtYgpGOswgnyBWXzOEG6q9RhvnSw0CEZ0MbWqCsl5PISgt9EzvJvNW5mU5n1PsphX/lnamzxjgQXH2GUJL8nqg==} + '@next/swc-win32-x64-msvc@15.5.28': + resolution: {integrity: sha512-GNipvXUYROSTf4PXEyH95FrWImIB5zdaK6uEOg0RP1/X2drnvoMwiBkJ0VWdzX7w3iiBm3y5g2TxkKDGVTOQcg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1459,11 +1467,11 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitest/expect@4.1.11': - resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + '@vitest/expect@4.1.12': + resolution: {integrity: sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==} - '@vitest/mocker@4.1.11': - resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + '@vitest/mocker@4.1.12': + resolution: {integrity: sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1473,20 +1481,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.11': - resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + '@vitest/pretty-format@4.1.12': + resolution: {integrity: sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==} - '@vitest/runner@4.1.11': - resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + '@vitest/runner@4.1.12': + resolution: {integrity: sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==} - '@vitest/snapshot@4.1.11': - resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + '@vitest/snapshot@4.1.12': + resolution: {integrity: sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==} - '@vitest/spy@4.1.11': - resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + '@vitest/spy@4.1.12': + resolution: {integrity: sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==} - '@vitest/utils@4.1.11': - resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@vitest/utils@4.1.12': + resolution: {integrity: sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -1617,10 +1625,6 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axe-core@4.11.4: - resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} - engines: {node: '>=4'} - axe-core@4.14.0: resolution: {integrity: sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==} engines: {node: '>=4'} @@ -1943,8 +1947,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-next@15.5.27: - resolution: {integrity: sha512-SlpZ5Ch1bM+GNHxahIoCy5OVgbpIHzHoGQDjHo6vEhxvYMukWYVo++AEh9RWYaIxH1i38PO7ArzSdmTruswOew==} + eslint-config-next@15.5.28: + resolution: {integrity: sha512-FQkDceHNJVfMa2rKbBQKyVILq7nG/zd2TxKsjecJAIv/dOcqaZynK5shwQCt6qrZFFae/EukOFggSP0jtNSLAA==} peerDependencies: eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 typescript: '>=3.3.1' @@ -2094,8 +2098,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.8: - resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -2432,6 +2436,9 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jose@6.2.13: + resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==} + jpeg-js@0.4.4: resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} @@ -2499,8 +2506,8 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} - legacy-javascript@0.0.3: - resolution: {integrity: sha512-1r6MT4dVaJyXt4G07Cm2cQ0AUaLJdefiEp4SmIAylA++gnEQ82YvmzE58RkM5EP0fk5hROLCslUPGnwIYdZQVA==} + legacy-javascript@0.0.4: + resolution: {integrity: sha512-UTmeKBVxnfJcpmpOvzzNnLsQT99BXHpPlc/ZKddIRmWmrR0fw6335I3lq4+sJoP60UTbBNaYsGYVPc65YB1x3A==} levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} @@ -2633,8 +2640,8 @@ packages: '@types/react': optional: true - next@15.5.27: - resolution: {integrity: sha512-F82CrlPZ8GRxBH9RIIoR3qU1t5RT3dv1K8moe8SjlDEquiFipvEI13R1Om90TaUOEwShkWCMlg8+gvTNzfMhvg==} + next@15.5.28: + resolution: {integrity: sha512-JjhLBBY1PNnuwPtcZ7t8yxVeJVYvD2Jyp633J+gRCNbzSt7N0E+xqCf6uQiff3+3qmUkwcF/ksfSsK1xsZ76Tw==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -2672,6 +2679,9 @@ packages: nwsapi@2.2.24: resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + oauth4webapi@3.8.8: + resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2722,6 +2732,9 @@ packages: peerDependencies: typescript: ^5.x + openid-client@6.8.8: + resolution: {integrity: sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3301,8 +3314,8 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - vite@6.4.3: - resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + vite@6.4.4: + resolution: {integrity: sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: @@ -3341,23 +3354,23 @@ packages: yaml: optional: true - vitest@4.1.11: - resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + vitest@4.1.12: + resolution: {integrity: sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@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 + '@vitest/browser-playwright': 4.1.12 + '@vitest/browser-preview': 4.1.12 + '@vitest/browser-webdriverio': ^4.1.11 + '@vitest/coverage-istanbul': 4.1.12 + '@vitest/coverage-v8': 4.1.12 + '@vitest/ui': 4.1.12 happy-dom: '*' jsdom: '*' - vite: 6.4.3 + vite: 6.4.4 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -3515,8 +3528,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@4.6.5: - resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + zod@4.6.6: + resolution: {integrity: sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==} snapshots: @@ -4015,34 +4028,34 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true - '@next/env@15.5.27': {} + '@next/env@15.5.28': {} - '@next/eslint-plugin-next@15.5.27': + '@next/eslint-plugin-next@15.5.28': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@15.5.27': + '@next/swc-darwin-arm64@15.5.28': optional: true - '@next/swc-darwin-x64@15.5.27': + '@next/swc-darwin-x64@15.5.28': optional: true - '@next/swc-linux-arm64-gnu@15.5.27': + '@next/swc-linux-arm64-gnu@15.5.28': optional: true - '@next/swc-linux-arm64-musl@15.5.27': + '@next/swc-linux-arm64-musl@15.5.28': optional: true - '@next/swc-linux-x64-gnu@15.5.27': + '@next/swc-linux-x64-gnu@15.5.28': optional: true - '@next/swc-linux-x64-musl@15.5.27': + '@next/swc-linux-x64-musl@15.5.28': optional: true - '@next/swc-win32-arm64-msvc@15.5.27': + '@next/swc-win32-arm64-msvc@15.5.28': optional: true - '@next/swc-win32-x64-msvc@15.5.27': + '@next/swc-win32-x64-msvc@15.5.28': optional: true '@nodelib/fs.scandir@2.1.5': @@ -4160,7 +4173,7 @@ snapshots: '@paulirish/trace_engine@0.0.65': dependencies: - legacy-javascript: 0.0.3 + legacy-javascript: 0.0.4 third-party-web: 0.30.0 '@puppeteer/browsers@3.2.4': @@ -4627,7 +4640,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.1': optional: true - '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7))': + '@vitejs/plugin-react@4.7.0(vite@6.4.4(@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) @@ -4635,48 +4648,48 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.3(@types/node@22.19.19)(jiti@1.21.7) + vite: 6.4.4(@types/node@22.19.19)(jiti@1.21.7) transitivePeerDependencies: - supports-color - '@vitest/expect@4.1.11': + '@vitest/expect@4.1.12': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 + '@vitest/spy': 4.1.12 + '@vitest/utils': 4.1.12 chai: 6.3.0 tinyrainbow: 3.2.0 - '@vitest/mocker@4.1.11(vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7))': + '@vitest/mocker@4.1.12(vite@6.4.4(@types/node@22.19.19)(jiti@1.21.7))': dependencies: - '@vitest/spy': 4.1.11 + '@vitest/spy': 4.1.12 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.3(@types/node@22.19.19)(jiti@1.21.7) + vite: 6.4.4(@types/node@22.19.19)(jiti@1.21.7) - '@vitest/pretty-format@4.1.11': + '@vitest/pretty-format@4.1.12': dependencies: tinyrainbow: 3.2.0 - '@vitest/runner@4.1.11': + '@vitest/runner@4.1.12': dependencies: - '@vitest/utils': 4.1.11 + '@vitest/utils': 4.1.12 pathe: 2.0.3 - '@vitest/snapshot@4.1.11': + '@vitest/snapshot@4.1.12': dependencies: - '@vitest/pretty-format': 4.1.11 - '@vitest/utils': 4.1.11 + '@vitest/pretty-format': 4.1.12 + '@vitest/utils': 4.1.12 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.11': {} + '@vitest/spy@4.1.12': {} - '@vitest/utils@4.1.11': + '@vitest/utils@4.1.12': dependencies: - '@vitest/pretty-format': 4.1.11 + '@vitest/pretty-format': 4.1.12 convert-source-map: 2.0.0 tinyrainbow: 3.2.0 @@ -4702,7 +4715,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.8 + fast-uri: 3.1.9 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -4828,8 +4841,6 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axe-core@4.11.4: {} - axe-core@4.14.0: {} axobject-query@4.1.0: {} @@ -4926,7 +4937,7 @@ snapshots: dependencies: devtools-protocol: 0.0.1696802 mitt: 3.0.1 - zod: 4.6.5 + zod: 4.6.6 cjs-module-lexer@2.3.0: {} @@ -5222,9 +5233,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@15.5.27(eslint@9.39.4(jiti@1.21.7))(typescript@5.6.3): + eslint-config-next@15.5.28(eslint@9.39.4(jiti@1.21.7))(typescript@5.6.3): dependencies: - '@next/eslint-plugin-next': 15.5.27 + '@next/eslint-plugin-next': 15.5.28 '@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) @@ -5315,7 +5326,7 @@ snapshots: array-includes: 3.1.9 array.prototype.flatmap: 1.3.3 ast-types-flow: 0.0.8 - axe-core: 4.11.4 + axe-core: 4.14.0 axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 @@ -5452,7 +5463,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.8: {} + fast-uri@3.1.9: {} fastq@1.20.1: dependencies: @@ -5791,6 +5802,8 @@ snapshots: jiti@1.21.7: {} + jose@6.2.13: {} + jpeg-js@0.4.4: {} js-levenshtein@1.1.6: {} @@ -5863,7 +5876,7 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 - legacy-javascript@0.0.3: {} + legacy-javascript@0.0.4: {} levn@0.4.1: dependencies: @@ -6001,7 +6014,7 @@ snapshots: next-intl-swc-plugin-extractor@4.14.9: {} - next-intl@4.14.9(@types/react@19.2.14)(next@15.5.27(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0): + next-intl@4.14.9(@types/react@19.2.14)(next@15.5.28(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0): dependencies: '@eloqnt/config': 0.1.1 '@eloqnt/format-json': 0.1.0 @@ -6011,7 +6024,7 @@ snapshots: '@swc/core': 1.16.13 icu-minify: 4.14.9 negotiator: 1.1.0 - next: 15.5.27(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next: 15.5.28(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) next-intl-swc-plugin-extractor: 4.14.9 react: 19.0.0 use-intl: 4.14.9(@types/react@19.2.14)(react@19.0.0) @@ -6020,24 +6033,24 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - next@15.5.27(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + next@15.5.28(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: - '@next/env': 15.5.27 + '@next/env': 15.5.28 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001793 + caniuse-lite: 1.0.30001815 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.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 + '@next/swc-darwin-arm64': 15.5.28 + '@next/swc-darwin-x64': 15.5.28 + '@next/swc-linux-arm64-gnu': 15.5.28 + '@next/swc-linux-arm64-musl': 15.5.28 + '@next/swc-linux-x64-gnu': 15.5.28 + '@next/swc-linux-x64-musl': 15.5.28 + '@next/swc-win32-arm64-msvc': 15.5.28 + '@next/swc-win32-x64-msvc': 15.5.28 '@opentelemetry/api': 1.9.1 sharp: 0.35.5(@types/node@22.19.19) transitivePeerDependencies: @@ -6060,6 +6073,8 @@ snapshots: nwsapi@2.2.24: {} + oauth4webapi@3.8.8: {} + object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -6122,6 +6137,11 @@ snapshots: typescript: 5.6.3 yargs-parser: 21.1.1 + openid-client@6.8.8: + dependencies: + jose: 6.2.13 + oauth4webapi: 3.8.8 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -6830,7 +6850,7 @@ snapshots: util-deprecate@1.0.2: {} - vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7): + vite@6.4.4(@types/node@22.19.19)(jiti@1.21.7): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -6843,15 +6863,15 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(jsdom@26.1.0)(vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7)): + vitest@4.1.12(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(jsdom@26.1.0)(vite@6.4.4(@types/node@22.19.19)(jiti@1.21.7)): dependencies: - '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@6.4.3(@types/node@22.19.19)(jiti@1.21.7)) - '@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 + '@vitest/expect': 4.1.12 + '@vitest/mocker': 4.1.12(vite@6.4.4(@types/node@22.19.19)(jiti@1.21.7)) + '@vitest/pretty-format': 4.1.12 + '@vitest/runner': 4.1.12 + '@vitest/snapshot': 4.1.12 + '@vitest/spy': 4.1.12 + '@vitest/utils': 4.1.12 es-module-lexer: 2.3.2 expect-type: 1.3.0 magic-string: 0.30.21 @@ -6863,7 +6883,7 @@ snapshots: 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) + vite: 6.4.4(@types/node@22.19.19)(jiti@1.21.7) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -7000,4 +7020,4 @@ snapshots: yocto-queue@0.1.0: {} - zod@4.6.5: {} + zod@4.6.6: {} diff --git a/infra/keycloak/README.md b/infra/keycloak/README.md index 46bd84b4..5cb680e1 100644 --- a/infra/keycloak/README.md +++ b/infra/keycloak/README.md @@ -12,6 +12,30 @@ retains the earlier approval and owns safe warm-realm convergence. Authentication grants no application membership or permission; Phase 03 owns those records. +## Development authentication events + +[ADR-0070](../../docs/decisions/0070-owned-tenant-bff-sessions.md) authorizes +development tenant-realm user-event storage. The export retains the pinned +default-saved selection and adds refresh success/error events. The reconciler +unions the current effective selection with the eight required login/logout/ +code/refresh success/error types, preserving listeners and admin-event policy. + +Fresh/default-empty retention is seven days. A warm disabled/default-zero store +(the pinned API omits zero expiration) needs a bounded empty-history read first. +Existing finite retention of at least seven days is preserved. Shorter, +indefinite or ambiguous custom policy refuses with `plan/event_policy_conflict` +before even profile preparation. Resolve policy explicitly; never delete history +to bypass the guard. Separate readback and a zero-write rerun qualify convergence. + +Stored events contain personal identity/address details and stay in the provider +database, not raw CI artifacts. This affects all tenant-realm users, including +account-console. Hub/admin-event policy is unchanged. Local BFF expiry/outage +logout are not durable provider events; Phase 03 owns authenticated event ingress/ +mirroring and Phase 11 owns production retention/security/capacity qualification. +This configuration does not activate browser sign-in by itself. +The native web process requires the separate explicit BFF opt-in documented in +[web setup](../../frontend/apps/web/README.md#optional-tenant-sign-in). + ## Realms and accounts | Realm | Population | Clients | @@ -51,8 +75,8 @@ against it, including the host's default organization where applicable. `KEYCLOAK_WEB_CLIENT_SECRET`; newly scaffolded private files inherit that example. File permissions do not make a published value confidential. Compose passes it only to Keycloak; the import resolves its environment placeholder. The development - reconciler and the later P02b-7 BFF are its other consumers. The API, public - renderer and .NET Seeder never receive this secret. + reconciler and the explicitly enabled native BFF are its other consumers. + The API, public renderer and .NET Seeder never receive this secret. - Older private `.env` files must add that key explicitly, matching an already imported confidential client's secret. Convergence verifies that existing secret; a mismatch requires operator correction rather than automatic rotation. @@ -90,8 +114,8 @@ URLs, logs or public configuration. `KEYCLOAK_WEB_CLIENT_SECRET` is create-once local state, not a new value on every seed. Before the first Keycloak import, set it in the selected private file. -Use the same value for Compose, reconciliation and the later BFF. The API, -public renderer and application Seeder never receive it. +Use the same value for Compose, reconciliation and the optional native BFF owner. +The API, public rendering contract and application Seeder never receive it. - `missing_web_secret`: add the key explicitly before starting the stack, matching the existing imported client when present. A previously populated file is not @@ -175,8 +199,11 @@ capability. P02b-7 owns server-side BFF token custody; narrowing this already approved development capability requires a separate decision. The tenant export enables open registration with email as username, password reset and remember-me, while email verification is disabled. It declares no -password policy or event-persistence/listener configuration. These are local -development settings; production provisioning owns its own hardened configuration. +password policy. ADR-0070 enables the selected development identity-event types +and a default seven-day retention while preserving qualified prior policy; this +is not production event capacity, retention or application-audit durability. +These are local development settings; production provisioning owns its own +hardened configuration. Production client scope mapping, refresh rotation and MFA enforcement remain Phase 11 work; Phase 03 owns tenant identity policy. Tenant MFA stays optional in this development export; the operator realm keeps its declared TOTP action. diff --git a/infra/keycloak/realms/learnstack.json b/infra/keycloak/realms/learnstack.json index 9eb285e0..38c359d4 100644 --- a/infra/keycloak/realms/learnstack.json +++ b/infra/keycloak/realms/learnstack.json @@ -20,6 +20,115 @@ "maxDeltaTimeSeconds": 43200, "failureFactor": 30, "accessTokenLifespan": 900, + "eventsEnabled": true, + "eventsExpiration": 604800, + "enabledEventTypes": [ + "AUTHREQID_TO_TOKEN", + "AUTHREQID_TO_TOKEN_ERROR", + "CLIENT_DELETE", + "CLIENT_DELETE_ERROR", + "CLIENT_INITIATED_ACCOUNT_LINKING", + "CLIENT_INITIATED_ACCOUNT_LINKING_ERROR", + "CLIENT_LOGIN", + "CLIENT_LOGIN_ERROR", + "CLIENT_REGISTER", + "CLIENT_REGISTER_ERROR", + "CLIENT_UPDATE", + "CLIENT_UPDATE_ERROR", + "CODE_TO_TOKEN", + "CODE_TO_TOKEN_ERROR", + "CUSTOM_REQUIRED_ACTION", + "CUSTOM_REQUIRED_ACTION_ERROR", + "DELETE_ACCOUNT", + "DELETE_ACCOUNT_ERROR", + "EXECUTE_ACTIONS", + "EXECUTE_ACTIONS_ERROR", + "EXECUTE_ACTION_TOKEN", + "EXECUTE_ACTION_TOKEN_ERROR", + "FEDERATED_IDENTITY_LINK", + "FEDERATED_IDENTITY_LINK_ERROR", + "FEDERATED_IDENTITY_OVERRIDE_LINK", + "FEDERATED_IDENTITY_OVERRIDE_LINK_ERROR", + "GRANT_CONSENT", + "GRANT_CONSENT_ERROR", + "IDENTITY_PROVIDER_FIRST_LOGIN", + "IDENTITY_PROVIDER_FIRST_LOGIN_ERROR", + "IDENTITY_PROVIDER_LINK_ACCOUNT", + "IDENTITY_PROVIDER_LINK_ACCOUNT_ERROR", + "IDENTITY_PROVIDER_LOGIN", + "IDENTITY_PROVIDER_LOGIN_ERROR", + "IDENTITY_PROVIDER_POST_LOGIN", + "IDENTITY_PROVIDER_POST_LOGIN_ERROR", + "IMPERSONATE", + "IMPERSONATE_ERROR", + "INVITE_ORG", + "INVITE_ORG_ERROR", + "JWT_AUTHORIZATION_GRANT", + "JWT_AUTHORIZATION_GRANT_ERROR", + "LOGIN", + "LOGIN_ERROR", + "LOGOUT", + "LOGOUT_ERROR", + "OAUTH2_DEVICE_AUTH", + "OAUTH2_DEVICE_AUTH_ERROR", + "OAUTH2_DEVICE_CODE_TO_TOKEN", + "OAUTH2_DEVICE_CODE_TO_TOKEN_ERROR", + "OAUTH2_DEVICE_VERIFY_USER_CODE", + "OAUTH2_DEVICE_VERIFY_USER_CODE_ERROR", + "OAUTH2_EXTENSION_GRANT", + "OAUTH2_EXTENSION_GRANT_ERROR", + "PERMISSION_TOKEN", + "REFRESH_TOKEN", + "REFRESH_TOKEN_ERROR", + "REGISTER", + "REGISTER_ERROR", + "REMOVE_CREDENTIAL", + "REMOVE_CREDENTIAL_ERROR", + "REMOVE_FEDERATED_IDENTITY", + "REMOVE_FEDERATED_IDENTITY_ERROR", + "REMOVE_TOTP", + "REMOVE_TOTP_ERROR", + "RESET_PASSWORD", + "RESET_PASSWORD_ERROR", + "RESTART_AUTHENTICATION", + "RESTART_AUTHENTICATION_ERROR", + "REVOKE_GRANT", + "REVOKE_GRANT_ERROR", + "SEND_IDENTITY_PROVIDER_LINK", + "SEND_IDENTITY_PROVIDER_LINK_ERROR", + "SEND_RESET_PASSWORD", + "SEND_RESET_PASSWORD_ERROR", + "SEND_VERIFY_EMAIL", + "SEND_VERIFY_EMAIL_ERROR", + "TOKEN_EXCHANGE", + "TOKEN_EXCHANGE_ERROR", + "UPDATE_CONSENT", + "UPDATE_CONSENT_ERROR", + "UPDATE_CREDENTIAL", + "UPDATE_CREDENTIAL_ERROR", + "UPDATE_EMAIL", + "UPDATE_EMAIL_ERROR", + "UPDATE_PASSWORD", + "UPDATE_PASSWORD_ERROR", + "UPDATE_PROFILE", + "UPDATE_PROFILE_ERROR", + "UPDATE_TOTP", + "UPDATE_TOTP_ERROR", + "USER_DISABLED_BY_PERMANENT_LOCKOUT", + "USER_DISABLED_BY_TEMPORARY_LOCKOUT", + "VERIFIABLE_CREDENTIAL_CREATE_OFFER", + "VERIFIABLE_CREDENTIAL_CREATE_OFFER_ERROR", + "VERIFIABLE_CREDENTIAL_OFFER_REQUEST", + "VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR", + "VERIFIABLE_CREDENTIAL_PRE_AUTHORIZED_GRANT", + "VERIFIABLE_CREDENTIAL_PRE_AUTHORIZED_GRANT_ERROR", + "VERIFIABLE_CREDENTIAL_REQUEST", + "VERIFIABLE_CREDENTIAL_REQUEST_ERROR", + "VERIFY_EMAIL", + "VERIFY_EMAIL_ERROR", + "VERIFY_PROFILE", + "VERIFY_PROFILE_ERROR" + ], "accessTokenLifespanForImplicitFlow": 900, "ssoSessionIdleTimeout": 1800, "ssoSessionMaxLifespan": 36000, diff --git a/scripts/ci-native-tests.py b/scripts/ci-native-tests.py index 9f380045..dca52f2d 100644 --- a/scripts/ci-native-tests.py +++ b/scripts/ci-native-tests.py @@ -21,6 +21,7 @@ import stat import subprocess import sys +import tempfile import time import xml.etree.ElementTree as ET @@ -82,12 +83,17 @@ 'cgroup_kill_unavailable', 'startup_io_refused', 'startup_internal_refusal'} FAILURE_SOURCES = { 'OutboxProcessorTests.cs': 'outbox_processor', + 'BffNativeDriver.cs': 'bff_native_driver', + 'TenantBffSessionTests.cs': 'bff_session', 'TenantRealmApiTests.WarmQualification.cs': 'realm_warm_qualification', 'BackgroundJobPositiveSdkTests.CausalRestart.cs': 'positive_sdk_causal_restart', + 'BackgroundJobPositiveSdkTests.CausalRestartObservations.cs': 'positive_sdk_causal_observation', 'BackgroundJobPositiveSdkTests.NativeSettlement.cs': 'positive_sdk_native_settlement', 'BackgroundJobPositiveSdkTests.NativeWait.cs': 'positive_sdk_native_wait', 'BackgroundJobPositiveSdkTests.NativeHistory.cs': 'positive_sdk_native_history', 'BackgroundJobPositiveSdkTests.NativeHandoff.cs': 'positive_sdk_native_handoff', + 'BackgroundJobPositiveSdkTests.NativeRecovery.cs': 'positive_sdk_native_recovery', + 'BackgroundJobPositiveSdkTests.SourceContention.cs': 'positive_sdk_source_contention', 'BackgroundJobPositiveSdkTests.NativeSettlementScheduling.cs': 'positive_sdk_settlement_scheduling', 'BackgroundJobPositiveSdkTests.NativeSettlementPrepare.cs': 'positive_sdk_settlement_prepare', 'BackgroundJobRecurrenceStoreTests.NativeOwnership.cs': 'recurrence_native_ownership', @@ -135,6 +141,9 @@ 'setup initial-request initial-status initial-source initial-site initial-release watch-connect change ' 'source-barrier refresh-request refresh-status refresh-type refresh-source refresh-site refresh-release after' ).split()} +PUBLIC_DEVTOOLS_KINDS = { + 'owner-inactive', 'port-unreadable', 'deadline-missing', 'deadline-empty', 'deadline-malformed', +} KEYCLOAK_FAILURE_CLASSES = { 'TenantRealmApiTests', 'TenantRealmIssuanceTests', 'TenantRealmProfileProtectionTests', 'TenantRealmReconciliationTests', 'TenantRealmSelfServiceTests', @@ -404,7 +413,7 @@ def closed_native_wait_details(raw): if sum(len(item['Text']) for item in messages) > 65536: return None stages = {'start', 'handoff', 'constructor', 'capture', 'cancel', 'return', 'wrapper', 'held', - 'cleanup_request', 'cleanup_stop', 'cleanup_completion', 'cleanup_complete'} + 'cleanup_request', 'cleanup_stop', 'cleanup_completion', 'cleanup_timers', 'cleanup_complete'} tasks = {'not_issued', 'succeeded', 'cancelled', 'faulted', 'pending'} fields = ('boundary', 'constructor', 'invocation', 'wrapper', 'actual', 'runtime', 'completion', 'control', 'pools', 'shared', 'bridgeLoop', 'bridgeStop') @@ -441,6 +450,37 @@ def closed_native_wait_details(raw): if values['boundary'] not in stages or kind == 'cleanup' and not values['boundary'].startswith('cleanup_'): return None result[kind] = values + marker = 'Native handoff;' + count = sum(item['Text'].count(marker) for item in messages) + if count: + lines = [line for item in messages if item['Category'] == 'StdOutMsgs' + for line in item['Text'].splitlines() if marker in line] + if count != 1 or len(lines) != 1: + return None + fields = ('native', 'eligible', 'running', 'retained', 'closed', 'route', 'phase', + 'owner', 'lease', 'attempts', 'hint') + match = re.fullmatch(re.escape(marker) + ';'.join(name + r'=([a-z_0-9]{1,24})' for name in fields) + + r'\.', lines[0]) + if match is None: + return None + handoff = dict(zip(fields, match.groups())) + allowed = { + 'native': {'succeeded', 'processing', 'enqueued', 'scheduled', 'missing', 'other'}, + 'eligible': {'yes', 'no'}, 'closed': {'yes', 'no'}, + 'hint': {'not_attempted', 'attempted', 'acknowledged'}, + 'route': {'not_checked', 'allowed', 'refused'}, + 'phase': {'pending', 'preparing', 'prepared', 'executing', 'reconciling', 'finalize_pending', + 'finalizing', 'terminal_pending', 'completed', 'quarantined', 'other'}, + 'owner': {'none', 'routing', 'execution', 'recovery', 'other'}, + 'lease': {'absent', 'live', 'elapsed'}, + } + if any(handoff[field] not in values for field, values in allowed.items()): + return None + for field, maximum in (('running', 64), ('retained', 64), ('attempts', 6)): + if not re.fullmatch(r'0|[1-9][0-9]?', handoff[field]) or int(handoff[field]) > maximum: + return None + handoff[field] = int(handoff[field]) + result['handoff'] = handoff return result @@ -481,12 +521,430 @@ def closed_outbox_startup_details(raw): return result +SOURCE_CONTENTION_FAILURE_CASE = ( + 'LearnStack.Tests.Integration.Database.BackgroundJobPositiveSdkTests.' + 'Native_transactional_pool_completes_at_the_app_source_floor_while_actual_external_reconcile_is_held' +) +SOURCE_CONTENTION_BOUNDARIES = { + 'start', 'capacity', 'reconcile_wait', 'reconcile_facts', 'pressure', 'submit', + 'transaction_wait', 'transaction_facts', 'overflow', 'transaction_complete', 'committed', 'external_facts', + 'cleanup_pressure', 'cleanup_request', 'cleanup_stop', 'cleanup_completion', 'cleanup_complete', +} + + +def closed_source_contention_details(raw): + """Test-owned first failure and later cleanup; neither is completion authority.""" + messages = raw.get('Messages') + if not isinstance(messages, list) or not 1 <= len(messages) <= 32: + return None + if any(not isinstance(item, dict) or set(item) != {'Category', 'Text'} + or not isinstance(item['Category'], str) or not 1 <= len(item['Category']) <= 32 + or not isinstance(item['Text'], str) for item in messages): + return None + if sum(len(item['Text']) for item in messages) > 65536: + return None + tasks = {'not_issued', 'succeeded', 'cancelled', 'faulted', 'pending'} + states = {'not_started', 'bootstrapping', 'starting', 'running', 'stopping', 'retained', 'stopped', 'unknown'} + counts = {'pressure': 30, 'executions': 64, 'retained': 64, 'renewalRefused': 64, 'leaseElapsed': 64} + fields = ('boundary', 'reconcile', 'transactional', 'provider', 'auxiliary', 'pressure', + 'runtime', 'completion', 'executions', 'retained', 'renewalRefused', 'leaseElapsed') + result = {'stage': 'source_contention'} + for kind in ('failure', 'cleanup'): + marker = 'Source contention ' + kind + ';' + count = sum(item['Text'].count(marker) for item in messages) + if count == 0 and kind == 'cleanup': + continue + lines = [line for item in messages if item['Category'] == 'StdOutMsgs' + for line in item['Text'].splitlines() if marker in line] + if count != 1 or len(lines) != 1: + return None + unavailable = re.fullmatch(re.escape(marker) + r'boundary=([a-z_]+);snapshot=unavailable\.', lines[0]) + if unavailable: + values = {'boundary': unavailable[1], 'snapshot': 'unavailable'} + else: + match = re.fullmatch(re.escape(marker) + ';'.join(name + r'=([a-z_0-9]{1,32})' for name in fields) + + r'\.', lines[0]) + if not match: + return None + values = dict(zip(fields, match.groups())) + if values['runtime'] not in states or any(values[name] not in tasks for name in + ('reconcile', 'transactional', 'provider', 'auxiliary', 'completion')): + return None + for name, maximum in counts.items(): + value = values[name] + if value in ('unavailable', 'out_of_range') and name != 'pressure': + continue + if not re.fullmatch(r'0|[1-9][0-9]?', value) or int(value) > maximum: + return None + values[name] = int(value) + if values['boundary'] not in SOURCE_CONTENTION_BOUNDARIES or ( + kind == 'cleanup' and not values['boundary'].startswith('cleanup_')): + return None + result[kind] = values + return result + + +CAUSAL_RESTART_FAILURE_CASES = { + 'LearnStack.Tests.Integration.Database.BackgroundJobPositiveSdkTests.' + 'Native_completed_cancellation_survives_restart_and_independent_failure_is_not_reclassified' + '(independentFailure: ' + independent + ', lieOnlyOnReconcile: ' + lie + ')' + for independent, lie in [('False', 'False'), ('True', 'False'), ('False', 'True')] +} +CAUSAL_RESTART_BOUNDARIES = { + 'original_start', 'provider_accept', 'original_facts', 'independent_completion', 'original_stop', + 'provider_token', 'provider_completion', 'provider_report', 'original_retention', 'restart_start', + 'terminal_wait', 'reconcile_facts', 'read_facts', 'transaction_witness', 'assert_facts', 'meaning', + 'original_cleanup_timers', +} | {prefix + '_cleanup_' + stage for prefix in ('original', 'restart') + for stage in ('request', 'release', 'stop', 'completion', 'complete')} + + +def closed_causal_restart_counts(case, raw): + # Successful retry/lease-loss observations are diagnostics, never an + # alternative result, receipt success signal or grant of execution authority. + if (case['assembly'] != 'learnstack.tests.integration' + or case['name'] not in CAUSAL_RESTART_FAILURE_CASES): + return None + messages = raw.get('Messages') + if (not isinstance(messages, list) or not 1 <= len(messages) <= 32 + or any(not isinstance(item, dict) or set(item) != {'Category', 'Text'} + or not isinstance(item['Category'], str) or not 1 <= len(item['Category']) <= 32 + or not isinstance(item['Text'], str) for item in messages) + or sum(len(item['Text']) for item in messages) > 65536): + return None + marker = 'Causal restart counts;' + lines = [line for item in messages if item['Category'] == 'StdOutMsgs' + for line in item['Text'].splitlines() if marker in line] + forced_case = case['name'].endswith('(independentFailure: False, lieOnlyOnReconcile: False)') + if (len(lines) != (2 if forced_case else 1) + or sum(item['Text'].count(marker) for item in messages) != len(lines)): + return None + runs = [] + for index, line in enumerate(lines): + match = re.fullmatch(re.escape(marker) + + r'reconciles=([0-9]+);finalize=([0-9]+);ledger=([0-9]+);forced=(yes|no);' + + r'leaseElapsed=([0-9]+);renewalRefused=([0-9]+)\.', line) + if match is None or (match[4] == 'yes') != (forced_case and index == 1): + return None + fields = ('reconciles', 'finalize', 'ledger', 'leaseElapsed', 'renewalRefused') + values = (match[1], match[2], match[3], match[5], match[6]) + if any(not re.fullmatch(r'0|[1-9][0-9]{0,4}', value) + or int(value) > maximum for value, maximum in zip(values, (10000, 6, 12, 10000, 10000))): + return None + runs.append({**dict(zip(fields, map(int, values))), 'forced': match[4] == 'yes'}) + return {'stage': 'causal_restart_counts', 'runs': runs} + + +def closed_causal_restart_details(raw): + messages = raw.get('Messages') + if not isinstance(messages, list) or not 1 <= len(messages) <= 32: + return None + if any(not isinstance(item, dict) or set(item) != {'Category', 'Text'} + or not isinstance(item['Category'], str) or not 1 <= len(item['Category']) <= 32 + or not isinstance(item['Text'], str) for item in messages): + return None + if sum(len(item['Text']) for item in messages) > 65536: + return None + states = {'not_started', 'bootstrapping', 'starting', 'running', 'stopping', 'retained', 'stopped', 'unknown'} + counts = {'executions': 64, 'renewalRefused': 64, 'leaseElapsed': 64, 'normal': 6, 'finalize': 6, + 'ledger': 12, 'release': 64, 'audits': 64, 'nulls': 511, 'calls': 6, 'forced': 1} + row_fields = {'normal', 'finalize', 'ledger', 'release', 'audits', 'incomplete', 'outcome', 'policy', 'reason', 'nulls'} + fields = ('boundary', 'runtime', 'executions', 'renewalRefused', 'leaseElapsed', 'row', + 'normal', 'finalize', 'ledger', 'release', 'audits', 'incomplete', 'outcome', 'policy', + 'reason', 'nulls', 'calls', 'forced') + enums = {'runtime': states, 'row': {'observed', 'unobserved'}, 'incomplete': {'yes', 'no'}, + 'outcome': {'succeeded', 'failed', 'cancelled', 'other', 'missing'}, + 'policy': {'permanent', 'transient_repeatable', 'transient_not_repeatable', 'other', 'missing'}, + 'reason': {'permanent_failure', 'external_retry_forbidden', 'retry_exhausted', + 'finalize_refused', 'finalize_exhausted', 'other', 'missing'}} + result = {'stage': 'causal_restart'} + for kind in ('failure', 'cleanup'): + marker = 'Causal restart ' + kind + ';' + count = sum(item['Text'].count(marker) for item in messages) + if count == 0 and kind == 'cleanup': + continue + lines = [line for item in messages if item['Category'] == 'StdOutMsgs' + for line in item['Text'].splitlines() if marker in line] + if count != 1 or len(lines) != 1: + return None + unavailable = re.fullmatch(re.escape(marker) + r'boundary=([a-z_]+);snapshot=unavailable\.', lines[0]) + if unavailable: + values = {'boundary': unavailable[1], 'snapshot': 'unavailable'} + else: + match = re.fullmatch(re.escape(marker) + ';'.join(name + r'=([a-z_0-9]{1,32})' for name in fields) + + r'\.', lines[0]) + if not match: + return None + values = dict(zip(fields, match.groups())) + for name, allowed in enums.items(): + if values[name] not in allowed and not (name in row_fields and values[name] == 'unavailable'): + return None + for name, maximum in counts.items(): + value = values[name] + if value == 'out_of_range' or value == 'unavailable' and name != 'forced': + continue + if not re.fullmatch(r'0|[1-9][0-9]{0,2}', value) or int(value) > maximum: + return None + values[name] = int(value) + if values['row'] == 'unobserved': + if any(values[name] != 'unavailable' for name in row_fields): + return None + elif any(values[name] == 'unavailable' for name in row_fields): + return None + if values['boundary'] not in CAUSAL_RESTART_BOUNDARIES or ( + kind == 'cleanup' and '_cleanup_' not in values['boundary']): + return None + result[kind] = values + return result + + +NATIVE_RECOVERY_FAILURE_CASES = { + 'LearnStack.Tests.Integration.Database.BackgroundJobPositiveSdkTests.' + 'Actual_native_recovery_owner_renews_past_old_deadlines_without_admission_or_another_Execute' + '(loseAcquisitionAck: ' + lost + ')' + for lost in ('False', 'True') +} +RECURRENCE_RUNTIME_FAILURE_CASES = { + 'LearnStack.Tests.Integration.Database.BackgroundJobRecurrenceStoreTests.' + 'Actual_runtime_fans_out_to_both_scopes_and_duplicate_native_ticks_create_no_extra_children' + '(organization: ' + value + ')' for value in ('True', 'False') +} +RECURRENCE_RUNTIME_COUNTS = ( + 'completed', 'executed', 'occurrences', 'recipients', 'fanouts', 'applications', + 'retainedApplications', 'bridgeOutstanding', 'bridgeRetained', 'bridgeFailed', + 'executions', 'renewalRefused', 'leaseElapsed', +) + + +def closed_recurrence_runtime_details(raw): + messages = raw.get('Messages') + if (not isinstance(messages, list) or not 1 <= len(messages) <= 32 + or any(not isinstance(item, dict) or set(item) != {'Category', 'Text'} + or not isinstance(item['Category'], str) or not 1 <= len(item['Category']) <= 32 + or not isinstance(item['Text'], str) for item in messages) + or sum(len(item['Text']) for item in messages) > 65536): + return None + marker = 'Recurrence runtime failure;' + lines = [line for item in messages if item['Category'] == 'StdOutMsgs' + for line in item['Text'].splitlines() if marker in line] + if len(lines) != 1 or sum(item['Text'].count(marker) for item in messages) != 1: + return None + fields = ('boundary', 'runtime', *RECURRENCE_RUNTIME_COUNTS) + match = re.fullmatch(re.escape(marker) + + ';'.join(name + r'=([A-Za-z_0-9]{1,32})' for name in fields) + r'\.', lines[0]) + if match is None: + return None + values = dict(zip(fields, match.groups())) + if (values['boundary'] not in ('start', 'children', 'fanout', 'ticks', 'health', 'assertions') + or values['runtime'] not in ('NotStarted', 'Bootstrapping', 'Starting', 'Running', + 'Stopping', 'Retained', 'Stopped')): + return None + for name in RECURRENCE_RUNTIME_COUNTS: + value = values[name] + if value == 'unavailable': + values[name] = None + elif re.fullmatch(r'0|[1-9][0-9]{0,4}', value) and int(value) <= 10000: + values[name] = int(value) + else: + return None + return {'stage': 'recurrence_runtime', **values} + + +NATIVE_HANDOFF_FAILURE_CASE = ('LearnStack.Tests.Integration.Database.BackgroundJobPositiveSdkTests.' + 'Native_fixture_start_waits_for_previous_actual_cleanup_before_one_same_generation_hint') +NATIVE_HANDOFF_BOUNDARIES = { + 'submit', 'bridge', 'lock', 'runtime_start', 'cleanup_enter', 'lock_release', 'hint_start', + 'hint_count', 'premature_hint', 'release_cleanup', 'construction', 'renewal', 'assert', 'cleanup', +} + + +def closed_native_handoff_details(raw): + messages = raw.get('Messages') + if (not isinstance(messages, list) or not 1 <= len(messages) <= 32 + or any(not isinstance(item, dict) or set(item) != {'Category', 'Text'} + or not isinstance(item['Category'], str) or not 1 <= len(item['Category']) <= 32 + or not isinstance(item['Text'], str) for item in messages) + or sum(len(item['Text']) for item in messages) > 65536): + return None + marker = 'Native handoff failure;' + lines = [line for item in messages if item['Category'] == 'StdOutMsgs' + for line in item['Text'].splitlines() if marker in line] + if len(lines) != 1 or sum(item['Text'].count(marker) for item in messages) != 1: + return None + tasks = ('pulse', 'helper', 'pacing', 'cleanup', 'joined', 'constructor') + counts = ('executions', 'renewalRefused', 'leaseElapsed') + fields = ('boundary', 'kind', *tasks, *counts) + match = re.fullmatch(re.escape(marker) + + ';'.join(name + r'=([a-z_0-9]{1,32})' for name in fields) + r'\.', lines[0]) + if match is None: + return None + values = dict(zip(fields, match.groups())) + if (values['boundary'] not in NATIVE_HANDOFF_BOUNDARIES + or values['kind'] not in ('timeout', 'cancellation', 'assertion', 'invalid_state', 'other') + or any(values[name] not in ('not_issued', 'pending', 'succeeded', 'canceled', 'faulted') for name in tasks)): + return None + for name in counts: + value = values[name] + if value == 'unavailable': + continue + if not re.fullmatch(r'0|[1-9][0-9]{0,4}', value) or int(value) > 10000: + return None + values[name] = int(value) + return {'stage': 'native_handoff', **values} + + +NATIVE_RECOVERY_BOUNDARIES = { + 'rooted', 'construction', 'identity', 'renewal_wait', 'database_renewal', 'old_lease', + 'provider_before', 'reconcile', 'row_facts', 'cleanup_actual', 'cleanup_work', + 'cleanup_control', 'post_cleanup', +} + + +def closed_native_recovery_details(raw): + messages = raw.get('Messages') + if (not isinstance(messages, list) or not 1 <= len(messages) <= 32 + or any(not isinstance(item, dict) or set(item) != {'Category', 'Text'} + or not isinstance(item['Category'], str) or not 1 <= len(item['Category']) <= 32 + or not isinstance(item['Text'], str) for item in messages) + or sum(len(item['Text']) for item in messages) > 65536): + return None + fields = ('boundary', 'kind', 'actual', 'timerChanges', 'baselineChanges', 'renewals', + 'renewalLosses', 'elapsedLosses', 'runClosed', 'tokenCancelled', 'dropped', 'clean') + enums = {'boundary': NATIVE_RECOVERY_BOUNDARIES, + 'kind': {'timeout', 'cancellation', 'assertion', 'invalid_state', 'other'}, + 'actual': {'not_issued', 'pending', 'succeeded', 'faulted', 'canceled'}} + counts = ('timerChanges', 'baselineChanges', 'renewals', 'renewalLosses', 'elapsedLosses', 'dropped') + result = {'stage': 'native_recovery'} + for record_kind in ('failure', 'cleanup'): + marker = 'Native recovery ' + record_kind + ';' + count = sum(item['Text'].count(marker) for item in messages) + if record_kind == 'cleanup' and count == 0: + continue + lines = [line for item in messages if item['Category'] == 'StdOutMsgs' + for line in item['Text'].splitlines() if marker in line] + match = re.fullmatch(re.escape(marker) + + ';'.join(name + r'=([a-z_0-9]{1,32})' for name in fields) + r'\.', lines[0]) \ + if count == 1 and len(lines) == 1 else None + if match is None: + return None + values = dict(zip(fields, match.groups())) + if any(values[name] not in allowed for name, allowed in enums.items()): + return None + for name in counts: + value = values[name] + if value in ('unavailable', 'out_of_range'): + continue + if not re.fullmatch(r'0|[1-9][0-9]{0,4}', value) or int(value) > 10000: + return None + values[name] = int(value) + for name in ('runClosed', 'tokenCancelled', 'clean'): + if values[name] not in ('true', 'false'): + return None + values[name] = values[name] == 'true' + result[record_kind] = values + return result + + +BFF_BROWSER_FAILURE_CASE = ('LearnStack.Tests.Integration.TenancyIdentity.TenantBffSessionTests.' + 'Native_https_browser_stores_host_cookies_and_revalidates_logout_history_with_a_live_counter_control') +BFF_BROWSER_FAILURE_STAGES = { + 'input', 'input-issuer', 'input-api', 'input-secrets', 'input-sites', 'setup', 'compile', 'build', + 'start', 'command', 'request', 'request-deadline', 'request-connect', 'request-tls', 'request-socket', + 'response-json', 'cleanup', 'privacy', 'native-stop', + 'browser-executable', 'browser-spawn', 'browser-devtools', 'browser-devtools-inactive', + 'browser-devtools-unreadable', 'browser-devtools-missing', 'browser-devtools-empty', 'browser-devtools-malformed', + 'browser-not-live', 'browser-connect', 'browser-context', + 'browser-launch', 'browser-anonymous', 'browser-signin-document', 'browser-submit', + 'browser-origin-null', 'browser-origin-absent', 'browser-origin-other', 'browser-storage', + 'browser-extension', 'browser-logout-submit', 'browser-logout-origin', 'browser-logout-origin-null', + 'browser-logout-origin-other', 'browser-logout-response', 'browser-logout-location', + 'browser-logout-navigation', 'browser-logout-cookie-header', 'browser-logout-cookie', + 'browser-back-revalidation', 'browser-history-setup', 'browser-history-revalidation', 'browser-headers', +} | {prefix + code for prefix in ('browser-callback-', 'browser-authorize-', 'browser-logout-status-') + for code in ('0', '200', '303', '400', '403', '404', '429', '503', 'other')} +BFF_BROWSER_FAILURE_REASONS = BFF_BROWSER_FAILURE_STAGES | { + stage + '_unexpected_output' for stage in BFF_BROWSER_FAILURE_STAGES +} | { + stage + '_cleanup_failed' for stage in BFF_BROWSER_FAILURE_STAGES +} | {'response', 'deadline_input', 'exit', 'output', 'node_module_missing', 'node_syntax', 'node_abort', + 'node_permission', 'unexpected_output'} +BFF_BROWSER_FIXED_FAILURES = { + 'BFF native fixture could not start.': 'spawn', + 'BFF native fixture private input failed.': 'input', + 'BFF browser fixture lacks its complete positive/counter witness.': 'witness', + 'BFF browser restore count is outside its bound.': 'restore_count', + 'BFF browser counter has no closed restoration witness.': 'counter_witness', + 'BFF native fixture emitted an unexpected extra receipt.': 'extra_receipt', + 'BFF native fixture required forced cleanup.': 'forced_cleanup', + 'BFF native fixture completed without a successful exit.': 'nonzero_exit', + 'BFF native fixture retained its private TLS/build tree.': 'retained_tree', + 'BFF native fixture cleanup could not be confirmed.': 'cleanup_deadline', +} + + +def closed_bff_browser_details(raw): + message = raw.get('ErrorMessage') + if not isinstance(message, str) or len(message) > 65536: + return None + result = {'stage': 'bff_browser'} + messages = raw.get('Messages', []) + if (not isinstance(messages, list) or len(messages) > 32 + or any(not isinstance(item, dict) or set(item) != {'Category', 'Text'} + or not isinstance(item['Category'], str) or not isinstance(item['Text'], str) for item in messages) + or sum(len(item['Text']) for item in messages) > 65536): + return None + for kind in ('failure', 'cleanup'): + marker = 'BFF browser ' + kind + ';' + count = sum(item['Text'].count(marker) for item in messages) + if not count: + continue + lines = [line for item in messages if item['Category'] == 'StdOutMsgs' + for line in item['Text'].splitlines() if marker in line] + pattern = re.escape(marker) + r'journey=(positive|counter)' + (r';state=failed\.' if kind == 'cleanup' else r'\.') + match = re.fullmatch(pattern, lines[0]) if count == 1 and len(lines) == 1 else None + if match is None or kind == 'cleanup' and result.get('journey') != match[1]: + return None + if kind == 'failure': + result['journey'] = match[1] + else: + result['secondaryCleanup'] = 'failed' + # xUnit's exact exception prefix is stripped; arbitrary trailing text, + # nested exceptions and credentials never become a diagnostic dimension. + prefix = 'System.InvalidOperationException : ' + if message.startswith(prefix): + message = message[len(prefix):] + if message in BFF_BROWSER_FIXED_FAILURES: + return {**result, 'reason': BFF_BROWSER_FIXED_FAILURES[message]} + match = re.fullmatch(r'BFF native fixture did not reach the closed browser_result/([a-z_0-9-]{1,96}) phase\.', message) + if match and match[1] in BFF_BROWSER_FAILURE_REASONS: + return {**result, 'reason': match[1]} + return {**result, 'reason': 'other'} if 'journey' in result else None + + def closed_failure_details(case, raw): """Decode only existing test-owned finite facts; raw error text never leaves this function.""" + if case['name'] in RECURRENCE_RUNTIME_FAILURE_CASES: + return closed_recurrence_runtime_details(raw) if case['assembly'] == 'learnstack.tests.integration' else None + if case['name'] in NATIVE_RECOVERY_FAILURE_CASES: + return closed_native_recovery_details(raw) if case['assembly'] == 'learnstack.tests.integration' else None + if case['name'] == NATIVE_HANDOFF_FAILURE_CASE: + return closed_native_handoff_details(raw) if case['assembly'] == 'learnstack.tests.integration' else None + if case['name'] == BFF_BROWSER_FAILURE_CASE: + return closed_bff_browser_details(raw) if case['assembly'] == 'learnstack.tests.integration' else None if case['name'] in OUTBOX_STARTUP_FAILURE_CASES: return closed_outbox_startup_details(raw) if case['assembly'] == 'learnstack.tests.integration' else None if case['name'] in NATIVE_WAIT_FAILURE_CASES: return closed_native_wait_details(raw) if case['assembly'] == 'learnstack.tests.integration' else None + if case['name'] == SOURCE_CONTENTION_FAILURE_CASE: + return closed_source_contention_details(raw) if case['assembly'] == 'learnstack.tests.integration' else None + if case['name'] in CAUSAL_RESTART_FAILURE_CASES: + if case['assembly'] != 'learnstack.tests.integration': + return None + details = closed_causal_restart_details(raw) + if details and not case['name'].endswith('(independentFailure: False, lieOnlyOnReconcile: False)'): + if any(value.get('forced', 0) != 0 for key, value in details.items() if key in ('failure', 'cleanup')): + return None + return details if case['name'] == RECURRENCE_NATIVE_FAILURE_CASE: return closed_recurrence_native_details(raw) if case['assembly'] == 'learnstack.tests.integration' else None if case['name'] in SETTLEMENT_FAILURE_CASES: @@ -525,6 +983,10 @@ def closed_failure_details(case, raw): stage = match[1] if stage in PUBLIC_FAILURE_STAGES: return {'stage': stage} + parts = stage.rsplit(' ', 2) + if (len(parts) == 3 and parts[0] in {'browser 429', 'browser 503'} + and parts[1] == 'browser-devtools' and parts[2] in PUBLIC_DEVTOOLS_KINDS): + return {'stage': parts[0], 'detail': parts[1], 'kind': parts[2]} stage, _, detail = stage.rpartition(' ') if stage in PUBLIC_FAILURE_STAGES and detail in PUBLIC_FAILURE_DETAILS: return {'stage': stage, 'detail': detail} @@ -651,7 +1113,116 @@ def write_json(path, value): temporary.replace(path) -def command(argv, deadline, reason, *, cap=COMMAND_SECONDS): +COMMAND_FAILURE_KINDS = frozenset({ + 'rate_limit', 'auth', 'manifest', 'dns', 'timeout', 'tls', 'unavailable', + 'disk', 'transport', 'other', 'launch', 'deadline', 'output_bound', +}) + + +def command_failure_kind(stderr): + # Report a closed observation, not a causal diagnosis. Never return a + # matched fragment, provider text, endpoint, path or exception message. + text = stderr.lower() + patterns = ( + ('rate_limit', (b'toomanyrequests', b'too many requests', b'rate limit')), + ('unavailable', (b'permission denied while trying to connect to the docker daemon socket', + b'cannot connect to the docker daemon', b'daemon is not running')), + ('auth', (b'unauthorized', b'authentication required', b'pull access denied', + b'no basic auth credentials', b'denied:', b'permission denied', b'403 forbidden')), + ('manifest', (b'manifest unknown', b'no matching manifest', b'manifest not found', b'manifest invalid')), + ('dns', (b'no such host', b'temporary failure in name resolution', b'name or service not known', + b'could not resolve host', b'server misbehaving')), + ('timeout', (b'timeout', b'timed out', b'deadline exceeded')), + ('tls', (b'x509:', b'certificate signed by unknown authority', b'tls handshake', + b'tls: ', b'certificate verify failed')), + ('unavailable', (b'service unavailable', b'bad gateway', b'internal server error')), + ('disk', (b'no space left on device', b'disk quota exceeded', b'not enough space', b'enospc')), + ('transport', (b'connection reset', b'connection refused', b'network is unreachable', + b'broken pipe', b'unexpected eof', b'connection closed')), + ) + for kind, fragments in patterns: + if any(fragment in text for fragment in fragments): + return kind + return 'transport' if text.strip() == b'eof' else 'other' + + +def diagnostic_command(argv, deadline, reason, cap): + # Only fixed fixture pulls opt in. Drain both private pipes while the child + # runs: neither a flood nor a slow command can grow a disk spool or an + # unbounded communicate() buffer. A timeout is never a process-join witness. + process, selector, failure = None, None, None + stdout, stderr = bytearray(), bytearray() + effective_deadline = min(deadline, time.monotonic() + cap) + try: + selector = selectors.DefaultSelector() + remaining(effective_deadline) + try: + process = subprocess.Popen(argv, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env={'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8', + 'LC_ALL': 'C.UTF-8', 'HOME': os.environ.get('HOME', ''), + 'GIT_CONFIG_NOSYSTEM': '1', 'GIT_CONFIG_GLOBAL': '/dev/null'}) + except OSError as error: + kind = 'disk' if error.errno in (errno.ENOSPC, errno.EDQUOT) else 'launch' + raise Refusal(reason + '_' + kind) from None + selector.register(process.stdout, selectors.EVENT_READ, stdout) + selector.register(process.stderr, selectors.EVENT_READ, stderr) + while selector.get_map() or process.poll() is None: + try: + budget = remaining(effective_deadline) + except Refusal as refused: + if refused.reason == 'deadline_exhausted': + raise Refusal(reason + '_timeout') from None + raise + for key, _ in selector.select(min(POLL_SECONDS, budget)): + target = key.data + chunk = os.read(key.fd, min(65536, MAX_BYTES - len(target) + 1)) + if not chunk: + selector.unregister(key.fileobj) + continue + require(len(target) + len(chunk) <= MAX_BYTES, reason + '_output_bound') + target.extend(chunk) + process.wait() + if process.returncode != 0: + kind = command_failure_kind(stderr) + require(isinstance(kind, str) and kind in COMMAND_FAILURE_KINDS, reason + '_other') + raise Refusal(reason + '_' + kind) + except Refusal as refused: + failure = Refusal(reason + '_deadline') if refused.reason == 'deadline_exhausted' else refused + except OSError as error: + kind = 'disk' if error.errno in (errno.ENOSPC, errno.EDQUOT) else 'unavailable' + failure = Refusal(reason + '_' + kind) + finally: + # Reap the direct child even after overflow/cancellation. Its exit does + # not claim anything about other runner processes or Docker containers. + if process is not None: + try: + try: + if process.poll() is None: + process.kill() + finally: + process.wait() + except OSError as error: + if failure is None: + kind = 'disk' if error.errno in (errno.ENOSPC, errno.EDQUOT) else 'unavailable' + failure = Refusal(reason + '_' + kind) + for resource in (selector, process.stdout if process else None, + process.stderr if process else None): + if resource is not None: + try: + resource.close() + except OSError as error: + if failure is None: + kind = 'disk' if error.errno in (errno.ENOSPC, errno.EDQUOT) else 'unavailable' + failure = Refusal(reason + '_' + kind) + if failure is not None: + raise failure from None + return bytes(stdout) + + +def command(argv, deadline, reason, *, cap=COMMAND_SECONDS, diagnose=False): + if diagnose: + return diagnostic_command(argv, deadline, reason, cap) try: result = subprocess.run(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=remaining(deadline, cap), @@ -659,7 +1230,7 @@ def command(argv, deadline, reason, *, cap=COMMAND_SECONDS): 'LC_ALL': 'C.UTF-8', 'HOME': os.environ.get('HOME', ''), 'GIT_CONFIG_NOSYSTEM': '1', 'GIT_CONFIG_GLOBAL': '/dev/null'}, check=False) - except (OSError, subprocess.TimeoutExpired): + except (subprocess.TimeoutExpired, OSError): raise Refusal(reason) from None require(result.returncode == 0 and len(result.stdout) <= MAX_BYTES, reason) return result.stdout @@ -1023,6 +1594,7 @@ def __init__(self, path, assembly, expected, stream): self.generations = 0 self.first_result_at = None self.failure_locations = [] + self.success_observations = [] def close(self): self.reader.close() @@ -1079,6 +1651,11 @@ def results(self, payload): if details is not None: diagnostic['details'] = details self.failure_locations.append(diagnostic) + else: + details = closed_causal_restart_counts(case, raw) + if details is not None: + self.success_observations.append({'assembly': case['assembly'], 'id': case['id'], + 'details': details}) if self.first_result_at is None: self.first_result_at = time.monotonic() item = {**case, 'outcome': outcome, 'duration': duration} @@ -1406,7 +1983,8 @@ def shard_workload(mode, context, private, output, deadline, work): diagnostic = private / (assembly + '-execution.diag') progress = ExecutionProgress(diagnostic, assembly, expected, stream) item = {'assembly': assembly.casefold(), 'cases': len(expected), 'adapter': None, - 'firstResultSeconds': None, 'testProcessSeconds': None, 'failureLocations': []} + 'firstResultSeconds': None, 'testProcessSeconds': None, 'failureLocations': [], + 'successObservations': []} measurements['projects'].append(item) try: argv = ['test', project, '--configuration', 'Release', '--no-build', '--no-restore', @@ -1420,6 +1998,7 @@ def shard_workload(mode, context, private, output, deadline, work): finally: progress.close() item['failureLocations'] = progress.failure_locations + item['successObservations'] = progress.success_observations item['testProcessSeconds'] = round(time.monotonic() - began, 3) if progress.first_result_at is not None: item['firstResultSeconds'] = round(progress.first_result_at - began, 3) diff --git a/scripts/ci-test-shard-results.py b/scripts/ci-test-shard-results.py index c734b3b1..57756282 100644 --- a/scripts/ci-test-shard-results.py +++ b/scripts/ci-test-shard-results.py @@ -76,9 +76,11 @@ def prepare_fixture_images(): beginning = time.monotonic() deadline = beginning + min(110, available) images = [] - for image in FIXTURE_IMAGES: + require(len(FIXTURE_IMAGES) == 2, 'fixture_setup_images_invalid') + for component, image in zip(('keycloak', 'postgres'), FIXTURE_IMAGES): image_started = time.monotonic() - OWNER.command([OWNER.DOCKER, 'pull', image], deadline, 'fixture_image_pull_failed', cap=55) + OWNER.command([OWNER.DOCKER, 'pull', image], deadline, + 'fixture_image_pull_failed_' + component, cap=55, diagnose=True) images.append({'image': image, 'pullSeconds': round(time.monotonic() - image_started, 3)}) require(time.monotonic() < deadline, 'fixture_setup_deadline_exhausted') print(json.dumps({'protocol': 1, 'fixtureImagePreparationSeconds': round(time.monotonic() - beginning, 3), @@ -187,6 +189,13 @@ def workflow_topology(documents, plan): require(len(names) == len(set(names)) and all(names.count(name) == 1 for name in (BUILD_STEP, EXECUTE_STEP, UPLOAD_STEP)), 'execution_gates_missing') if scope == 'ordinary': + compile_name = 'Compile native web and authenticated SDK fixture entries' + compile_steps = [step for step in steps if step.get('name') == compile_name] + require(len(compile_steps) == 1 + and compile_steps[0].get('run') == 'pnpm --filter @learnstack/web build:ingress' + and compile_steps[0].get('working-directory') == 'frontend' + and 'if' not in compile_steps[0] + and names.index(compile_name) < names.index(EXECUTE_STEP), 'fixture_compile_gate_invalid') prepare_name = 'Prepare pinned provider fixture images within the whole-job budget' prepare = [step for step in steps if step.get('name') == prepare_name] require(len(prepare) == 1 and prepare[0].get('run') == 'python3 scripts/ci-test-shards.py prepare-fixtures' diff --git a/scripts/ci-test-shards-plan.json b/scripts/ci-test-shards-plan.json index bb6e89aa..bd902f1d 100644 --- a/scripts/ci-test-shards-plan.json +++ b/scripts/ci-test-shards-plan.json @@ -140,6 +140,34 @@ "sourceSha": null, "runId": null, "note": "Complete final refresh-presence-guard local journey Fact, 1/1 Passed and zero skips. Commit is not embedded in TRX. Separate seven-file source hash manifest SHA256 92cde32454dfb23f0421ce4e2d92ffe3c054281138cfe2d8a2ebe8c734960da3. Actual TestMethod row is 40205.7204 ms; local scale 2.271245, ceil/min1. Advisory, not a hosted wall bound or final-source CI qualification." + }, + "local-bff-lifecycle-20261024": { + "kind": "local_trx", + "artifactSha256": "e7e98d28870397f11c8e0962dafc0c7fa2473cd9f25d9113098ba292e1df7d78", + "sourceSha": null, + "runId": null, + "note": "Passing Step 5 local BFF lifecycle/browser/startup method durations. Local uncommitted source is not embedded in TRX. Advisory body sums exclude shared collection startup; hosted fit remains unqualified." + }, + "local-bff-lifecycle-r1-20261024": { + "kind": "local_trx", + "artifactSha256": "a8aed7b2684219ebec622a931c85640c8a59fcfa40407aabe9f0be95a1208fb5", + "sourceSha": null, + "runId": null, + "note": "Passing Step 5 R1 native/browser/early-disposal/nonzero-stop controls. Local source not embedded in TRX; advisory method body costs exclude collection startup and do not establish hosted wall fit." + }, + "local-bff-lifecycle-r2-20261024": { + "kind": "local_trx", + "artifactSha256": "eb29f34d707a87e2ddcdc9c0c269cd011c983cce4a79d8592ffaf9c95aa2ccf0", + "sourceSha": null, + "runId": null, + "note": "Passing Step 5 R2 six-case real BFF/browser and acquisition EOF/shutdown controls. Local source not embedded in TRX. Advisory method body costs exclude shared fixture startup; hosted fit remains unqualified." + }, + "p02b7-local-causal-retry": { + "kind": "local_trx", + "artifactSha256": "8f004ff7e3631179620ed3ca5cc8873402520ec147f537cca224eb2a6b3d242d", + "sourceSha": null, + "runId": null, + "note": "Three passing local causal-restart variants, including the additional genuine-handler post-write conflict/retry journey. Exact case durations summed per actual selector and scaled by the existing historical ordinary ratio 2.271245 (ceil ms); advisory only, not a wall bound. Immutable source is not embedded in the local TRX." } }, "weights": [ @@ -3380,8 +3408,8 @@ { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.Database.BackgroundJobPositiveSdkTests.Native_completed_cancellation_survives_restart_and_independent_failure_is_not_reclassified", - "milliseconds": 151947, - "source": "local-ordinary-scaled" + "milliseconds": 235758, + "source": "p02b7-local-causal-retry" }, { "assembly": "learnstack.tests.integration", @@ -8159,6 +8187,42 @@ "milliseconds": 29, "source": "local-ordinary-scaled" }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantBffSessionTests.Early_parent_disposal_joins_and_removes_its_private_native_tree", + "milliseconds": 7368, + "source": "local-bff-lifecycle-r2-20261024" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantBffSessionTests.Enabled_native_startup_and_shutdown_make_no_provider_or_API_connection", + "milliseconds": 6595, + "source": "local-bff-lifecycle-r2-20261024" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantBffSessionTests.Native_https_browser_stores_host_cookies_and_revalidates_logout_history_with_a_live_counter_control", + "milliseconds": 32915, + "source": "local-bff-lifecycle-r2-20261024" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantBffSessionTests.Nonzero_native_shutdown_is_rejected_instead_of_publishing_success", + "milliseconds": 6539, + "source": "local-bff-lifecycle-r2-20261024" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantBffSessionTests.Parent_EOF_during_held_build_joins_acquisition_and_removes_its_private_tree", + "milliseconds": 972, + "source": "local-bff-lifecycle-r2-20261024" + }, + { + "assembly": "learnstack.tests.integration", + "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantBffSessionTests.Reconciled_warm_realm_serves_both_native_hosts_with_real_refresh_logout_and_persisted_events", + "milliseconds": 79261, + "source": "local-bff-lifecycle-r2-20261024" + }, { "assembly": "learnstack.tests.integration", "method": "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmApiTests.A_genuine_Hub_issuer_token_is_rejected_without_changing_the_operator_or_Hub_client", @@ -8544,7 +8608,9 @@ "LearnStack.Tests.Integration.RateLimitForwardedForTests", "LearnStack.Tests.Integration.RateLimitingHttpTests", "LearnStack.Tests.Integration.SmokeTests", + "LearnStack.Tests.Integration.TenancyIdentity.TenantBffSessionTests", "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmApiTests", + "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmEventTests", "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmIssuanceTests", "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmProfileProtectionTests", "LearnStack.Tests.Integration.TenancyIdentity.TenantRealmReconciliationTests", diff --git a/scripts/development-realm-core.test.mjs b/scripts/development-realm-core.test.mjs index 6e886169..77f31a7f 100644 --- a/scripts/development-realm-core.test.mjs +++ b/scripts/development-realm-core.test.mjs @@ -73,6 +73,26 @@ test('the actual core applies one mutation, verifies full state, and makes an ex assert.equal(recording.requests.filter(request => request.path === '/admin/realms/learnstack/' && !request.method).length, 6); }); +test('a successful independent readback still requires an empty final fresh plan', async () => { + const state = provider(); + const recording = providerTransport(state); + let realmReads = 0; + const transport = async (url, options) => { + const response = await recording.transport(url, options); + if (url.pathname === '/admin/realms/learnstack/' && !options.method && ++realmReads === 3) { + assert.equal(options.stage, 'readback'); + const value = JSON.parse(response.bytes.toString('utf8')); + value.accessTokenLifespan = 1800; + return { ...response, bytes: Buffer.from(JSON.stringify(value)) }; + } + return response; + }; + await assert.rejects(reconcileRealm(memoryInput(transport)), refusal('readback', 'readback_conflict')); + assert.equal(realmReads, 3, 'planning and its independent verification completed before the final fresh-plan control'); + assert.deepEqual(state.writes, [], 'the remaining change is refused, never automatically applied'); + assert.deepEqual(await reconcileRealm(memoryInput(recording.transport)), { mutations: 0 }); +}); + for (const stage of ['apply', 'readback']) { test(`an unexpected ${stage} fault preserves its closed stage and a committed mutation resumes from fresh state`, async () => { const state = provider(); state.state.realm.accessTokenLifespan = 1800; @@ -442,7 +462,7 @@ test('an exact 512 KiB version document reaches planning only after the complete await assert.rejects(f.invoke(), refusal('plan', 'status')); assert.equal(f.paths.length, 4); assert.deepEqual(f.paths[2], ['GET', '/admin/serverinfo']); - assert.deepEqual(f.paths[3], ['GET', '/admin/realms/learnstack/users/profile']); + assert.deepEqual(f.paths[3], ['GET', '/admin/realms/learnstack/events/config']); }); @@ -611,9 +631,10 @@ test('hidden identity preparation is one fixed write, independently verified bef assert.deepEqual(recording.requests.filter(request => request.method && !request.form) .map(request => [request.stage, request.method, request.path]), [['prepare', 'PUT', '/admin/realms/learnstack/users/profile']]); - assert.deepEqual(recording.requests.slice(preparation + 1, preparation + 3) + assert.deepEqual(recording.requests.slice(preparation + 1, preparation + 4) .map(request => [request.stage, request.path]), [ ['prepare', '/admin/realms/learnstack/users/profile'], + ['plan', '/admin/realms/learnstack/events/config'], ['plan', '/admin/realms/learnstack/'], ]); assert.deepEqual(fixture.state.profile.attributes.slice(0, before.profile.attributes.length), before.profile.attributes); diff --git a/scripts/development-realm-errors.mjs b/scripts/development-realm-errors.mjs index 73bcdc84..ed549619 100644 --- a/scripts/development-realm-errors.mjs +++ b/scripts/development-realm-errors.mjs @@ -6,7 +6,7 @@ const reasons = new Set([ 'configuration', 'identity_conflict', 'mapper_conflict', 'secret_conflict', 'unsupported', 'response', 'status', 'redirect', 'deadline', 'cancelled', 'transport', 'lock_busy', 'body_limit', 'readback_conflict', 'cleanup', - 'missing_web_secret', 'private_file', + 'missing_web_secret', 'private_file', 'event_policy_conflict', ]); /** Neither upstream objects nor arbitrary caller text can become a diagnostic. */ @@ -39,6 +39,8 @@ export function realmFailureNotice(error) { message += '\nrealm: add KEYCLOAK_WEB_CLIENT_SECRET explicitly to the selected private file; see infra/keycloak/README.md'; else if (error.reason === 'secret_conflict') message += '\nrealm: retain the confidential client secret in the selected private file; see infra/keycloak/README.md#secret-continuity-and-recovery'; + else if (error.reason === 'event_policy_conflict') + message += '\nrealm: resolve the existing event retention policy explicitly without deleting history; see infra/keycloak/README.md#development-authentication-events'; else if (error.reason === 'lock_busy') message += '\nrealm: verify the previous owner has stopped before removing its private lock; see infra/keycloak/README.md#lock-recovery'; if (error.preparationDispatched === true) diff --git a/scripts/development-realm-events.mjs b/scripts/development-realm-events.mjs new file mode 100644 index 00000000..3b180e93 --- /dev/null +++ b/scripts/development-realm-events.mjs @@ -0,0 +1,67 @@ +import { isDeepStrictEqual } from 'node:util'; +import { refuse } from './development-realm-errors.mjs'; + +export const developmentEventRetentionSeconds = 604800; +export const requiredDevelopmentEvents = Object.freeze([ + 'LOGIN', 'LOGIN_ERROR', 'LOGOUT', 'LOGOUT_ERROR', 'CODE_TO_TOKEN', + 'CODE_TO_TOKEN_ERROR', 'REFRESH_TOKEN', 'REFRESH_TOKEN_ERROR', +]); + +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const clone = value => structuredClone(value); + +function strings(value, stage) { + if (!Array.isArray(value) || value.length > 256 + || value.some(item => typeof item !== 'string' || !/^[A-Za-z0-9_.-]{1,128}$/.test(item)) + || new Set(value).size !== value.length) refuse(stage, 'event_policy_conflict'); + return [...value].sort(); +} + +function normalize(value, stage) { + // Pinned ModelToRepresentation omits eventsExpiration when its stored value + // is zero. This is the provider's default/indefinite state, not a new policy. + const retention = value?.eventsExpiration == null ? 0 : value.eventsExpiration; + if (!object(value) || typeof value.eventsEnabled !== 'boolean' + || !Number.isSafeInteger(retention) || retention < 0) + refuse(stage, 'event_policy_conflict'); + const result = clone(value); + result.eventsExpiration = retention; + result.enabledEventTypes = strings(value.enabledEventTypes, stage); + // Pinned GET events/config expands the implicit default-saved selection. + // Never interpret an unavailable/empty effective selection as permission to + // replace the provider's other events with only our eight types. + if (!result.enabledEventTypes.length) refuse(stage, 'event_policy_conflict'); + if (value.eventsListeners !== undefined) result.eventsListeners = strings(value.eventsListeners, stage); + for (const field of ['adminEventsEnabled', 'adminEventsDetailsEnabled']) + if (value[field] !== undefined && typeof value[field] !== 'boolean') + refuse(stage, 'event_policy_conflict'); + if (Buffer.byteLength(JSON.stringify(result)) > 256 * 1024) refuse(stage, 'body_limit'); + return result; +} + +/** Read-only qualification, including before ADR-0068's metadata preparation. */ +export async function planRealmEvents(admin, stage = 'plan') { + const before = normalize(await admin.get('events/config'), stage); + let retention = before.eventsExpiration; + if (retention === 0 && before.eventsEnabled === false) { + // One bounded row is enough to disprove empty history. Do not enumerate, + // discard or return provider event details to diagnostics/artifacts. + const history = await admin.get('events?first=0&max=1'); + if (!Array.isArray(history) || history.length !== 0) refuse(stage, 'event_policy_conflict'); + retention = developmentEventRetentionSeconds; + } else if (retention < developmentEventRetentionSeconds) { + refuse(stage, 'event_policy_conflict'); + } + const expected = { ...before, eventsEnabled: true, eventsExpiration: retention, + enabledEventTypes: [...new Set([...before.enabledEventTypes, ...requiredDevelopmentEvents])].sort() }; + if (expected.enabledEventTypes.length > 256 + || Buffer.byteLength(JSON.stringify(expected)) > 256 * 1024) refuse(stage, 'body_limit'); + return Object.freeze({ + changed: !isDeepStrictEqual(before, expected), + body: clone(expected), + async verify() { + const after = normalize(await admin.get('events/config'), 'readback'); + if (!isDeepStrictEqual(after, expected)) refuse('readback', 'readback_conflict'); + }, + }); +} diff --git a/scripts/development-realm-events.test.mjs b/scripts/development-realm-events.test.mjs new file mode 100644 index 00000000..8955dc21 --- /dev/null +++ b/scripts/development-realm-events.test.mjs @@ -0,0 +1,276 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { planRealmEvents } from './development-realm-events.mjs'; +import { buildRealmPlan, buildRealmPreparation } from './development-realm-plan.mjs'; +import { desired, manifest, secret, provider } from './development-realm-test-fixture.mjs'; + +const plan = fixture => buildRealmPlan({ admin: fixture.admin, desired, manifest, webSecret: secret }); +const refused = error => error.stage === 'plan' && error.reason === 'event_policy_conflict'; +const requiredTypes = [ + 'LOGIN', 'LOGIN_ERROR', 'LOGOUT', 'LOGOUT_ERROR', 'CODE_TO_TOKEN', + 'CODE_TO_TOKEN_ERROR', 'REFRESH_TOKEN', 'REFRESH_TOKEN_ERROR', +]; + +for (const [name, mutate] of [ + ['disabled storage', value => { value.eventsEnabled = false; }], + ['missing storage flag', value => { delete value.eventsEnabled; }], + ['wrong retention', value => { value.eventsExpiration = 604801; }], + ['missing retention', value => { delete value.eventsExpiration; }], + ['nonarray selection', value => { value.enabledEventTypes = null; }], + ...requiredTypes.map(type => [`missing ${type}`, value => { + value.enabledEventTypes = value.enabledEventTypes.filter(current => current !== type); + }]), +]) { + test(`the desired export rejects ${name} before ordinary or preparation reads`, async () => { + for (const build of [buildRealmPlan, buildRealmPreparation]) { + const fixture = provider(); + const declaration = structuredClone(desired); + mutate(declaration); + await assert.rejects(build({ admin: fixture.admin, desired: declaration, manifest, webSecret: secret }), + error => error.stage === 'plan' && error.reason === 'unsupported'); + assert.deepEqual(fixture.reads, []); + assert.deepEqual(fixture.writes, []); + } + }); +} + +test('a converged policy has no write and independently verifies listener/admin preservation', async () => { + const fixture = provider(); + const before = structuredClone(fixture.state.eventsConfig); + const result = await planRealmEvents(fixture.admin); + assert.equal(result.changed, false); + await result.verify(); + assert.deepEqual(fixture.writes, []); + assert.deepEqual(fixture.state.eventsConfig, before); + assert.deepEqual(fixture.reads, ['events/config', 'events/config']); +}); + +test('disabled default storage needs one bounded empty-history read and retains effective prior types', async () => { + const fixture = provider(); + Object.assign(fixture.state.eventsConfig, { eventsEnabled: false, eventsExpiration: 0, + enabledEventTypes: ['UPDATE_CREDENTIAL', 'CUSTOM_REQUIRED_ACTION'], + eventsListeners: ['jboss-logging', 'existing-listener'], adminEventsEnabled: true, adminEventsDetailsEnabled: true }); + const result = await plan(fixture); + assert.deepEqual(fixture.writes, []); + assert.equal(result.actions.length, 1); + for (const action of result.actions) await action.apply(); + await result.verify(); + assert.equal(fixture.state.eventsConfig.eventsExpiration, 604800); + assert.equal(fixture.state.eventsConfig.eventsEnabled, true); + assert.deepEqual(fixture.state.eventsConfig.enabledEventTypes, [ + 'LOGIN', 'LOGIN_ERROR', 'LOGOUT', 'LOGOUT_ERROR', 'CODE_TO_TOKEN', + 'CODE_TO_TOKEN_ERROR', 'REFRESH_TOKEN', 'REFRESH_TOKEN_ERROR', + 'UPDATE_CREDENTIAL', 'CUSTOM_REQUIRED_ACTION', + ].sort()); + assert.deepEqual(fixture.state.eventsConfig.eventsListeners, ['existing-listener', 'jboss-logging']); + assert.equal(fixture.state.eventsConfig.adminEventsEnabled, true); + assert.equal(fixture.state.eventsConfig.adminEventsDetailsEnabled, true); + assert.ok(fixture.reads.includes('events?first=0&max=1')); + assert.equal((await plan(fixture)).actions.length, 0); +}); + +for (const enabled of [true, false]) { + test(`longer finite retention survives convergence with storage initially ${enabled}`, async () => { + const fixture = provider(); + Object.assign(fixture.state.eventsConfig, { eventsEnabled: enabled, eventsExpiration: 1209600 }); + const change = await plan(fixture); + for (const action of change.actions) await action.apply(); + await change.verify(); + assert.equal(fixture.state.eventsConfig.eventsExpiration, 1209600); + assert.ok(!fixture.reads.some(path => path.startsWith('events?'))); + }); +} + +for (const [name, patch] of [ + ['enabled indefinite retention', { eventsExpiration: 0 }], + ['enabled shorter retention', { eventsExpiration: 604799 }], + ['disabled customized shorter retention', { eventsEnabled: false, eventsExpiration: 10 }], + ['negative retention', { eventsExpiration: -1 }], + ['fractional retention', { eventsExpiration: 604800.5 }], + ['missing retention', { eventsExpiration: undefined }], + ['nonboolean enablement', { eventsEnabled: 'false' }], + ['unknown effective selection', { enabledEventTypes: [] }], + ['duplicate types', { enabledEventTypes: ['LOGIN', 'LOGIN'] }], + ['invalid type', { enabledEventTypes: ['LOGIN', 'arbitrary/private?value'] }], + ['invalid listeners', { eventsListeners: 'jboss-logging' }], + ['invalid admin policy', { adminEventsEnabled: 'false' }], +]) { + test(`${name} refuses before identity metadata preparation or any write`, async () => { + const fixture = provider(); + Object.assign(fixture.state.eventsConfig, patch); + fixture.state.profile.attributes = fixture.state.profile.attributes.filter(entry => !['user_id', 'tenant_id'].includes(entry.name)); + await assert.rejects(buildRealmPreparation({ admin: fixture.admin, desired, manifest, webSecret: secret }), refused); + assert.deepEqual(fixture.writes, []); + assert.deepEqual(fixture.reads, ['events/config']); + }); +} + +for (const history of [[{ type: 'LOGIN', userId: 'synthetic-private-event' }], null]) { + test(`default retention adoption rejects ${history === null ? 'malformed' : 'nonempty'} history without returning details`, async () => { + const fixture = provider(); + Object.assign(fixture.state.eventsConfig, { eventsEnabled: false, eventsExpiration: 0 }); + const get = fixture.admin.get; + fixture.admin.get = path => path.startsWith('events?') ? history : get(path); + await assert.rejects(plan(fixture), error => refused(error) && !error.stack.includes('synthetic-private-event')); + assert.deepEqual(fixture.writes, []); + }); +} + +for (const [name, mutate] of [ + ['each missing required event', value => value], + ['lost listener', value => { value.eventsListeners = []; }], + ['changed admin policy', value => { value.adminEventsEnabled = !value.adminEventsEnabled; }], + ['changed retention', value => { value.eventsExpiration = 1209600; }], + ['disabled storage', value => { value.eventsEnabled = false; }], +]) { + test(`independent policy readback rejects ${name}`, async () => { + const fixture = provider(); + const change = await plan(fixture); + const before = structuredClone(fixture.state.eventsConfig); + for (const removed of name === 'each missing required event' ? [ + 'LOGIN', 'LOGIN_ERROR', 'LOGOUT', 'LOGOUT_ERROR', 'CODE_TO_TOKEN', + 'CODE_TO_TOKEN_ERROR', 'REFRESH_TOKEN', 'REFRESH_TOKEN_ERROR', + ] : [undefined]) { + fixture.state.eventsConfig = structuredClone(before); + if (removed) fixture.state.eventsConfig.enabledEventTypes = before.enabledEventTypes.filter(type => type !== removed); + else mutate(fixture.state.eventsConfig); + await assert.rejects(change.verify(), error => error.stage === 'readback' && error.reason === 'readback_conflict'); + } + assert.deepEqual(fixture.writes, []); + }); +} + +for (const count of [256, 257]) { + test(`effective selection has an independent ${count}-type input boundary`, async () => { + const fixture = provider(); + fixture.state.eventsConfig.enabledEventTypes = [ + ...requiredTypes, + ...Array.from({ length: count - requiredTypes.length }, (_, index) => `CUSTOM_${index}`), + ]; + if (count === 256) { + const change = await planRealmEvents(fixture.admin); + assert.equal(change.changed, false); + assert.equal(change.body.enabledEventTypes.length, 256); + await change.verify(); + } else await assert.rejects(planRealmEvents(fixture.admin), refused); + assert.deepEqual(fixture.writes, []); + }); +} + +for (const count of [255, 256]) { + test(`adding the missing required type respects the ${count}-type post-union boundary`, async () => { + const fixture = provider(); + fixture.state.eventsConfig.enabledEventTypes = [ + ...requiredTypes.slice(1), + ...Array.from({ length: count - requiredTypes.length + 1 }, (_, index) => `CUSTOM_${index}`), + ]; + if (count === 255) { + const change = await planRealmEvents(fixture.admin); + assert.equal(change.body.enabledEventTypes.length, 256); + assert.ok(change.body.enabledEventTypes.includes('LOGIN')); + } else await assert.rejects(planRealmEvents(fixture.admin), + error => error.stage === 'plan' && error.reason === 'body_limit'); + assert.deepEqual(fixture.writes, []); + }); +} + +for (const overflow of [false, true]) { + test(`the normalized event document has its own exact decoded-byte boundary: overflow=${overflow}`, async () => { + const fixture = provider(); + fixture.state.eventsConfig.padding = ''; + const bytes = value => Buffer.byteLength(JSON.stringify(value)); + fixture.state.eventsConfig.padding = 'x'.repeat(256 * 1024 - bytes(fixture.state.eventsConfig) + Number(overflow)); + if (overflow) await assert.rejects(planRealmEvents(fixture.admin), + error => error.stage === 'plan' && error.reason === 'body_limit'); + else { + const change = await planRealmEvents(fixture.admin); + assert.equal(bytes(change.body), 256 * 1024); + await change.verify(); + } + assert.deepEqual(fixture.writes, []); + }); +} + +test('event readback independently rejects decoded-byte overflow before comparing state', async () => { + const fixture = provider(); + fixture.state.eventsConfig.padding = ''; + fixture.state.eventsConfig.padding = 'x'.repeat(256 * 1024 - Buffer.byteLength(JSON.stringify(fixture.state.eventsConfig))); + const change = await planRealmEvents(fixture.admin); + await change.verify(); + fixture.state.eventsConfig.padding += 'x'; + await assert.rejects(change.verify(), error => error.stage === 'readback' && error.reason === 'body_limit'); + assert.deepEqual(fixture.writes, []); +}); + +for (const overflow of [false, true]) { + test(`event expansion independently enforces its own exact decoded-byte boundary: overflow=${overflow}`, async () => { + const fixture = provider(); + Object.assign(fixture.state.eventsConfig, { enabledEventTypes: ['LOGIN'], padding: '' }); + const projected = { ...fixture.state.eventsConfig, enabledEventTypes: [...requiredTypes].sort() }; + const bytes = value => Buffer.byteLength(JSON.stringify(value)); + fixture.state.eventsConfig.padding = 'x'.repeat(256 * 1024 - bytes(projected) + Number(overflow)); + assert.ok(bytes(fixture.state.eventsConfig) < 256 * 1024); + if (overflow) await assert.rejects(planRealmEvents(fixture.admin), + error => error.stage === 'plan' && error.reason === 'body_limit'); + else { + const change = await planRealmEvents(fixture.admin); + assert.equal(bytes(change.body), 256 * 1024); + assert.deepEqual(change.body.enabledEventTypes, [...requiredTypes].sort()); + } + assert.deepEqual(fixture.writes, []); + }); +} + +test('effective type/listener ordering does not manufacture mutations', async () => { + const fixture = provider(); + fixture.state.eventsConfig.enabledEventTypes.reverse(); + const result = await planRealmEvents(fixture.admin); + assert.equal(result.changed, false); + fixture.state.eventsConfig.enabledEventTypes.reverse(); + await result.verify(); +}); + +test('the pinned omitted-zero representation qualifies only a disabled empty store', async () => { + const fixture = provider(); + fixture.state.eventsConfig.eventsEnabled = false; + delete fixture.state.eventsConfig.eventsExpiration; + const change = await plan(fixture); + for (const action of change.actions) await action.apply(); + await change.verify(); + assert.equal(fixture.state.eventsConfig.eventsExpiration, 604800); + assert.ok(fixture.reads.includes('events?first=0&max=1')); +}); + +for (const preparing of [false, true]) { + for (const overflow of [false, true]) { + test(`${preparing ? 'preparation' : 'ordinary planning'} budgets event expansion at the exact response boundary: overflow=${overflow}`, async () => { + const fixture = provider(); + Object.assign(fixture.state.eventsConfig, { eventsEnabled: false, eventsExpiration: 0, enabledEventTypes: ['LOGIN'] }); + Object.assign(fixture.state.realm, { eventsEnabled: false, eventsExpiration: 0, enabledEventTypes: ['LOGIN'], padding: '' }); + if (preparing) fixture.state.profile.attributes = fixture.state.profile.attributes + .filter(entry => !['user_id', 'tenant_id', 'organization_id'].includes(entry.name)); + const events = await planRealmEvents(fixture.admin); + const projected = { ...fixture.state.realm, accessTokenLifespan: 900, + eventsEnabled: events.body.eventsEnabled, eventsExpiration: events.body.eventsExpiration, + enabledEventTypes: events.body.enabledEventTypes }; + const bytes = value => Buffer.byteLength(JSON.stringify(value)); + fixture.state.realm.padding = 'x'.repeat(256 * 1024 - bytes(projected) + Number(overflow)); + assert.ok(bytes(fixture.state.realm) <= 256 * 1024); + const attempt = () => preparing + ? buildRealmPreparation({ admin: fixture.admin, desired, manifest, webSecret: secret }) : plan(fixture); + if (overflow) { + await assert.rejects(attempt(), error => error.stage === 'plan' && error.reason === 'body_limit'); + } else { + const result = await attempt(); + assert.ok(result); + if (!preparing) { + assert.equal(result.actions.length, 1); + for (const action of result.actions) await action.apply(); + await result.verify(); + assert.equal(bytes(fixture.state.realm), 256 * 1024); + } + } + if (preparing || overflow) assert.deepEqual(fixture.writes, []); + }); + } +} diff --git a/scripts/development-realm-plan.mjs b/scripts/development-realm-plan.mjs index 5e5ef710..dc4d2afe 100644 --- a/scripts/development-realm-plan.mjs +++ b/scripts/development-realm-plan.mjs @@ -3,6 +3,7 @@ import { refuse } from './development-realm-errors.mjs'; import { identityClaims, mergeOwnedMappers, validateEffectiveMappers } from './development-realm-mappers.mjs'; import { readRealmGroups, readUserGroupIds } from './development-realm-groups.mjs'; import { exactCreatedAccountAccess, planAccountSelfService } from './development-realm-account.mjs'; +import { planRealmEvents, requiredDevelopmentEvents, developmentEventRetentionSeconds } from './development-realm-events.mjs'; const managedScope = 'learnstack-tenant-api'; const namedClients = ['learnstack-web', 'learnstack-api']; @@ -58,8 +59,10 @@ function preflightResponse(value, reserve = 0) { if (jsonBytes(value) + reserve > bodyLimit) refuse('plan', 'body_limit'); } -function validateProjectedResponses({ before, profile, scopes, mappers, clients, assignments, users, desired, manifest, webSecret, identityReserve = new Map() }) { - preflightResponse({ ...before.realm, accessTokenLifespan: 900 }); +function validateProjectedResponses({ before, profile, scopes, mappers, clients, assignments, users, desired, manifest, webSecret, events, identityReserve = new Map() }) { + preflightResponse({ ...before.realm, accessTokenLifespan: 900, + ...Object.fromEntries(['eventsEnabled', 'eventsExpiration', 'enabledEventTypes'] + .map(key => [key, events.body[key]])) }); preflightResponse(profile); // These UUID placeholders are used only for byte accounting. Provider IDs // in the plan/apply/readback state remain exclusively authoritative reads. @@ -211,6 +214,9 @@ function identityAttributes(user, declaration, stage) { function validateInputs(desired, manifest, webSecret) { if (!object(desired) || desired.realm !== 'learnstack' || desired.accessTokenLifespan !== 900 + || desired.eventsEnabled !== true || desired.eventsExpiration !== developmentEventRetentionSeconds + || !Array.isArray(desired.enabledEventTypes) + || !requiredDevelopmentEvents.every(type => desired.enabledEventTypes.includes(type)) || !Array.isArray(desired.users) || desired.users.length !== 4 || !object(manifest) || !Array.isArray(manifest.users) || manifest.users.length !== 4 || typeof webSecret !== 'string' || !webSecret) refuse('plan', 'unsupported'); @@ -430,8 +436,9 @@ function exactCreatedAccess(roles, credentials, roleId, account) { /** A complete read-only preflight. No ordinary mutation escapes before identity qualification. */ export async function buildRealmPlan({ admin, desired, manifest, webSecret }) { validateInputs(desired, manifest, webSecret); + const events = await planRealmEvents(admin); const before = await snapshot(admin, desired, manifest, 'plan'); - return assemblePlan({ admin, desired, manifest, webSecret, before }); + return assemblePlan({ admin, desired, manifest, webSecret, before, events }); } // Only this internal path defers checks needing newly visible protected values. @@ -439,6 +446,7 @@ export async function buildRealmPlan({ admin, desired, manifest, webSecret }) { // never escape: the caller receives only a profile and its exact verification. export async function buildRealmPreparation({ admin, desired, manifest, webSecret }) { validateInputs(desired, manifest, webSecret); + const events = await planRealmEvents(admin); const current = requireObject(await admin.get('users/profile'), 'prepare'); const attributes = unique(array(current.attributes, 'prepare'), 'name', 'prepare'); if (current.groups !== undefined) unique(array(current.groups, 'prepare'), 'name', 'prepare'); @@ -460,13 +468,13 @@ export async function buildRealmPreparation({ admin, desired, manifest, webSecre preflightBody(expected); preflightResponse(expected); const before = await snapshot(admin, desired, manifest, 'plan', { preparationProfile: current, preparing: true }); - await assemblePlan({ admin, desired, manifest, webSecret, before, preparationOnly: true }); + await assemblePlan({ admin, desired, manifest, webSecret, before, events, preparationOnly: true }); return Object.freeze({ profile: copy(expected), verify: actual => { if (!equal(actual, expected)) refuse('prepare', 'readback_conflict'); } }); } -async function assemblePlan({ admin, desired, manifest, webSecret, before, preparationOnly = false }) { +async function assemblePlan({ admin, desired, manifest, webSecret, before, events, preparationOnly = false }) { const actions = []; const add = (label, apply, body) => { preflightBody(body); @@ -533,6 +541,7 @@ async function assemblePlan({ admin, desired, manifest, webSecret, before, prepa const profile = mergedProfile(before.profile, desired); if (!equal(profile, before.profile)) add('update_profile', () => admin.put('users/profile', copy(profile)), profile); if (before.realm.accessTokenLifespan !== 900) add('update_lifespan', () => admin.put('', { accessTokenLifespan: 900 }), { accessTokenLifespan: 900 }); + if (events.changed) add('update_events', () => admin.put('events/config', copy(events.body)), events.body); const expectedClients = new Map(); const expectedAssignments = new Map(); for (const name of namedClients) { @@ -683,17 +692,19 @@ async function assemblePlan({ admin, desired, manifest, webSecret, before, prepa if (before.users.length + manifest.users.filter(entry => !before.candidates.has(entry.username)).length >= pageSize * pageLimit) refuse('plan', 'unsupported'); validateProjectedResponses({ before, profile, scopes: expectedScopes, mappers: expectedMappers, - clients: expectedClients, assignments: expectedAssignments, users: expectedUsers, desired, manifest, webSecret, identityReserve }); + clients: expectedClients, assignments: expectedAssignments, users: expectedUsers, desired, manifest, webSecret, events, identityReserve }); if (preparationOnly) return; return { actions, verify: async () => { + await events.verify(); const after = await snapshot(admin, desired, manifest, 'readback'); await account.verify(after.candidates); const afterGroups = await readRealmGroups(admin, after.users, 'readback'); const check = condition => { if (!condition) refuse('readback', 'readback_conflict'); }; check(after.realm.accessTokenLifespan === 900 - && equal(omit(after.realm, ['accessTokenLifespan']), omit(before.realm, ['accessTokenLifespan']))); + && equal(omit(after.realm, ['accessTokenLifespan', 'eventsEnabled', 'eventsExpiration', 'enabledEventTypes']), + omit(before.realm, ['accessTokenLifespan', 'eventsEnabled', 'eventsExpiration', 'enabledEventTypes']))); check(equal(after.profile, profile)); check(equal(afterGroups.groups, beforeGroups.groups)); for (const [userId, assigned] of beforeGroups.memberships) diff --git a/scripts/development-realm-plan.test.mjs b/scripts/development-realm-plan.test.mjs index af5915c0..9e3c0057 100644 --- a/scripts/development-realm-plan.test.mjs +++ b/scripts/development-realm-plan.test.mjs @@ -996,7 +996,8 @@ test('hidden or synthesized protected values refuse before writes, including the await assert.rejects(plan(fixture), error => error.stage === 'plan' && error.reason === 'identity_conflict', variant); assert.equal(fixture.writes.length, 0); assert.deepEqual(fixture.state, original); - assert.deepEqual(fixture.reads, ['', 'users/profile'], 'profile qualification precedes projected account reads'); + assert.deepEqual(fixture.reads, ['events/config', '', 'users/profile'], + 'event-policy and profile qualification precede projected account reads'); } }); diff --git a/scripts/development-realm-preparation.test.mjs b/scripts/development-realm-preparation.test.mjs index 8adef5b0..7be3bd81 100644 --- a/scripts/development-realm-preparation.test.mjs +++ b/scripts/development-realm-preparation.test.mjs @@ -38,7 +38,7 @@ test('the exact missing-definition and unmanaged-visibility trigger keeps all al const preparation = await probe(fixture); const expected = missing.length > 0 && !['ENABLED', 'ADMIN_EDIT', 'ADMIN_VIEW'].includes(policy); assert.equal(Boolean(preparation), expected, `${missing.length}/${policy}`); - if (!expected) assert.deepEqual(fixture.reads, ['users/profile']); + if (!expected) assert.deepEqual(fixture.reads, ['events/config', 'users/profile']); assert.deepEqual(fixture.writes, []); } }); @@ -73,7 +73,7 @@ test('hidden defaulted selected or unqualified existing protected definitions ca if (variant === 'selector') attribute.selector = {}; if (variant === 'unknown_field') attribute.unsupported = false; await assert.rejects(probe(fixture), refusal('prepare', 'identity_conflict')); - assert.deepEqual(fixture.reads, ['users/profile']); + assert.deepEqual(fixture.reads, ['events/config', 'users/profile']); assert.deepEqual(fixture.writes, []); } }); diff --git a/scripts/development-realm-test-fixture.mjs b/scripts/development-realm-test-fixture.mjs index b0ddd2fa..db2e1675 100644 --- a/scripts/development-realm-test-fixture.mjs +++ b/scripts/development-realm-test-fixture.mjs @@ -23,6 +23,10 @@ export function provider() { const id = () => `provider-${serial += 1}`; const state = { realm: { realm: desired.realm, enabled: true, id: id(), accessTokenLifespan: 900, displayName: 'retained realm' }, + eventHistory: [], + eventsConfig: { eventsEnabled: desired.eventsEnabled, eventsExpiration: desired.eventsExpiration, + enabledEventTypes: clone(desired.enabledEventTypes), eventsListeners: ['jboss-logging'], + adminEventsEnabled: false, adminEventsDetailsEnabled: false }, profile: JSON.parse(desired.components['org.keycloak.userprofile.UserProfileProvider'][0].config['kc.user.profile.config'][0]), scopes: desired.clientScopes.map(scope => ({ ...clone(scope), id: id(), protocolMappers: (scope.protocolMappers ?? []).map(mapper => ({ ...storedMapper(mapper), id: id() })) })), @@ -99,6 +103,8 @@ export function provider() { const parts = url.pathname.slice(1).split('/').map(decodeURIComponent); let value; if (path === '') value = state.realm; + else if (path === 'events/config') value = state.eventsConfig; + else if (path === 'events?first=0&max=1') value = state.eventHistory.slice(0, 1); else if (path === 'users/profile') value = state.profile; else if (path === 'groups/count') value = { count: state.groups.filter(group => group.type !== 'organization').length }; else if (parts[0] === 'groups') { @@ -176,6 +182,11 @@ export function provider() { writes.push({ method: 'PUT', path, body: clone(body) }); const parts = path.split('/').map(decodeURIComponent); if (path === '') Object.assign(state.realm, clone(body)); + else if (path === 'events/config') { + state.eventsConfig = clone(body); + for (const key of ['eventsEnabled', 'eventsExpiration', 'enabledEventTypes']) + state.realm[key] = clone(body[key]); + } else if (path === 'users/profile') state.profile = clone(body); else if (parts[0] === 'users') { const user = state.users.find(entry => entry.id === parts[1]); diff --git a/scripts/reconcile-development-realm.test.mjs b/scripts/reconcile-development-realm.test.mjs index ed89b405..5beafd22 100644 --- a/scripts/reconcile-development-realm.test.mjs +++ b/scripts/reconcile-development-realm.test.mjs @@ -110,6 +110,7 @@ async function ownedEntryFixture(t, scenario) { await mkdir(join(root, directory), { recursive: true }); for (const name of ['compose', 'demo-process', 'demo-database', 'reconcile-development-realm', 'development-realm-owner', 'development-realm-errors', 'development-realm-core', 'development-realm-plan', + 'development-realm-events', 'development-realm-account', 'development-realm-mappers', 'development-realm-groups', 'development-realm-test-fixture', 'development-realm-stock-console', 'development-realm-stock-console-test-fixture']) await copyFile(new URL(`./${name}.mjs`, import.meta.url), join(root, `scripts/${name}.mjs`)); diff --git a/scripts/run-realm-node-tests.py b/scripts/run-realm-node-tests.py index f9f9773b..b39b99d6 100644 --- a/scripts/run-realm-node-tests.py +++ b/scripts/run-realm-node-tests.py @@ -12,7 +12,8 @@ # Per-file expectations also fix the total; an empty or deleted test file cannot be # replaced by a passing named suite or by extra tests in another file. EXPECTED = { - 'development-realm-core.test.mjs': 62, + 'development-realm-events.test.mjs': 51, + 'development-realm-core.test.mjs': 63, 'development-realm-http.test.mjs': 42, 'development-realm-owner.test.mjs': 56, 'development-realm-plan.test.mjs': 106, diff --git a/scripts/test-ci-native-tests.py b/scripts/test-ci-native-tests.py index f016b877..6c3f7064 100644 --- a/scripts/test-ci-native-tests.py +++ b/scripts/test-ci-native-tests.py @@ -7,6 +7,7 @@ """ from collections import Counter import contextlib +import errno import hashlib import importlib.util import io @@ -14,10 +15,13 @@ import os from pathlib import Path import re +import signal +import stat import subprocess import sys import tempfile import time +import types import unittest from unittest import mock import xml.etree.ElementTree as ET @@ -30,6 +34,7 @@ base = pathlib.Path(__file__).parent config_path = base / 'fixture.json' config = json.loads(config_path.read_text()) +os.write(2, config.get('stderr', '').encode()) name = pathlib.Path(sys.argv[0]).name with (base / 'calls.jsonl').open('a') as stream: stream.write(json.dumps({'name': name, 'argv': sys.argv[1:], @@ -207,6 +212,33 @@ def execution_complete(self, cases, *, tail=()): 'Stats': {'Passed': len(cases)}}}, 'LastRunTests': {'NewTestResults': [self.execution_result(case) for case in tail]}}) + def selector_fixture(self): + spec = importlib.util.spec_from_file_location('ci_workload_fixtures', ROOT / 'scripts/test-ci-test-shards.py') + fixtures = importlib.util.module_from_spec(spec); spec.loader.exec_module(fixtures) + fixture = fixtures.ShardControls(); fixture.plan = self.owner.shard_owner().load_plan() + return fixture + + def run_workload(self, manifest, status, name): + private, output = self.root / ('private-' + name), self.root / ('output-' + name) + private.mkdir(); output.mkdir(); (output / 'results').mkdir() + plan = self.owner.shard_owner().load_plan() + expected = self.owner.shard_owner().assign(manifest, plan, '1')['owners']['ordinary-0'] + def completed(argv, deadline, work, consume, progress): + self.trx(private / 'trx', expected, raw_canary=True) + progress.line(self.execution_complete(expected, tail=expected).encode()) + return status + with mock.patch.object(self.owner, 'cgroup_path', return_value='/owned/work'), \ + mock.patch.object(self.owner, 'runtime_toolchain', return_value={}), \ + mock.patch.object(self.owner, 'discover_selectors', return_value=manifest), \ + mock.patch.object(self.owner, 'selected_discovery', return_value=expected), \ + mock.patch.object(self.owner, 'test_projects', return_value=[('fixture', 'LearnStack.Tests.Integration')]), \ + mock.patch.object(self.owner, 'adapter_evidence', return_value={}), \ + mock.patch.object(self.owner, 'shard_dotnet', side_effect=completed), \ + contextlib.redirect_stdout(io.StringIO()): + self.owner.workload('ordinary-0', {'runAttempt': '1'}, private, output, + time.monotonic() + 3, '/owned/work') + return private, output, expected + def trx(self, output, cases, *, outcome='Passed', summary='Completed', raw_canary=False): output.mkdir(parents=True, exist_ok=True) tag = lambda name: '{' + self.owner.NS['t'] + '}' + name @@ -273,6 +305,12 @@ def test_identity_accepts_only_absent_service_shapes_for_both_execution_jobs(sel with self.subTest(mode=mode, services=services): os.environ['LEARNSTACK_NATIVE_JOB_SERVICES'] = services self.assertEqual('42', self.owner.identity(mode)['runId']) + for uid, gid, euid in ((0, 1000, 0), (1000, 0, 1000), (1000, 1000, 0)): + with self.subTest(mode=mode, uid=uid, gid=gid, euid=euid), \ + mock.patch.object(self.owner.os, 'getuid', return_value=uid), \ + mock.patch.object(self.owner.os, 'getgid', return_value=gid), \ + mock.patch.object(self.owner.os, 'geteuid', return_value=euid): + self.refuses('nonroot_required', self.owner.identity, mode) calls_before_dirty = len(self.calls()) for services in ('[]', '{"db":{}}', '"foreign"', 'broken', None): with self.subTest(mode=mode, services=services): @@ -326,8 +364,21 @@ def load(name, path): self.assertEqual([], fixture.commands('sudo')) def test_actual_subprocess_failure_and_timeout_disclose_no_provider_text(self): - self.configure(status={'docker': 7}, docker='PRIVATE-CANARY') - self.refuses('failed', self.owner.command, [self.owner.DOCKER, 'ps'], time.monotonic() + 2, 'failed') + self.configure(status={'docker': 7}, docker='PRIVATE-CANARY', stderr='unauthorized PRIVATE-CANARY') + actual_run = subprocess.run + with mock.patch.dict(os.environ, {'PRIVATE_CANARY': 'PRIVATE-CANARY', 'DOCKER_CONFIG': 'PRIVATE-CANARY'}), \ + mock.patch.object(self.owner.subprocess, 'run', wraps=actual_run) as run, \ + mock.patch.object(self.owner.tempfile, 'TemporaryFile') as spool: + self.refuses('failed', self.owner.command, [self.owner.DOCKER, 'ps'], time.monotonic() + 2, 'failed') + spool.assert_not_called() + self.assertEqual(subprocess.DEVNULL, run.call_args.kwargs['stderr']) + self.assertEqual(subprocess.DEVNULL, run.call_args.kwargs['stdin']) + self.assertEqual(subprocess.PIPE, run.call_args.kwargs['stdout']) + expected_environment = {'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8', + 'HOME': os.environ.get('HOME', ''), 'GIT_CONFIG_NOSYSTEM': '1', 'GIT_CONFIG_GLOBAL': '/dev/null'} + self.assertEqual(expected_environment, run.call_args.kwargs['env']) + self.assertFalse(run.call_args.kwargs['check']) + self.assertIsNone(self.calls()[-1]['secret']) self.configure(status={}, delay=0.3) self.refuses('timed', self.owner.command, [self.owner.DOTNET], time.monotonic() + 0.05, 'timed') # subprocess.run really reaps its directly timed-out child; fixture's @@ -336,6 +387,208 @@ def test_actual_subprocess_failure_and_timeout_disclose_no_provider_text(self): for path in directory.iterdir(): path.unlink() directory.rmdir() + @contextlib.contextmanager + def capture_diagnostic_owners(self): + actual_process = subprocess.Popen + actual_selector = self.owner.selectors.DefaultSelector + processes, selectors, launches = [], [], [] + def start(*args, **kwargs): + process = actual_process(*args, **kwargs) + processes.append(process) + launches.append((args, kwargs)) + return process + def select(): + selector = actual_selector() + selectors.append(selector) + return selector + try: + with mock.patch.object(self.owner.subprocess, 'Popen', side_effect=start), \ + mock.patch.object(self.owner.selectors, 'DefaultSelector', side_effect=select), \ + mock.patch.object(self.owner.tempfile, 'TemporaryFile') as spool: + yield processes, selectors, launches + spool.assert_not_called() + finally: + for process in processes: + self.assertIsNotNone(process.returncode, 'Actual direct child must be reaped') + self.assertTrue(process.stdout.closed) + self.assertTrue(process.stderr.closed) + with self.assertRaises(ProcessLookupError): + os.kill(process.pid, 0) + for selector in selectors: + self.assertIsNone(selector.get_map(), 'Selector fd must be closed on every path') + + def diagnostic_child(self, body): + path = self.bin / 'diagnostic-child' + path.write_text('#!' + sys.executable + '\nimport os, sys, time\n' + body) + path.chmod(0o700) + return [str(path)] + + def test_diagnostic_pipes_preserve_closed_categories_and_private_launch_environment(self): + labels = { + 'rate_limit': 'toomanyrequests: You have reached your pull rate limit', + 'auth': 'unauthorized: authentication required', + 'manifest': 'manifest unknown', + 'dns': 'dial tcp: lookup registry: no such host', + 'timeout': 'net/http: request canceled while waiting: timeout', + 'tls': 'x509: certificate signed by unknown authority', + 'unavailable': '503 Service Unavailable', + 'disk': 'no space left on device', + 'transport': 'read: connection reset by peer', + 'other': 'unrecognized diagnostic', + } + self.assertEqual(set(labels) | {'launch', 'deadline', 'output_bound'}, self.owner.COMMAND_FAILURE_KINDS) + console = io.StringIO() + with self.capture_diagnostic_owners() as (_, _, launches), \ + contextlib.redirect_stdout(console), contextlib.redirect_stderr(console), \ + mock.patch.dict(os.environ, {'PRIVATE_CANARY': 'PRIVATE-CANARY', 'DOCKER_CONFIG': 'PRIVATE-CANARY'}): + for kind, diagnostic in labels.items(): + self.configure(status={'docker': 7}, docker='PRIVATE-CANARY', + stderr=diagnostic + ' PRIVATE-CANARY https://private.invalid/token password=private') + self.refuses('pull_' + kind, self.owner.command, [self.owner.DOCKER, 'pull', 'fixed-image'], + time.monotonic() + 2, 'pull', cap=55, diagnose=True) + self.assertEqual('other', self.owner.command_failure_kind(b'\xffPRIVATE-CANARY')) + self.configure(status={'docker': 0}, docker='ok', stderr='unauthorized PRIVATE-CANARY') + with mock.patch.object(self.owner.time, 'monotonic', return_value=500), \ + mock.patch.object(self.owner, 'remaining', wraps=self.owner.remaining) as remaining: + self.assertEqual(b'ok', self.owner.command([self.owner.DOCKER, 'pull', 'fixed-image'], + 610, 'pull', cap=55, diagnose=True)) + self.assertTrue(remaining.call_args_list) + self.assertTrue(all(call.args == (555,) for call in remaining.call_args_list)) + args, launch = launches[-1] + self.assertEqual(([self.owner.DOCKER, 'pull', 'fixed-image'],), args) + self.assertEqual({'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8', + 'HOME': os.environ.get('HOME', ''), 'GIT_CONFIG_NOSYSTEM': '1', 'GIT_CONFIG_GLOBAL': '/dev/null'}, launch['env']) + self.assertEqual(subprocess.DEVNULL, launch['stdin']) + self.assertEqual(subprocess.PIPE, launch['stdout']) + self.assertEqual(subprocess.PIPE, launch['stderr']) + self.assertNotIn('shell', launch) + self.assertIsNone(self.calls()[-1]['secret']) + self.configure(status={'docker': 7}, docker='PRIVATE-CANARY', stderr='unknown PRIVATE-CANARY') + for unknown in ('PRIVATE-CANARY', {'private': 'PRIVATE-CANARY'}, None, 4): + with mock.patch.object(self.owner, 'command_failure_kind', return_value=unknown): + self.refuses('pull_other', self.owner.command, [self.owner.DOCKER, 'pull', 'fixed-image'], + time.monotonic() + 2, 'pull', diagnose=True) + self.assertEqual('', console.getvalue()) + + def test_diagnostic_streams_accept_each_exact_limit_and_kill_at_its_first_excess_byte(self): + with mock.patch.object(self.owner, 'MAX_BYTES', 64), self.capture_diagnostic_owners() as (processes, _, _): + child = self.diagnostic_child('os.write(1, b"x" * 64)\nos.write(2, b"y" * 64)\n') + self.assertEqual(b'x' * 64, self.owner.command(child, time.monotonic() + 2, 'pull', diagnose=True)) + for descriptor in (1, 2): + with self.subTest(descriptor=descriptor): + child = self.diagnostic_child(f'os.write({descriptor}, b"x" * 65)\ntime.sleep(10)\n') + self.refuses('pull_output_bound', self.owner.command, child, + time.monotonic() + 2, 'pull', diagnose=True) + self.assertEqual(-signal.SIGKILL, processes[-1].returncode) + child = self.diagnostic_child('while True:\n os.write(1, b"x" * 32)\n os.write(2, b"y" * 32)\n') + self.refuses('pull_output_bound', self.owner.command, child, + time.monotonic() + 2, 'pull', diagnose=True) + self.assertEqual(-signal.SIGKILL, processes[-1].returncode) + + def test_diagnostic_deadline_cancellation_launch_and_slow_child_remain_closed(self): + with self.capture_diagnostic_owners() as (processes, selectors, _): + with mock.patch.object(self.owner.subprocess, 'Popen') as start: + self.refuses('pull_deadline', self.owner.command, [self.owner.DOCKER, 'pull', 'fixed-image'], + time.monotonic() - 1, 'pull', diagnose=True) + start.assert_not_called() + with mock.patch.object(self.owner, 'CANCELLED', True): + self.refuses('cancelled', self.owner.command, [self.owner.DOCKER, 'pull', 'fixed-image'], + time.monotonic() + 2, 'pull', diagnose=True) + start.assert_not_called() + self.refuses('pull_launch', self.owner.command, [str(self.bin / 'missing-command')], + time.monotonic() + 2, 'pull', diagnose=True) + child = self.diagnostic_child('os.write(1, b"PRIVATE-CANARY")\nos.write(2, b"PRIVATE-CANARY")\ntime.sleep(10)\n') + self.refuses('pull_timeout', self.owner.command, child, + time.monotonic() + 2, 'pull', cap=0.5, diagnose=True) + self.assertEqual(-signal.SIGKILL, processes[-1].returncode) + # Even EOF on both pipes is not process completion. + child = self.diagnostic_child('os.close(1)\nos.close(2)\ntime.sleep(10)\n') + self.refuses('pull_timeout', self.owner.command, child, + time.monotonic() + 2, 'pull', cap=0.5, diagnose=True) + self.assertEqual(-signal.SIGKILL, processes[-1].returncode) + # Cancellation after spawn must settle the same actual child too. + actual_remaining = self.owner.remaining + previous = len(processes) + def cancelled_after_spawn(deadline, cap=None): + if len(processes) > previous: + raise self.owner.Refusal('cancelled') + return actual_remaining(deadline, cap) + with mock.patch.object(self.owner, 'remaining', side_effect=cancelled_after_spawn): + self.refuses('cancelled', self.owner.command, child, + time.monotonic() + 2, 'pull', diagnose=True) + self.assertEqual(-signal.SIGKILL, processes[-1].returncode) + for error, kind in ((errno.ENOSPC, 'disk'), (errno.EIO, 'launch')): + with mock.patch.object(self.owner.subprocess, 'Popen', side_effect=OSError(error, 'PRIVATE-CANARY')): + self.refuses('pull_' + kind, self.owner.command, [self.owner.DOCKER, 'pull', 'fixed-image'], + time.monotonic() + 2, 'pull', diagnose=True) + + def test_diagnostic_setup_spends_the_same_absolute_budget_before_launch(self): + for global_deadline, after_setup in ((610, 556), (510, 511)): + clock = {'now': 500} + original = self.owner.selectors.DefaultSelector + captures = [] + def delayed_selector(): + selector = original() + captures.append(selector) + clock['now'] = after_setup + return selector + with mock.patch.object(self.owner.time, 'monotonic', side_effect=lambda: clock['now']), \ + mock.patch.object(self.owner.selectors, 'DefaultSelector', side_effect=delayed_selector), \ + mock.patch.object(self.owner.subprocess, 'Popen') as start: + self.refuses('pull_deadline', self.owner.command, [self.owner.DOCKER, 'pull', 'fixed-image'], + global_deadline, 'pull', cap=55, diagnose=True) + start.assert_not_called() + self.assertIsNone(captures[-1].get_map()) + for error, kind in ((errno.ENOSPC, 'disk'), (errno.EIO, 'unavailable')): + with mock.patch.object(self.owner.selectors, 'DefaultSelector', + side_effect=OSError(error, 'PRIVATE-CANARY')), \ + mock.patch.object(self.owner.subprocess, 'Popen') as start: + self.refuses('pull_' + kind, self.owner.command, [self.owner.DOCKER, 'pull', 'fixed-image'], + time.monotonic() + 2, 'pull', diagnose=True) + start.assert_not_called() + + def test_diagnostic_io_and_cleanup_failures_join_child_and_preserve_first_closed_reason(self): + actual_read = os.read + with self.capture_diagnostic_owners() as (processes, _, _): + child = self.diagnostic_child('os.write(1, b"PRIVATE-CANARY")\ntime.sleep(10)\n') + def broken_pipe(fd, count): + if processes and fd in (processes[-1].stdout.fileno(), processes[-1].stderr.fileno()): + raise OSError(errno.EIO, 'PRIVATE-CANARY https://private.invalid/credential') + return actual_read(fd, count) + with mock.patch.object(self.owner.os, 'read', side_effect=broken_pipe): + self.refuses('pull_unavailable', self.owner.command, child, + time.monotonic() + 2, 'pull', diagnose=True) + self.assertEqual(-signal.SIGKILL, processes[-1].returncode) + for status, diagnostic, reason in ((7, 'unauthorized PRIVATE-CANARY', 'auth'), (0, '', 'disk')): + actual_process = subprocess.Popen + captures = [] + def broken_close(*args, **kwargs): + process = actual_process(*args, **kwargs) + captures.append(process) + original_close = process.stdout.close + def close(): + original_close() + raise OSError(errno.ENOSPC, 'PRIVATE-CANARY https://private.invalid/credential') + process.stdout.close = close + return process + self.configure(status={'docker': status}, docker='ok', stderr=diagnostic) + with mock.patch.object(self.owner.subprocess, 'Popen', side_effect=broken_close): + self.refuses('pull_' + reason, self.owner.command, [self.owner.DOCKER, 'pull', 'fixed-image'], + time.monotonic() + 2, 'pull', diagnose=True) + self.assertTrue(captures[-1].stdout.closed) + self.assertTrue(captures[-1].stderr.closed) + with self.assertRaises(ProcessLookupError): + os.kill(captures[-1].pid, 0) + + def test_docker_socket_access_failure_is_not_registry_authentication(self): + for diagnostic in ( + b'permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock', + b'Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?'): + self.assertEqual('unavailable', self.owner.command_failure_kind(diagnostic)) + for diagnostic in (b'pull access denied for private/image, repository does not exist or may require docker login', + b'permission denied: registry policy', b'unauthorized: authentication required'): + self.assertEqual('auth', self.owner.command_failure_kind(diagnostic)) + def test_real_subprocess_and_setsid_keep_observed_membership_and_completed_duration(self): case = self.case(1) diagnostic = self.root / 'execution.diag' @@ -431,18 +684,22 @@ def test_failed_protocol_retains_only_closed_source_codes_and_bounded_line_numbe expected = {path.name for path in (ROOT / 'backend/tests/LearnStack.Tests.Integration/Database').glob( 'BackgroundJobRuntimeFacadeTests*.cs')} | { 'OutboxProcessorTests.cs', 'BackgroundJobPositiveSdkTests.CausalRestart.cs', + 'BackgroundJobPositiveSdkTests.CausalRestartObservations.cs', 'BackgroundJobPositiveSdkTests.NativeSettlement.cs', 'PublicAdmissionRenderingTests.cs', 'BackgroundJobPositiveSdkTests.NativeWait.cs', 'BackgroundJobPositiveSdkTests.NativeHistory.cs', 'BackgroundJobPositiveSdkTests.NativeHandoff.cs', + 'BackgroundJobPositiveSdkTests.NativeRecovery.cs', + 'BackgroundJobPositiveSdkTests.SourceContention.cs', 'BackgroundJobPositiveSdkTests.NativeSettlementScheduling.cs', 'BackgroundJobPositiveSdkTests.NativeSettlementPrepare.cs', 'BackgroundJobRecurrenceStoreTests.NativeOwnership.cs', 'BackgroundJobRecurrenceStoreTests.Runtime.cs', - 'TenantRealmApiTests.WarmQualification.cs'} + 'TenantRealmApiTests.WarmQualification.cs', 'BffNativeDriver.cs', 'TenantBffSessionTests.cs'} self.assertEqual(expected, set(self.owner.FAILURE_SOURCES)) self.assertEqual(len(expected), len(set(self.owner.FAILURE_SOURCES.values()))) for filename, code in self.owner.FAILURE_SOURCES.items(): - directory = 'TenancyIdentity' if filename == 'TenantRealmApiTests.WarmQualification.cs' else 'Database' + directory = 'TenancyIdentity' if filename in { + 'TenantRealmApiTests.WarmQualification.cs', 'BffNativeDriver.cs', 'TenantBffSessionTests.cs'} else 'Database' self.assertTrue((ROOT / 'backend/tests/LearnStack.Tests.Integration' / directory / filename).is_file()) progress.failure_locations.clear() item = self.execution_result(cases[0]); item['Outcome'] = 2 @@ -506,6 +763,16 @@ def test_public_failure_details_export_only_registered_test_owned_stages(self): for detail in self.owner.PUBLIC_FAILURE_DETAILS: text = 'Public admission fixture failed during browser 429 ' + detail + '.' self.assertEqual({'stage': 'browser 429', 'detail': detail}, parse(case, {'ErrorMessage': text})) + for stage in ('browser 429', 'browser 503'): + for kind in self.owner.PUBLIC_DEVTOOLS_KINDS: + text = 'Public admission fixture failed during ' + stage + ' browser-devtools ' + kind + '.' + self.assertEqual({'stage': stage, 'detail': 'browser-devtools', 'kind': kind}, + parse(case, {'ErrorMessage': text})) + for suffix in ('browser 429 browser-devtools PRIVATE-CANARY', + 'browser 429 browser-launch deadline-missing', + 'production build browser-devtools deadline-missing', + 'browser 429 browser-devtools deadline-missing PRIVATE-CANARY'): + self.assertIsNone(parse(case, {'ErrorMessage': 'Public admission fixture failed during ' + suffix + '.'})) for text in (None, {}, 'Public admission fixture failed during PRIVATE-CANARY.', 'Public admission fixture failed during browser 429 PRIVATE-CANARY.', 'x' * 65536 + 'Public admission fixture failed during production build.'): @@ -836,6 +1103,347 @@ def test_outbox_startup_protocol_keeps_finite_failure_details_and_sanitized_resu self.assertNotIn('/private/', exported) self.assertNotIn('Outbox startup result;', exported) + @staticmethod + def source_contention_sentence(kind='failure', **changes): + values = dict(boundary='reconcile_wait', reconcile='pending', transactional='pending', + provider='not_issued', auxiliary='not_issued', pressure='0', runtime='running', + completion='pending', executions='0', retained='0', renewalRefused='0', leaseElapsed='0') + values.update(changes) + return 'Source contention ' + kind + ';' + ';'.join(key + '=' + value for key, value in values.items()) + '.' + + def test_source_contention_preserves_first_boundary_and_separate_cleanup_without_private_payloads(self): + case = {**self.case(1), 'name': self.owner.SOURCE_CONTENTION_FAILURE_CASE} + parse = self.owner.closed_failure_details + raw = {'Messages': [{'Category': 'StdOutMsgs', 'Text': 'PRIVATE-CANARY\n' + + self.source_contention_sentence(renewalRefused='1') + '\n' + + self.source_contention_sentence('cleanup', boundary='cleanup_complete', runtime='stopped', + completion='succeeded', reconcile='succeeded')}]} + result = parse(case, raw) + self.assertEqual('source_contention', result['stage']) + self.assertEqual('reconcile_wait', result['failure']['boundary']) + self.assertEqual(1, result['failure']['renewalRefused']) + self.assertEqual('cleanup_complete', result['cleanup']['boundary']) + self.assertEqual('succeeded', result['cleanup']['completion']) + self.assertNotIn('PRIVATE-CANARY', json.dumps(result)) + self.assertIsNone(parse({**case, 'assembly': 'other'}, raw)) + self.assertIsNone(parse({**case, 'name': case['name'] + '_other'}, raw)) + for boundary in self.owner.SOURCE_CONTENTION_BOUNDARIES: + with self.subTest(boundary=boundary): + value = parse(case, {'Messages': [{'Category': 'StdOutMsgs', + 'Text': self.source_contention_sentence(boundary=boundary)}]}) + self.assertEqual(boundary, value['failure']['boundary']) + for field in ('executions', 'retained', 'renewalRefused', 'leaseElapsed'): + for value in ('unavailable', 'out_of_range', '0', '64'): + actual = parse(case, {'Messages': [{'Category': 'StdOutMsgs', + 'Text': self.source_contention_sentence(**{field: value})}]})['failure'][field] + self.assertEqual(int(value) if value.isdigit() else value, actual) + unavailable = parse(case, {'Messages': [{'Category': 'StdOutMsgs', 'Text': + 'Source contention failure;boundary=cleanup_stop;snapshot=unavailable.'}]}) + self.assertEqual({'boundary': 'cleanup_stop', 'snapshot': 'unavailable'}, unavailable['failure']) + + def test_source_contention_refuses_ambiguous_private_and_unbounded_observations(self): + case = {**self.case(1), 'name': self.owner.SOURCE_CONTENTION_FAILURE_CASE} + parse = self.owner.closed_failure_details + sentence = self.source_contention_sentence() + for category, text in [('StdErrMsgs', sentence), ('StdOutMsgs', sentence + '\n' + sentence), + ('StdOutMsgs', sentence + 'PRIVATE-CANARY'), ('StdOutMsgs', sentence.replace(';pressure=0;', ';pressure=31;')), + ('StdOutMsgs', sentence + '\n' + self.source_contention_sentence('cleanup', boundary='pressure')), + ('StdOutMsgs', self.source_contention_sentence('cleanup', boundary='cleanup_stop')), + ('StdOutMsgs', 'X' * 65537 + sentence)]: + with self.subTest(category=category, text=text[:60]): + self.assertIsNone(parse(case, {'Messages': [{'Category': category, 'Text': text}]})) + for field, value in [('boundary', 'PRIVATE-CANARY'), ('runtime', 'private'), ('reconcile', 'private'), + ('transactional', 'ready'), ('provider', 'done'), ('auxiliary', 'complete'), + ('completion', 'unavailable'), ('pressure', '-1'), ('pressure', '01'), + ('pressure', 'out_of_range'), ('executions', '65'), ('retained', '1000000000'), + ('renewalRefused', '65'), ('leaseElapsed', 'url=https://private/')]: + with self.subTest(field=field, value=value): + self.assertIsNone(parse(case, {'Messages': [{'Category': 'StdOutMsgs', + 'Text': self.source_contention_sentence(**{field: value})}]})) + self.assertIsNone(parse(case, {'Messages': [{'Category': 'StdOutMsgs', 'Text': sentence, 'private': 'canary'}]})) + + def test_source_contention_failure_export_is_case_bound_and_failure_only(self): + case = {**self.case(1), 'name': self.owner.SOURCE_CONTENTION_FAILURE_CASE} + for outcome in (1, 2): + stream = io.StringIO() + progress = self.progress(self.root / ('source-contention-' + str(outcome) + '.diag'), + 'LearnStack.Tests.Integration', [case], stream) + item = self.execution_result(case, outcome) + item['ErrorMessage'] = 'System.OperationCanceledException : PRIVATE-CANARY' + item['ErrorStackTrace'] = '/private/path/BackgroundJobPositiveSdkTests.SourceContention.cs:line 123' + item['Messages'] = [{'Category': 'StdOutMsgs', 'Text': self.source_contention_sentence()}] + with contextlib.redirect_stdout(io.StringIO()): + progress.results({'NewTestResults': [item]}) + self.assertEqual(outcome == 2, bool(progress.failure_locations)) + if outcome == 2: + diagnostic = progress.failure_locations[0] + self.assertEqual('source_contention', diagnostic['details']['stage']) + self.assertEqual('cancellation', diagnostic['exceptionKind']) + self.assertEqual([{'source': 'positive_sdk_source_contention', 'line': 123}], diagnostic['locations']) + exported = stream.getvalue() + json.dumps(progress.failure_locations) + self.assertNotIn('PRIVATE-CANARY', exported) + self.assertNotIn('/private/', exported) + self.assertNotIn('Source contention failure;', exported) + + def test_bff_browser_details_preserve_only_existing_closed_reasons_and_journey(self): + case = {**self.case(1), 'name': self.owner.BFF_BROWSER_FAILURE_CASE} + parse = self.owner.closed_failure_details + source = (ROOT / 'backend/tests/LearnStack.Tests.Integration/TenancyIdentity/BffNativeDriver.cs').read_text() + stages = source.split('private static readonly string[] ErrorStages = ', 1)[1].split('private readonly Process', 1)[0] + initial, suffix = stages.split('.Concat', 1) + expected = set(re.findall(r'"([^"]+)"', initial)) + expected |= {prefix + code for prefix in ('browser-callback-', 'browser-authorize-', 'browser-logout-status-') + for code in ('0', '200', '303', '400', '403', '404', '429', '503', 'other')} + self.assertEqual(expected, self.owner.BFF_BROWSER_FAILURE_STAGES) + self.assertIn('privacy', self.owner.BFF_BROWSER_FAILURE_STAGES) + for reason in self.owner.BFF_BROWSER_FAILURE_REASONS: + message = 'System.InvalidOperationException : BFF native fixture did not reach the closed browser_result/' + reason + ' phase.' + self.assertEqual({'stage': 'bff_browser', 'reason': reason}, parse(case, {'ErrorMessage': message})) + for message, reason in self.owner.BFF_BROWSER_FIXED_FAILURES.items(): + self.assertIn(message, source) + self.assertEqual(reason, parse(case, {'ErrorMessage': message})['reason']) + for journey in ('positive', 'counter'): + raw = {'ErrorMessage': 'PRIVATE-CANARY', 'Messages': [{'Category': 'StdOutMsgs', 'Text': + 'BFF browser failure;journey=' + journey + '.\nBFF browser cleanup;journey=' + journey + ';state=failed.'}]} + self.assertEqual({'stage': 'bff_browser', 'journey': journey, 'secondaryCleanup': 'failed', 'reason': 'other'}, parse(case, raw)) + self.assertIsNone(parse({**case, 'name': case['name'] + '_other'}, raw)) + self.assertIsNone(parse({**case, 'assembly': 'other'}, raw)) + + def test_bff_browser_details_refuse_private_malformed_duplicate_and_wrong_channel_data(self): + case = {**self.case(1), 'name': self.owner.BFF_BROWSER_FAILURE_CASE} + parse = self.owner.closed_failure_details + message = 'BFF native fixture did not reach the closed browser_result/browser-launch phase.' + for invalid in (None, {}, message + 'PRIVATE-CANARY', message + '\n' + message, + message.replace('browser-launch', 'private-canary'), 'X' * 65537 + message): + self.assertIsNone(parse(case, {'ErrorMessage': invalid})) + marker = 'BFF browser failure;journey=positive.' + for channel, text in [('StdErrMsgs', marker), ('StdOutMsgs', marker + '\n' + marker), + ('StdOutMsgs', marker + 'PRIVATE-CANARY'), ('StdOutMsgs', marker.replace('positive', 'private')), + ('StdOutMsgs', 'BFF browser cleanup;journey=positive;state=failed.'), + ('StdOutMsgs', marker + '\nBFF browser cleanup;journey=counter;state=failed.'), + ('StdOutMsgs', 'X' * 65537 + marker)]: + self.assertIsNone(parse(case, {'ErrorMessage': message, 'Messages': [{'Category': channel, 'Text': text}]})) + self.assertIsNone(parse(case, {'ErrorMessage': message, 'Messages': [ + {'Category': 'StdOutMsgs', 'Text': marker, 'private': 'canary'}]})) + + def test_bff_browser_failure_export_is_case_bound_failure_only_and_contains_no_raw_text(self): + case = {**self.case(1), 'name': self.owner.BFF_BROWSER_FAILURE_CASE} + for reason in ('browser-executable', 'browser-spawn', 'browser-devtools', 'browser-devtools-inactive', + 'browser-devtools-unreadable', 'browser-devtools-missing', 'browser-devtools-empty', + 'browser-devtools-malformed', 'browser-not-live', 'browser-connect', 'browser-context'): + for suffix in ('', '_cleanup_failed'): + raw = {'ErrorMessage': 'BFF native fixture did not reach the closed browser_result/' + reason + suffix + ' phase.'} + self.assertEqual(reason + suffix, self.owner.closed_failure_details(case, raw)['reason']) + self.assertIsNone(self.owner.closed_failure_details(case, + {'ErrorMessage': raw['ErrorMessage'].replace(reason, reason + '-PRIVATE-CANARY')})) + for outcome in (1, 2): + stream = io.StringIO() + progress = self.progress(self.root / ('bff-browser-' + str(outcome) + '.diag'), + 'LearnStack.Tests.Integration', [case], stream) + item = self.execution_result(case, outcome) + item['ErrorMessage'] = 'System.InvalidOperationException : BFF native fixture did not reach the closed browser_result/browser-launch phase.' + item['ErrorStackTrace'] = '/private/PRIVATE-CANARY/BffNativeDriver.cs:line 123' + item['Messages'] = [{'Category': 'StdOutMsgs', 'Text': 'PRIVATE-CANARY\nBFF browser failure;journey=positive.'}] + with contextlib.redirect_stdout(io.StringIO()): + progress.results({'NewTestResults': [item]}) + self.assertEqual(bool(progress.failure_locations), outcome == 2) + if outcome == 2: + self.assertEqual({'stage': 'bff_browser', 'journey': 'positive', 'reason': 'browser-launch'}, progress.failure_locations[0]['details']) + self.assertEqual([{'source': 'bff_native_driver', 'line': 123}], progress.failure_locations[0]['locations']) + exported = stream.getvalue() + json.dumps(progress.failure_locations) + for private in ('PRIVATE-CANARY', '/private/', 'BFF native fixture', 'BFF browser failure;'): + self.assertNotIn(private, exported) + + @staticmethod + def causal_restart_sentence(kind='failure', **changes): + values = dict(boundary='assert_facts', runtime='running', executions='0', renewalRefused='1', leaseElapsed='0', + row='observed', normal='1', finalize='2', ledger='3', release='1', audits='1', incomplete='no', + outcome='cancelled', policy='transient_not_repeatable', reason='external_retry_forbidden', + nulls='0', calls='0', forced='0') + values.update(changes) + return 'Causal restart ' + kind + ';' + ';'.join(key + '=' + value for key, value in values.items()) + '.' + + @staticmethod + def native_recovery_sentence(record_kind='failure', **changes): + values = dict(boundary='renewal_wait', kind='timeout', actual='pending', timerChanges='0', + baselineChanges='1', renewals='0', renewalLosses='0', elapsedLosses='0', + runClosed='false', tokenCancelled='false', dropped='1', clean='false') + values.update(changes) + return 'Native recovery ' + record_kind + ';' + ';'.join( + key + '=' + value for key, value in values.items()) + '.' + + def test_causal_restart_details_preserve_real_row_counts_and_first_boundary_separately_from_cleanup(self): + parse = self.owner.closed_failure_details + for name in self.owner.CAUSAL_RESTART_FAILURE_CASES: + case = {**self.case(1), 'name': name} + raw = {'Messages': [{'Category': 'StdOutMsgs', 'Text': 'PRIVATE-CANARY\n' + + self.causal_restart_sentence() + '\n' + self.causal_restart_sentence('cleanup', + boundary='restart_cleanup_complete', runtime='stopped')}]} + result = parse(case, raw) + self.assertEqual('causal_restart', result['stage']) + self.assertEqual(2, result['failure']['finalize']) + self.assertEqual(3, result['failure']['ledger']) + self.assertEqual('assert_facts', result['failure']['boundary']) + self.assertEqual('restart_cleanup_complete', result['cleanup']['boundary']) + timers = parse(case, {'Messages': [{'Category': 'StdOutMsgs', + 'Text': self.causal_restart_sentence(boundary='original_cleanup_timers')}]}) + self.assertEqual('original_cleanup_timers', timers['failure']['boundary']) + absent_probe = parse(case, {'Messages': [{'Category': 'StdOutMsgs', + 'Text': self.causal_restart_sentence(boundary='provider_accept', calls='unavailable', forced='0')}]}) + self.assertEqual('unavailable', absent_probe['failure']['calls']) + self.assertIsNone(parse(case, {'Messages': [{'Category': 'StdOutMsgs', + 'Text': self.causal_restart_sentence(boundary='restart_cleanup_timers')}]})) + self.assertNotIn('PRIVATE-CANARY', json.dumps(result)) + self.assertIsNone(parse({**case, 'assembly': 'other'}, raw)) + self.assertIsNone(parse({**case, 'name': name + '_other'}, raw)) + for boundary in self.owner.CAUSAL_RESTART_BOUNDARIES: + self.assertEqual(boundary, parse(case, {'Messages': [{'Category': 'StdOutMsgs', + 'Text': self.causal_restart_sentence(boundary=boundary)}]})['failure']['boundary']) + forced = parse(case, {'Messages': [{'Category': 'StdOutMsgs', + 'Text': self.causal_restart_sentence(calls='2', forced='1')}]}) + self.assertEqual(bool(forced), name.endswith('(independentFailure: False, lieOnlyOnReconcile: False)')) + case = {**self.case(1), 'name': sorted(self.owner.CAUSAL_RESTART_FAILURE_CASES)[0]} + unavailable = parse(case, {'Messages': [{'Category': 'StdOutMsgs', 'Text': + 'Causal restart failure;boundary=read_facts;snapshot=unavailable.'}]}) + self.assertEqual({'boundary': 'read_facts', 'snapshot': 'unavailable'}, unavailable['failure']) + missing = dict(row='unobserved', **{name:'unavailable' for name in + ('normal', 'finalize', 'ledger', 'release', 'audits', 'incomplete', 'outcome', 'policy', 'reason', 'nulls')}) + self.assertEqual('unobserved', parse(case, {'Messages': [{'Category': 'StdOutMsgs', + 'Text': self.causal_restart_sentence(**missing)}]})['failure']['row']) + self.assertEqual(511, parse(case, {'Messages': [{'Category': 'StdOutMsgs', + 'Text': self.causal_restart_sentence(nulls='511')} ]})['failure']['nulls']) + expected_method = ('LearnStack.Tests.Integration.Database.BackgroundJobPositiveSdkTests.' + 'Actual_native_recovery_owner_renews_past_old_deadlines_without_admission_or_another_Execute') + self.assertEqual({expected_method + '(loseAcquisitionAck: False)', + expected_method + '(loseAcquisitionAck: True)'}, self.owner.NATIVE_RECOVERY_FAILURE_CASES) + self.assertEqual({'rooted', 'construction', 'identity', 'renewal_wait', 'database_renewal', + 'old_lease', 'provider_before', 'reconcile', 'row_facts', 'cleanup_actual', 'cleanup_work', + 'cleanup_control', 'post_cleanup'}, self.owner.NATIVE_RECOVERY_BOUNDARIES) + self.assertEqual('Native recovery failure;boundary=renewal_wait;kind=timeout;actual=pending;' + 'timerChanges=0;baselineChanges=1;renewals=0;renewalLosses=0;elapsedLosses=0;runClosed=false;' + 'tokenCancelled=false;dropped=1;clean=false.', self.native_recovery_sentence()) + for name in self.owner.NATIVE_RECOVERY_FAILURE_CASES: + case = {**self.case(1), 'name': name} + raw = {'Messages': [{'Category': 'StdOutMsgs', 'Text': 'PRIVATE-CANARY\n' + + self.native_recovery_sentence() + '\n' + + self.native_recovery_sentence('cleanup', boundary='post_cleanup', actual='succeeded', + timerChanges='10000', baselineChanges='unavailable', renewals='out_of_range', + runClosed='true', tokenCancelled='true', clean='true')}]} + result = parse(case, raw) + self.assertEqual('native_recovery', result['stage']) + self.assertEqual({'boundary': 'renewal_wait', 'kind': 'timeout', 'actual': 'pending', + 'timerChanges': 0, 'baselineChanges': 1, 'renewals': 0, 'renewalLosses': 0, + 'elapsedLosses': 0, 'runClosed': False, 'tokenCancelled': False, + 'dropped': 1, 'clean': False}, result['failure']) + self.assertEqual(10000, result['cleanup']['timerChanges']) + self.assertEqual('unavailable', result['cleanup']['baselineChanges']) + self.assertEqual('out_of_range', result['cleanup']['renewals']) + self.assertIs(result['cleanup']['clean'], True) + self.assertNotIn('PRIVATE-CANARY', json.dumps(result)) + self.assertIsNone(parse({**case, 'assembly': 'other'}, raw)) + self.assertIsNone(parse({**case, 'name': name + '_other'}, raw)) + for field, allowed in {'boundary': self.owner.NATIVE_RECOVERY_BOUNDARIES, + 'kind': ('timeout', 'cancellation', 'assertion', 'invalid_state', 'other'), + 'actual': ('not_issued', 'pending', 'succeeded', 'faulted', 'canceled')}.items(): + for value in allowed: + self.assertEqual(value, parse(case, {'Messages': [{'Category': 'StdOutMsgs', + 'Text': self.native_recovery_sentence(**{field: value})}]})['failure'][field]) + + def test_causal_restart_details_reject_private_ambiguous_wrong_channel_and_out_of_bound_values(self): + case = {**self.case(1), 'name': sorted(self.owner.CAUSAL_RESTART_FAILURE_CASES)[0]} + parse = self.owner.closed_failure_details + sentence = self.causal_restart_sentence() + for category, text in [('StdErrMsgs', sentence), ('StdOutMsgs', sentence + '\n' + sentence), + ('StdOutMsgs', sentence + 'PRIVATE-CANARY'), ('StdOutMsgs', 'X' * 65537 + sentence), + ('StdOutMsgs', self.causal_restart_sentence('cleanup', boundary='restart_cleanup_complete')), + ('StdOutMsgs', sentence + '\n' + self.causal_restart_sentence('cleanup', boundary='assert_facts'))]: + self.assertIsNone(parse(case, {'Messages': [{'Category': category, 'Text': text}]})) + for name, value in [('boundary', 'private'), ('runtime', 'ready'), ('row', 'missing'), + ('row', 'unobserved'), ('normal', '7'), ('finalize', '7'), ('finalize', '01'), ('ledger', '13'), + ('release', '65'), ('audits', '65'), ('incomplete', 'true'), ('outcome', 'PRIVATE-CANARY'), + ('policy', 'https://private/'), ('reason', 'credentials'), ('nulls', '512'), + ('calls', '7'), ('forced', '2'), ('forced', 'unavailable'), ('executions', '65'), + ('normal', 'unavailable'), ('renewalRefused', '-1')]: + with self.subTest(name=name,value=value): + self.assertIsNone(parse(case, {'Messages': [{'Category': 'StdOutMsgs', + 'Text': self.causal_restart_sentence(**{name:value})}]})) + case = {**self.case(1), 'name': sorted(self.owner.NATIVE_RECOVERY_FAILURE_CASES)[0]} + sentence = self.native_recovery_sentence() + cleanup = self.native_recovery_sentence('cleanup', boundary='cleanup_actual') + for category, text in [('StdErrMsgs', sentence), ('StdOutMsgs', sentence + '\n' + sentence), + ('StdOutMsgs', sentence + '\n' + cleanup + '\n' + cleanup), + ('StdOutMsgs', sentence + 'PRIVATE-CANARY'), ('StdOutMsgs', 'X' * 65537 + sentence), + ('StdOutMsgs', cleanup), ('StdOutMsgs', sentence.replace(';kind=timeout', '')), + ('StdOutMsgs', sentence.replace(';actual=pending', ';actual=pending;private=canary'))]: + self.assertIsNone(parse(case, {'Messages': [{'Category': category, 'Text': text}]})) + changes = [('boundary', 'private'), ('kind', 'System.TimeoutException'), ('kind', 'https://private/'), + ('actual', 'complete'), ('runClosed', 'True'), ('tokenCancelled', 'unavailable'), + ('clean', '1')] + changes += [(name, value) for name in ('timerChanges', 'baselineChanges', 'renewals', + 'renewalLosses', 'elapsedLosses', 'dropped') + for value in ('10001', '-1', '01', '1.0', 'PRIVATE-CANARY')] + for name, value in changes: + with self.subTest(native_recovery=name, value=value): + self.assertIsNone(parse(case, {'Messages': [{'Category': 'StdOutMsgs', + 'Text': self.native_recovery_sentence(**{name: value})}]})) + for messages in ([], [{'Category': 'StdOutMsgs', 'Text': sentence}] * 33, + [{'Category': 'StdOutMsgs', 'Text': sentence, 'private': 'canary'}], + [{'Category': 'x' * 33, 'Text': sentence}]): + self.assertIsNone(parse(case, {'Messages': messages})) + + def test_causal_restart_failure_export_is_exact_case_bound_failure_only_and_drops_raw_messages(self): + case = {**self.case(1), 'name': sorted(self.owner.CAUSAL_RESTART_FAILURE_CASES)[0]} + for outcome in (1, 2): + stream = io.StringIO() + progress = self.progress(self.root / ('causal-restart-' + str(outcome) + '.diag'), + 'LearnStack.Tests.Integration', [case], stream) + item = self.execution_result(case, outcome) + item['ErrorMessage'] = 'PRIVATE-CANARY' + item['ErrorStackTrace'] = '/private/path/BackgroundJobPositiveSdkTests.CausalRestartObservations.cs:line 123' + item['Messages'] = [{'Category': 'StdOutMsgs', 'Text': self.causal_restart_sentence()}] + with contextlib.redirect_stdout(io.StringIO()): + progress.results({'NewTestResults': [item]}) + self.assertEqual(outcome == 2, bool(progress.failure_locations)) + if outcome == 2: + diagnostic = progress.failure_locations[0] + self.assertEqual('causal_restart', diagnostic['details']['stage']) + self.assertEqual([{'source': 'positive_sdk_causal_observation', 'line': 123}], diagnostic['locations']) + exported = stream.getvalue() + json.dumps(progress.failure_locations) + for private in ('PRIVATE-CANARY', '/private/', 'Causal restart failure;'): + self.assertNotIn(private, exported) + for name in self.owner.NATIVE_RECOVERY_FAILURE_CASES: + case = {**self.case(1), 'name': name} + for outcome in (1, 2): + stream = io.StringIO() + progress = self.progress(self.root / ('native-recovery-' + str(outcome) + '-' + + str(name.endswith('True)')) + '.diag'), 'LearnStack.Tests.Integration', [case], stream) + item = self.execution_result(case, outcome) + item['ErrorMessage'] = 'System.TimeoutException : PRIVATE-CANARY' + item['ErrorStackTrace'] = '/private/path/BackgroundJobPositiveSdkTests.NativeRecovery.cs:line 123' + item['Messages'] = [{'Category': 'StdOutMsgs', 'Text': self.native_recovery_sentence()}] + with contextlib.redirect_stdout(io.StringIO()): + progress.results({'NewTestResults': [item]}) + self.assertEqual(outcome == 2, bool(progress.failure_locations)) + if outcome == 2: + diagnostic = progress.failure_locations[0] + self.assertEqual('native_recovery', diagnostic['details']['stage']) + self.assertEqual('timeout', diagnostic['exceptionKind']) + self.assertEqual([{'source': 'positive_sdk_native_recovery', 'line': 123}], diagnostic['locations']) + exported = stream.getvalue() + json.dumps(progress.failure_locations) + for private in ('PRIVATE-CANARY', '/private/', 'Native recovery failure;'): + self.assertNotIn(private, exported) + for foreign in ({'assembly': 'other'}, {'name': sorted(self.owner.NATIVE_RECOVERY_FAILURE_CASES)[0] + '_other'}): + case = {**self.case(1), 'name': sorted(self.owner.NATIVE_RECOVERY_FAILURE_CASES)[0], **foreign} + stream = io.StringIO() + progress = self.progress(self.root / ('native-foreign-' + next(iter(foreign)) + '.diag'), + case['assembly'], [case], stream) + item = self.execution_result(case, 2) + item['TestCase']['Source'] = '/private/' + case['assembly'] + '.dll' + item['Messages'] = [{'Category': 'StdOutMsgs', 'Text': self.native_recovery_sentence()}] + with contextlib.redirect_stdout(io.StringIO()): + progress.results({'NewTestResults': [item]}) + self.assertNotIn('details', progress.failure_locations[0]) + @staticmethod def native_wait_sentence(kind='failure', **changes): values = dict(boundary='constructor', constructor='pending', invocation='not_issued', @@ -862,7 +1470,7 @@ def test_native_wait_details_preserve_first_boundary_and_separate_real_cleanup_o self.assertIsNone(parse({**case, 'assembly': 'unregistered'}, raw)) self.assertIsNone(parse({**case, 'name': 'other'}, raw)) for boundary in ('start', 'handoff', 'capture', 'cancel', 'return', 'wrapper', 'held', - 'cleanup_request', 'cleanup_stop', 'cleanup_completion'): + 'cleanup_request', 'cleanup_stop', 'cleanup_completion', 'cleanup_timers'): self.assertEqual(boundary, parse(case, {'Messages': [{'Category': 'StdOutMsgs', 'Text': self.native_wait_sentence(boundary=boundary)}]})['failure']['boundary']) @@ -876,6 +1484,52 @@ def test_native_wait_details_accept_unavailable_projection_without_inventing_joi self.assertEqual({'boundary': 'cleanup_stop', 'snapshot': 'unavailable'}, result['failure']) self.assertEqual(result['failure'], result['cleanup']) + @staticmethod + def native_handoff_sentence(**changes): + values = dict(native='succeeded', eligible='no', running='0', retained='0', closed='no', + route='allowed', phase='executing', owner='execution', lease='live', attempts='1', hint='not_attempted') + values.update(changes) + return 'Native handoff;' + ';'.join(key + '=' + value for key, value in values.items()) + '.' + + def test_native_wait_retains_only_closed_last_observed_handoff_guards(self): + for name in self.owner.NATIVE_WAIT_FAILURE_CASES: + case = {**self.case(1), 'name': name} + raw = {'Messages': [{'Category': 'StdOutMsgs', 'Text': 'PRIVATE-CANARY\n' + + self.native_wait_sentence(boundary='handoff') + '\n' + self.native_handoff_sentence()}]} + result = self.owner.closed_failure_details(case, raw) + self.assertEqual('handoff', result['failure']['boundary']) + self.assertEqual({'native': 'succeeded', 'eligible': 'no', 'running': 0, 'retained': 0, + 'closed': 'no', 'route': 'allowed', 'phase': 'executing', 'owner': 'execution', + 'lease': 'live', 'attempts': 1, 'hint': 'not_attempted'}, result['handoff']) + self.assertNotIn('PRIVATE-CANARY', json.dumps(result)) + self.assertIsNone(self.owner.closed_failure_details({**case, 'name': 'other'}, raw)) + for phase in ('pending', 'preparing', 'prepared', 'executing', 'reconciling', 'finalize_pending', + 'finalizing', 'terminal_pending', 'completed', 'quarantined'): + qualified = {'Messages': [{'Category': 'StdOutMsgs', 'Text': self.native_wait_sentence(boundary='handoff') + + '\n' + self.native_handoff_sentence(phase=phase, owner='routing')}]} + self.assertEqual(phase, self.owner.closed_failure_details(case, qualified)['handoff']['phase']) + self.assertEqual('routing', self.owner.closed_failure_details(case, qualified)['handoff']['owner']) + for hint in ('not_attempted', 'attempted', 'acknowledged'): + raw['Messages'][0]['Text'] = self.native_wait_sentence(boundary='handoff') + '\n' + self.native_handoff_sentence(hint=hint) + self.assertEqual(hint, self.owner.closed_failure_details(case, raw)['handoff']['hint']) + + def test_native_wait_refuses_private_unbounded_or_ambiguous_handoff_guards(self): + case = {**self.case(1), 'name': sorted(self.owner.NATIVE_WAIT_FAILURE_CASES)[0]} + prefix = self.native_wait_sentence(boundary='handoff') + '\n' + sentence = self.native_handoff_sentence() + for value in (sentence + '\n' + sentence, sentence.replace(';route=', ';unlisted='), + sentence + 'PRIVATE-CANARY'): + self.assertIsNone(self.owner.closed_failure_details(case, + {'Messages': [{'Category': 'StdOutMsgs', 'Text': prefix + value}]})) + for field, value in (('native', 'PRIVATE-CANARY'), ('eligible', 'true'), ('running', '65'), + ('retained', '-1'), ('running', '01'), ('closed', '0'), ('route', 'yes'), + ('phase', 'private_path'), ('owner', 'subject_id'), ('lease', 'unknown'), + ('attempts', '7'), ('hint', 'true')): + self.assertIsNone(self.owner.closed_failure_details(case, {'Messages': [{'Category': 'StdOutMsgs', + 'Text': prefix + self.native_handoff_sentence(**{field: value})}]})) + self.assertIsNone(self.owner.closed_failure_details(case, {'Messages': [ + {'Category': 'StdOutMsgs', 'Text': prefix}, {'Category': 'StdErrMsgs', 'Text': sentence}]})) + def test_native_wait_details_refuse_ambiguous_private_or_inconsistent_observations(self): case = {**self.case(1), 'name': sorted(self.owner.NATIVE_WAIT_FAILURE_CASES)[0]} parse = self.owner.closed_failure_details @@ -899,13 +1553,15 @@ def test_native_wait_protocol_exports_only_closed_details_and_truthful_failed_ou item = self.execution_result(case); item['Outcome'] = 2 item['ErrorMessage'] = 'System.OperationCanceledException : PRIVATE-CANARY' item['ErrorStackTrace'] = '/private/path/BackgroundJobPositiveSdkTests.NativeWait.cs:line 98' - item['Messages'] = [{'Category': 'StdOutMsgs', 'Text': self.native_wait_sentence(boundary='cleanup_stop')}] + item['Messages'] = [{'Category': 'StdOutMsgs', 'Text': self.native_wait_sentence(boundary='cleanup_stop') + + '\n' + self.native_handoff_sentence()}] with contextlib.redirect_stdout(io.StringIO()): progress.results({'NewTestResults': [item]}) result = progress.failure_locations[0] self.assertEqual([{'source': 'positive_sdk_native_wait', 'line': 98}], result['locations']) self.assertEqual('cleanup_stop', result['details']['failure']['boundary']) self.assertEqual('cancellation', result['exceptionKind']) + self.assertEqual('no', result['details']['handoff']['eligible']) self.assertEqual('Failed', json.loads(stream.getvalue())['outcome']) for text in ('PRIVATE-CANARY', '/private/', 'Native wait failure;'): self.assertNotIn(text, json.dumps(result)) @@ -1285,6 +1941,162 @@ def test_execution_completion_tail_uses_actual_ids_and_refuses_missing_results(s self.refuses('execution_incomplete', incomplete.line, self.execution_complete(cases[:1], tail=cases[:1]).encode()) + def test_complete_passed_population_does_not_override_execution_abort_cancel_or_error(self): + cases = self.population()['ordinary'] + for field, value in (('IsCanceled', True), ('IsAborted', True), ('Error', {'Message': 'PRIVATE-CANARY'})): + with self.subTest(field=field): + progress = self.progress(self.root / (field + '.diag'), + 'LearnStack.Tests.Integration', cases, io.StringIO()) + message = json.loads(self.execution_complete(cases, tail=cases).split( + self.owner.ExecutionProgress.marker.decode(), 1)[1]) + message['Payload']['TestRunCompleteArgs'][field] = value + with contextlib.redirect_stdout(io.StringIO()): + self.refuses('execution_incomplete', progress.line, + (self.owner.ExecutionProgress.marker.decode() + json.dumps(message)).encode()) + self.assertEqual(progress.expected, progress.seen) + self.assertFalse(progress.completed) + + def test_successful_causal_observations_retain_only_exact_case_bound_closed_counts(self): + counts = ('Causal restart counts;reconciles=1;finalize=1;ledger=2;forced=no;' + 'leaseElapsed=0;renewalRefused=0.') + forced = counts.replace('finalize=1;ledger=2;forced=no', 'finalize=2;ledger=3;forced=yes') + for index, name in enumerate(sorted(self.owner.CAUSAL_RESTART_FAILURE_CASES)): + case = {**self.case(index + 1), 'name': name} + forced_case = name.endswith('(independentFailure: False, lieOnlyOnReconcile: False)') + text = counts + ('\n' + forced if forced_case else '') + raw = self.execution_result(case) + raw['Messages'] = [{'Category': 'StdOutMsgs', 'Text': 'PRIVATE-CANARY\n' + text}] + stream = io.StringIO() + progress = self.progress(self.root / ('success-counts-' + str(index) + '.diag'), + 'LearnStack.Tests.Integration', [case], stream) + with contextlib.redirect_stdout(io.StringIO()): + progress.results({'NewTestResults': [raw]}) + self.assertEqual([], progress.failure_locations) + self.assertEqual(1, len(progress.success_observations)) + observed = progress.success_observations[0] + self.assertEqual(case['id'], observed['id']) + self.assertEqual('causal_restart_counts', observed['details']['stage']) + self.assertEqual([False, True] if forced_case else [False], + [run['forced'] for run in observed['details']['runs']]) + exported = stream.getvalue() + json.dumps(progress.success_observations) + for private in ('PRIVATE-CANARY', 'Causal restart counts;', 'ErrorMessage', 'Messages'): + self.assertNotIn(private, exported) + self.assertEqual({'assembly', 'id', 'name', 'outcome', 'duration'}, set(json.loads(stream.getvalue()))) + for category, dirty in ( + ('StdErrMsgs', text), ('StdOutMsgs', text + '\n' + counts), + ('StdOutMsgs', text + 'PRIVATE-CANARY'), ('StdOutMsgs', text.replace('ledger=2', 'ledger=13')), + ('StdOutMsgs', text.replace('reconciles=1', 'reconciles=01')), + ('StdOutMsgs', text.replace('renewalRefused=0', 'renewalRefused=10001'))): + self.assertIsNone(self.owner.closed_causal_restart_counts(case, + {'Messages': [{'Category': category, 'Text': dirty}]})) + self.assertIsNone(self.owner.closed_causal_restart_counts({**case, 'assembly': 'foreign'}, raw)) + + def test_recurrence_runtime_failure_keeps_only_exact_case_bound_closed_observations(self): + prefix = ('LearnStack.Tests.Integration.Database.BackgroundJobRecurrenceStoreTests.' + 'Actual_runtime_fans_out_to_both_scopes_and_duplicate_native_ticks_create_no_extra_children') + cases = {prefix + '(organization: True)', prefix + '(organization: False)'} + self.assertEqual(cases, self.owner.RECURRENCE_RUNTIME_FAILURE_CASES) + sentence = ('Recurrence runtime failure;boundary=children;runtime=Running;completed=1;' + 'executed=1;occurrences=1;recipients=2;fanouts=1;applications=0;retainedApplications=0;' + 'bridgeOutstanding=0;bridgeRetained=0;bridgeFailed=0;executions=0;renewalRefused=0;leaseElapsed=0.') + for name in cases: + case = {**self.case(1), 'name': name} + raw = {'Messages': [{'Category': 'StdOutMsgs', 'Text': 'PRIVATE-CANARY\n' + sentence}]} + actual = self.owner.closed_failure_details(case, raw) + self.assertEqual('recurrence_runtime', actual['stage']) + self.assertEqual('children', actual['boundary']) + self.assertEqual(1, actual['completed']) + self.assertEqual(2, actual['recipients']) + self.assertNotIn('PRIVATE-CANARY', json.dumps(actual)) + result = self.execution_result(case) + result['Outcome'] = 2 + result['Messages'] = raw['Messages'] + stream = io.StringIO() + progress = self.progress(self.root / ('recurrence-' + str(len(name)) + '.diag'), + 'LearnStack.Tests.Integration', [case], stream) + with contextlib.redirect_stdout(io.StringIO()): + progress.results({'NewTestResults': [result]}) + self.assertEqual(actual, progress.failure_locations[0]['details']) + self.assertEqual({'Failed': 1}, dict(progress.outcomes)) + self.assertEqual([], progress.success_observations) + self.assertNotIn('PRIVATE-CANARY', stream.getvalue() + json.dumps(progress.failure_locations)) + progress.close() + for old, new in (('children', 'private'), ('Running', 'Unknown'), ('completed=1', 'completed=01'), + ('executed=1', 'executed=-1'), ('recipients=2', 'recipients=10001')): + self.assertIsNone(self.owner.closed_failure_details(case, + {'Messages': [{'Category': 'StdOutMsgs', 'Text': sentence.replace(old, new)}]})) + for category, dirty in (('StdErrMsgs', sentence), ('StdOutMsgs', sentence + '\n' + sentence), + ('StdOutMsgs', sentence + 'PRIVATE-CANARY')): + self.assertIsNone(self.owner.closed_failure_details(case, + {'Messages': [{'Category': category, 'Text': dirty}]})) + self.assertIsNone(self.owner.closed_failure_details({**case, 'assembly': 'foreign'}, raw)) + self.assertIsNone(self.owner.closed_failure_details({**case, 'name': name + '-foreign'}, raw)) + unavailable = self.owner.closed_failure_details(case, {'Messages': [{'Category': 'StdOutMsgs', + 'Text': sentence.replace('completed=1', 'completed=unavailable')}]}) + self.assertIsNone(unavailable['completed']) + + def test_native_handoff_first_failure_retains_only_closed_task_boundary_and_lease_facts(self): + case = {**self.case(1), 'name': self.owner.NATIVE_HANDOFF_FAILURE_CASE} + sentence = ('Native handoff failure;boundary=cleanup_enter;kind=cancellation;pulse=pending;' + 'helper=not_issued;pacing=pending;cleanup=pending;joined=pending;constructor=pending;' + 'executions=1;renewalRefused=0;leaseElapsed=0.') + raw = {'Messages': [{'Category': 'StdOutMsgs', 'Text': 'PRIVATE-CANARY\n' + sentence}]} + actual = self.owner.closed_failure_details(case, raw) + self.assertEqual('native_handoff', actual['stage']) + self.assertEqual('cleanup_enter', actual['boundary']) + self.assertEqual(1, actual['executions']) + self.assertNotIn('PRIVATE-CANARY', json.dumps(actual)) + for old, new in (('cleanup_enter', 'private'), ('kind=cancellation', 'kind=private'), + ('helper=not_issued', 'helper=complete'), ('executions=1', 'executions=10001'), + ('renewalRefused=0', 'renewalRefused=01')): + self.assertIsNone(self.owner.closed_failure_details(case, + {'Messages': [{'Category': 'StdOutMsgs', 'Text': sentence.replace(old, new)}]})) + for category, dirty in (('StdErrMsgs', sentence), ('StdOutMsgs', sentence + '\n' + sentence), + ('StdOutMsgs', sentence + 'PRIVATE-CANARY')): + self.assertIsNone(self.owner.closed_failure_details(case, {'Messages': [{'Category': category, 'Text': dirty}]})) + self.assertIsNone(self.owner.closed_failure_details({**case, 'assembly': 'foreign'}, raw)) + + def test_nonzero_test_exit_refuses_even_with_all_passed_complete_protocol_and_trx(self): + manifest = self.selector_fixture().manifest() + private, output, expected = self.run_workload(manifest, 0, 'zero-exit') + self.assertEqual(len(expected), self.owner.validate_results(output, expected)) + self.assertTrue(self.owner.read_json(private / 'workload.json')['success']) + self.refuses('tests_failed', self.run_workload, manifest, 7, 'nonzero-exit') + refused_output = self.root / 'output-nonzero-exit' + self.assertEqual(len(expected), self.owner.validate_results(refused_output, expected)) + self.assertFalse((self.root / 'private-nonzero-exit/workload.json').exists()) + + def test_workload_rejects_assignments_that_only_renumber_bins(self): + self.refuses('assignment_not_distinct', self.run_workload, + self.selector_fixture().renumber_only_manifest(), 0, 'renumber-only') + + def test_missing_or_invalid_execution_and_trx_durations_are_not_completion_evidence(self): + case = self.case(1) + stream = io.StringIO() + clean = self.progress(self.root / 'duration-clean.diag', 'LearnStack.Tests.Integration', [case], stream) + with contextlib.redirect_stdout(io.StringIO()): + clean.results({'NewTestResults': [self.execution_result(case)]}) + self.assertEqual('00:00:00.0010000', json.loads(stream.getvalue())['duration']) + for index, duration in enumerate((None, '', '-00:00:01', 'PRIVATE-CANARY', 1)): + result = self.execution_result(case) + if duration is None: + result.pop('Duration') + else: + result['Duration'] = duration + progress = self.progress(self.root / f'duration-{index}.diag', + 'LearnStack.Tests.Integration', [case], io.StringIO()) + self.refuses('execution_duration_invalid', progress.results, {'NewTestResults': [result]}) + path = self.trx(self.root / f'trx-duration-{index}', [case], raw_canary=True) + root = ET.parse(path) + row = root.find('t:Results/t:UnitTestResult', self.owner.NS) + if duration is None: + row.attrib.pop('duration') + else: + row.set('duration', str(duration)) + root.write(path, encoding='utf-8') + self.refuses('trx_duration_invalid', self.owner.trx_cases, path, sanitize=True) + self.assertIn(b'PRIVATE-CANARY', path.read_bytes()) + def test_rotated_execution_preserves_old_tail_partial_line_and_exact_case_ids(self): cases = self.population()['ordinary'] path = self.root / 'rotate.diag' diff --git a/scripts/test-ci-test-shard-results.py b/scripts/test-ci-test-shard-results.py index eaa56e62..7dbc2018 100644 --- a/scripts/test-ci-test-shard-results.py +++ b/scripts/test-ci-test-shard-results.py @@ -7,6 +7,7 @@ import json import os from pathlib import Path +import re import stat import subprocess import tempfile @@ -328,6 +329,25 @@ def test_real_current_workflow_topology_and_both_full_synthetic_unions(self): self.assertEqual('A' if attempt == '1' else 'B', result['variant']) self.assertTrue(RESULTS.verify_jobs(self.jobs(scope, self.context(attempt)), self.context(attempt), self.plan, scope)) + def test_aggregate_refuses_complete_receipts_whose_assignments_only_renumber_bins(self): + manifest = self.fixture.renumber_only_manifest() + for scope in ('backend', 'integration'): + with self.subTest(scope=scope), tempfile.TemporaryDirectory() as temporary: + paths = self.outputs(temporary, scope, manifest=manifest) + self.refuses('assignment_not_distinct', RESULTS.validate_outputs, + paths, self.context(), self.plan, scope) + + def test_prepared_images_match_the_actual_provider_and_database_fixture_sources(self): + provider = (ROOT / 'backend/tests/LearnStack.Tests.Integration/TenancyIdentity/KeycloakFixture.cs').read_text() + database = (ROOT / 'backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs').read_text() + provider_images = re.findall(r'\bconst string Image = "([^"\n]+)";', provider) + database_images = re.findall(r'new PostgreSqlBuilder\("([^"\n]+)"\)', database) + provider_databases = re.findall(r'new PostgreSqlBuilder\("([^"\n]+)"\)', provider) + self.assertEqual(1, len(provider_images)) + self.assertEqual(1, len(database_images)) + self.assertEqual(database_images, provider_databases) + self.assertEqual(tuple(provider_images + database_images), RESULTS.FIXTURE_IMAGES) + def test_fixture_image_preparation_uses_fixed_images_and_preserves_cleanup_reserve(self): environment = {'GITHUB_ACTIONS': 'true', 'RUNNER_ENVIRONMENT': 'github-hosted', 'GITHUB_JOB': 'backend-integration-tests', 'LEARNSTACK_CI_STARTED_AT': '1000'} @@ -336,13 +356,49 @@ def test_fixture_image_preparation_uses_fixed_images_and_preserves_cleanup_reser mock.patch.object(RESULTS.time, 'time', return_value=1700), \ mock.patch.object(RESULTS.time, 'monotonic', return_value=500), \ mock.patch.object(RESULTS.sys, 'platform', 'linux'), \ - mock.patch.object(RESULTS.OWNER, 'command', return_value=b'') as command, \ + mock.patch.object(RESULTS.OWNER, 'command', return_value=b'PRIVATE-CANARY') as command, \ mock.patch('sys.stdout', output): self.assertEqual(0, RESULTS.prepare_fixture_images()) + self.assertEqual(('quay.io/keycloak/keycloak:26.6.2', 'postgres:18.4-alpine'), RESULTS.FIXTURE_IMAGES) self.assertEqual([mock.call(['/usr/bin/docker', 'pull', image], 610, - 'fixture_image_pull_failed', cap=55) for image in RESULTS.FIXTURE_IMAGES], + 'fixture_image_pull_failed_' + component, cap=55, diagnose=True) + for component, image in zip(('keycloak', 'postgres'), RESULTS.FIXTURE_IMAGES)], command.call_args_list) self.assertEqual(list(RESULTS.FIXTURE_IMAGES), [row['image'] for row in json.loads(output.getvalue())['images']]) + self.assertNotIn('PRIVATE-CANARY', output.getvalue()) + for index, component in enumerate(('keycloak', 'postgres')): + output = io.StringIO() + reason = 'fixture_image_pull_failed_' + component + '_rate_limit' + with mock.patch.dict(os.environ, environment, clear=True), \ + mock.patch.object(RESULTS.time, 'time', return_value=1700), \ + mock.patch.object(RESULTS.time, 'monotonic', return_value=500), \ + mock.patch.object(RESULTS.sys, 'platform', 'linux'), \ + mock.patch.object(RESULTS.OWNER, 'command', + side_effect=[b'PRIVATE-CANARY'] * index + [RESULTS.OWNER.Refusal(reason)]) as command, \ + mock.patch('sys.stdout', output): + self.refuses(reason, RESULTS.prepare_fixture_images) + self.assertEqual(index + 1, command.call_count, 'No failed pull is retried or followed by another image') + self.assertEqual('', output.getvalue()) + with mock.patch.dict(os.environ, environment, clear=True), \ + mock.patch.object(RESULTS.time, 'time', return_value=1960), \ + mock.patch.object(RESULTS.time, 'monotonic', return_value=500), \ + mock.patch.object(RESULTS.sys, 'platform', 'linux'), \ + mock.patch.object(RESULTS.OWNER, 'command', return_value=b'') as command, \ + mock.patch('sys.stdout', io.StringIO()): + self.assertEqual(0, RESULTS.prepare_fixture_images()) + self.assertEqual(2, command.call_count) + self.assertTrue(all(call.args[1] == 500 + RESULTS.OWNER.JOB_SECONDS + - RESULTS.OWNER.RESERVE_SECONDS - 960 for call in command.call_args_list)) + self.assertTrue(all(call.kwargs == {'cap': 55, 'diagnose': True} for call in command.call_args_list)) + for images in ((), ('one',), ('one', 'two', 'three')): + with mock.patch.dict(os.environ, environment, clear=True), \ + mock.patch.object(RESULTS.time, 'time', return_value=1700), \ + mock.patch.object(RESULTS.time, 'monotonic', return_value=500), \ + mock.patch.object(RESULTS.sys, 'platform', 'linux'), \ + mock.patch.object(RESULTS, 'FIXTURE_IMAGES', images), \ + mock.patch.object(RESULTS.OWNER, 'command') as command: + self.refuses('fixture_setup_images_invalid', RESULTS.prepare_fixture_images) + command.assert_not_called() with mock.patch.dict(os.environ, environment, clear=True), \ mock.patch.object(RESULTS.time, 'time', return_value=2000), \ mock.patch.object(RESULTS.sys, 'platform', 'linux'), \ @@ -395,6 +451,12 @@ def test_topology_rejects_wrong_counts_authority_gates_permissions_and_canonical if step.get('name') == RESULTS.UPLOAD_STEP).update(uses='actions/upload-artifact@' + 'f' * 40)), ('fixture_image_gate_invalid', lambda jobs: next(step for step in jobs['backend-integration-tests']['steps'] if step.get('run') == 'python3 scripts/ci-test-shards.py prepare-fixtures').update({'if': 'success()'})), + ('fixture_compile_gate_invalid', lambda jobs: next(step for step in jobs['backend-integration-tests']['steps'] + if step.get('name') == 'Compile native web and authenticated SDK fixture entries').update({'if': 'false'})), + ('fixture_compile_gate_invalid', lambda jobs: next(step for step in jobs['backend-integration-tests']['steps'] + if step.get('name') == 'Compile native web and authenticated SDK fixture entries').update(run='true')), + ('fixture_compile_gate_invalid', lambda jobs: next(step for step in jobs['backend-integration-tests']['steps'] + if step.get('name') == 'Compile native web and authenticated SDK fixture entries').update({'working-directory': '.'})), ('aggregate_topology_invalid', lambda jobs: jobs['backend'].update({'if': 'success()'})), ('aggregate_topology_invalid', lambda jobs: jobs['backend-integration'].update(needs=['backend-native'])), ('aggregate_topology_invalid', lambda jobs: jobs['backend'].update({'timeout-minutes': 6})), @@ -410,6 +472,13 @@ def test_topology_rejects_wrong_counts_authority_gates_permissions_and_canonical documents['.github/workflows/other.yml']['jobs']['copy']['name'] = '${{ matrix.name }}' self.refuses('unqualified_job_name', RESULTS.workflow_topology, documents, self.plan) + def test_root_workflow_permissions_cannot_be_widened(self): + for permissions in ({'contents': 'write'}, {'contents': 'read', 'actions': 'read'}, 'read-all', None): + with self.subTest(permissions=permissions): + documents = copy.deepcopy(self.documents) + documents['.github/workflows/ci.yml']['permissions'] = permissions + self.refuses('workflow_permissions_invalid', RESULTS.workflow_topology, documents, self.plan) + def test_attempt_api_refuses_stale_missing_skipped_failed_duplicate_or_oversized_jobs(self): context = self.context('2'); clean = self.jobs('backend', context) self.assertTrue(RESULTS.verify_jobs(clean, context, self.plan, 'backend')) @@ -658,7 +727,7 @@ def test_common_base_witness_refuses_cross_scope_classification_or_toolchain_dri integration, self.context(), self.plan, 'integration') def test_actual_trx_and_completed_fields_are_rechecked_even_with_matching_hashes(self): - for changed in ('completed', 'trx', 'missing'): + for changed in ('completed', 'trx', 'missing', 'missing_duration', 'outside_partition'): with tempfile.TemporaryDirectory() as temporary: paths = self.outputs(temporary, 'backend'); base = paths['base'] if changed == 'completed': @@ -667,11 +736,43 @@ def test_actual_trx_and_completed_fields_are_rechecked_even_with_matching_hashes elif changed == 'trx': path = next((base / 'results').glob('*.trx')); value = path.read_text().replace('outcome="Passed"', 'outcome="NotExecuted"') path.write_text(value); reason = 'trx_counters_invalid' + elif changed == 'missing_duration': + path = next((base / 'results').glob('*.trx')); root = ET.parse(path) + root.find('t:Results/t:UnitTestResult', RESULTS.OWNER.NS).attrib.pop('duration') + root.write(path, encoding='utf-8'); reason = 'trx_duration_invalid' + elif changed == 'outside_partition': + # Matching hashes, counters and completed evidence cannot + # turn a discovered case owned by another shard into base work. + foreign = self.fixture.manifest()['ordinary'][0] + path = next((base / 'results').glob('*.trx')); root = ET.parse(path) + definition = root.find('t:TestDefinitions/t:UnitTest', RESULTS.OWNER.NS) + definition.set('id', foreign['id']); definition.set('storage', foreign['assembly'] + '.dll') + row = root.find('t:Results/t:UnitTestResult', RESULTS.OWNER.NS) + row.set('testId', foreign['id']); row.set('testName', foreign['name']) + root.write(path, encoding='utf-8') + (base / 'completed-cases.jsonl').write_text(json.dumps({ + **{key: foreign[key] for key in ('assembly', 'id', 'name')}, + 'outcome': 'Passed', 'duration': '00:00:00.001'}) + '\n') + reason = 'trx_case_name_mismatch' else: (base / 'completed-cases.jsonl').write_bytes(b''); reason = 'completed_evidence_incomplete' self.rehash(base) self.refuses(reason, RESULTS.validate_outputs, paths, self.context(), self.plan, 'backend') + def test_docker_absence_is_required_only_for_actual_docker_owners(self): + with tempfile.TemporaryDirectory() as temporary: + paths = self.outputs(temporary, 'integration') + assignment = PLAN.assign(self.fixture.manifest(), self.plan, '1') + for owner in ('base', 'native', 'ordinary-0'): + path = paths[owner]; clean = RESULTS.read_json(path / 'result.json') + self.assertTrue(RESULTS.verify_receipt(path, self.context(), owner, self.plan, assignment)) + for absent in ((True, False, 0) if owner == 'base' else (False, None, 1, 'true')): + with self.subTest(owner=owner, absent=absent): + self.write(path, 'result.json', {**clean, 'dockerAbsent': absent}) + self.refuses('execution_authority_mismatch', RESULTS.verify_receipt, + path, self.context(), owner, self.plan, assignment) + self.write(path, 'result.json', clean) + def test_native_scope_provisioning_crash_witness_and_no_dump_refusals_remain_mandatory(self): with tempfile.TemporaryDirectory() as temporary: paths = self.outputs(temporary, 'integration'); native = paths['native'] diff --git a/scripts/test-ci-test-shards.py b/scripts/test-ci-test-shards.py index a183917c..030155f8 100644 --- a/scripts/test-ci-test-shards.py +++ b/scripts/test-ci-test-shards.py @@ -51,6 +51,18 @@ def manifest(self): 'allTests': base + architecture + native + ordinary, 'nonDocker': base + architecture, 'all': native + ordinary, 'native': native, 'ordinary': ordinary} + def renumber_only_manifest(self): + # One method group per bin makes both assignments complete but changes + # only bin numbers. The callers must reject this false independence. + manifest = self.manifest() + manifest['ordinary'] = manifest['ordinary'][:self.plan['counts']['ordinary'] * 2] + architecture = [case for case in manifest['nonDocker'] + if case['assembly'] == 'learnstack.tests.architecture'][:self.plan['counts']['architecture']] + manifest['nonDocker'] = manifest['nonDocker'][:1] + architecture + manifest['all'] = manifest['native'] + manifest['ordinary'] + manifest['allTests'] = manifest['nonDocker'] + manifest['all'] + return manifest + def refuses(self, reason, function, *args): with self.assertRaises(OWNER.Refusal) as caught: function(*args) @@ -232,6 +244,19 @@ def test_missing_population_foreign_and_duplicate_identity_are_rejected(self): self.refuses(reason, OWNER.validate_manifest, dirty) self.assertTrue(OWNER.validate_manifest(clean)) + def test_outer_population_union_refuses_self_consistent_inner_omissions(self): + for missing_from in ('allTests', 'partitions'): + with self.subTest(missing_from=missing_from): + manifest = self.manifest() + omitted = manifest['ordinary'][-1] + for part in (('allTests',) if missing_from == 'allTests' else ('ordinary', 'all')): + manifest[part] = [case for case in manifest[part] if case != omitted] + # Native/ordinary are still disjoint and exactly cover all + # Docker rows; only the outer allTests equality can refuse. + self.assertEqual(OWNER.population(manifest['all']), + OWNER.population(manifest['native'] + manifest['ordinary'])) + self.refuses('population_union_invalid', OWNER.validate_manifest, manifest) + def test_display_names_are_neither_identity_nor_method_authority(self): manifest = self.manifest() for case in manifest['allTests']: