@teamscala/service-boot 0.23.503 → 0.23.506

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/package.json +2 -1
  2. package/src/agreement-gate/signing-shell.test.ts +9 -15
  3. package/src/agreement-gate.test.ts +8 -15
  4. package/src/asset-proxy.test.ts +9 -9
  5. package/src/attach-portal-members.test.ts +9 -13
  6. package/src/attach-transfers-upload.test.ts +9 -10
  7. package/src/auth-gate.test.ts +8 -7
  8. package/src/bind-page-handler.ts +24 -8
  9. package/src/build-client-assets/manifest.test.ts +8 -14
  10. package/src/build-client-assets/source-candidates.test.ts +9 -11
  11. package/src/build-client-assets/sourcemaps-upload.test.ts +8 -15
  12. package/src/build-client-assets/styles-build.test.ts +9 -15
  13. package/src/build-client-assets.test.ts +8 -12
  14. package/src/capability-slug.test.ts +9 -0
  15. package/src/compose-page-title.test.ts +8 -9
  16. package/src/content-gate/access.ts +11 -0
  17. package/src/content-gate/catalog.test.ts +8 -8
  18. package/src/content-gate/config-types.ts +15 -0
  19. package/src/content-gate/gate-entry-clears.ts +38 -0
  20. package/src/content-gate/gate-entry-marker.test.ts +102 -0
  21. package/src/content-gate/gate.ts +16 -0
  22. package/src/content-gate/token.test.ts +8 -13
  23. package/src/content-page-floor.test.ts +9 -11
  24. package/src/csrf-body-limit.test.ts +9 -15
  25. package/src/handlers/messaging-send-reconcile-logic.test.ts +9 -11
  26. package/src/handlers/messaging-send-recording-logic.test.ts +9 -9
  27. package/src/handlers/messaging-send-recording-watch.test.ts +9 -11
  28. package/src/handlers/messaging-send-sweep-selection.test.ts +9 -7
  29. package/src/handlers/platform-fault-escalation/probe.test.ts +9 -10
  30. package/src/handlers/platform-fault-escalation-refresh.test.ts +9 -22
  31. package/src/handlers/platform-fault-escalation.test.ts +9 -10
  32. package/src/handlers/scala-tools-currency-watch.test.ts +9 -0
  33. package/src/handlers/singleton-tools-currency-watch.test.ts +9 -0
  34. package/src/handlers/whatsapp-liveness-handler.test.ts +9 -12
  35. package/src/handlers/whatsapp-liveness.test.ts +9 -9
  36. package/src/interpolate-meta.ts +123 -0
  37. package/src/is-react-element-type.test.ts +8 -9
  38. package/src/loader-errors.ts +41 -0
  39. package/src/module-not-found.test.ts +8 -12
  40. package/src/mount-orpc-router.test.ts +8 -9
  41. package/src/page-server-orpc/server-client.test.ts +8 -17
  42. package/src/quiz-gate.ts +10 -0
  43. package/src/read-html-template.test.ts +8 -12
  44. package/src/register-registry-page-route/apply-page-branding.test.ts +8 -19
  45. package/src/register-registry-page-route/captured-loader-config.test.ts +9 -13
  46. package/src/register-registry-page-route/graph-cache.test.ts +9 -12
  47. package/src/register-registry-page-route/live-loader-config.test.ts +8 -24
  48. package/src/register-registry-page-route/meta-title.ts +1 -1
  49. package/src/register-registry-page-route/page-data-shared.test.ts +9 -12
  50. package/src/register-registry-page-route/public-shell-page-data.test.ts +8 -19
  51. package/src/register-registry-page-route/resolve-entity-title.test.ts +8 -7
  52. package/src/register-registry-page-route/resolve-page-data.test.ts +9 -13
  53. package/src/register-registry-page-route/resolve-request-graph.test.ts +9 -13
  54. package/src/resolve-from-loader-config.test.ts +117 -14
  55. package/src/resolve-from-loader-config.ts +28 -115
  56. package/src/resolve-service-port.test.ts +8 -12
  57. package/src/resolvers/app-header.test.ts +9 -5
  58. package/src/resolvers/fetch-org-brand.ts +18 -2
  59. package/src/resolvers/portal-route-paths.test.ts +8 -13
  60. package/src/resource-chat-inject.page-assistant.test.ts +8 -28
  61. package/src/resource-chat-inject.test.ts +8 -25
  62. package/src/resource-not-found.test.ts +9 -8
  63. package/src/security-headers.test.ts +9 -12
  64. package/src/select-service-routes.test.ts +9 -0
  65. package/src/server-error-page.test.ts +8 -10
  66. package/src/theme-fonts-link.test.ts +8 -10
  67. package/src/two-phase-full-app.test.ts +8 -16
  68. package/src/two-phase-health-gate-client-only.test.ts +8 -28
  69. package/src/two-phase-health-gate.test.ts +8 -28
package/package.json CHANGED
@@ -1116,6 +1116,7 @@
1116
1116
  "#service-boot/*.ts": "./src/*.ts"
1117
1117
  },
1118
1118
  "name": "@teamscala/service-boot",
1119
+ "capability": "Lower-tier boot stages composed by service-runtime — page-server SSR, asset proxy, hono pipeline, agreement gate (no-shadow-runtime-exports: only service-runtime composes them).",
1119
1120
  "peerDependencies": {
1120
1121
  "@teamscala/chat-completion": "*"
1121
1122
  },
@@ -1130,5 +1131,5 @@
1130
1131
  "repository": "https://github.com/teamgbg/service-boot",
1131
1132
  "sideEffects": false,
1132
1133
  "type": "module",
1133
- "version": "0.23.503"
1134
+ "version": "0.23.506"
1134
1135
  }
@@ -1,18 +1,12 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Behavioural pins for the agreement signing-shell units. The load-bearing
7
- * cases: the gate block carries the OTP switch as the STRING the renderer
8
- * validates against (a boolean is stripped before the block reaches the
9
- * component — the same coercion that bit the pre-placed gate), an empty
10
- * registry title falls back to the DB-driven default, the signing shell
11
- * ALWAYS renders (registry failure falls back to the hardcoded shell), the
12
- * shell is deep-cloned per call (injection mutates the tree in place), and
13
- * the pre-placed-gate probe recognises an agreement gate by its context
14
- * field — a PROPOSAL gate in the same region must not read as an agreement.
15
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
9
+
16
10
  import { describe, expect, mock, test } from "bun:test";
17
11
 
18
12
  let registryBlocks: unknown = undefined;
@@ -1,18 +1,11 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Regression test for the new-flow agreement gate's body-region targeting.
7
- * The body region of the public sign shell IS resolved.page (composed by
8
- * register-registry-page-route.ts Branch 2 as extractPuckData(resolved.page)).
9
- * A prior version of getBodyContentArray scored every region by top-level
10
- * non-shell block count and picked the highest — which misruted to /header when
11
- * the body wrapped its clauses inside a Container (body top-level count ~1-2
12
- * vs nested clause count ~40). onUnauthorized then cleared the HEADER and
13
- * injected the gate there, leaving the entire agreement body visible past the
14
- * gate. This test pins the fix: the body is withheld, the header is untouched.
15
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
16
9
 
17
10
  import { describe, expect, test } from "bun:test";
18
11
  import { agreementGateConfig } from "./agreement-gate.ts";
@@ -1,12 +1,12 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Co-located tests for handleAssetProxy's early-return paths: a non-asset path
7
- * 404s, and an asset path with no resolvable S3 client 502s (getClient returns
8
- * null when creds/config are absent — the documented "no client" contract).
9
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
9
+
10
10
  import { describe, expect, it } from "bun:test";
11
11
  import { handleAssetProxy } from "./asset-proxy.ts";
12
12
 
@@ -1,16 +1,12 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Behavioural pins for the portal member directory endpoint. The load-bearing
7
- * cases: org-scoped reads bind the orgId as a PARAMETER (never interpolated —
8
- * the orgId arrives from the query string), the all-orgs "View As" mode is
9
- * gated to admin/super_admin sessions (any other caller without an orgId is
10
- * a 400, and an unauthenticated caller never reaches the probe query at
11
- * all), and a database fault is reported as resolve-failed rather than a
12
- * 500 leaking driver detail.
13
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
9
+
14
10
  import { describe, expect, mock, test } from "bun:test";
15
11
  import { Hono } from "hono";
16
12
 
@@ -1,13 +1,12 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Co-located test for attachTransfersUpload: it registers the POST
7
- * /api/transfers/upload route on the host app. Hono is a type-only import
8
- * (the real app is built by the runtime), so the host interface is mocked
9
- * here to exercise registration without the Hono dependency.
10
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
9
+
11
10
  import { describe, expect, it } from "bun:test";
12
11
  import { attachTransfersUpload } from "./attach-transfers-upload.ts";
13
12
 
@@ -1,10 +1,11 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The signed-out screen must be able to render its own branding.
7
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
8
9
 
9
10
  import { describe, expect, test } from "bun:test";
10
11
  import { isStaticAssetPath } from "./auth-gate.ts";
@@ -22,6 +22,10 @@
22
22
  // wave-parallel loader, brotli) is the sanctioned path; re-adding shared-cache headers needs a fresh operator ruling.
23
23
 
24
24
  import { getAppLogger } from "@teamscala/logger/app-loggers";
25
+ import {
26
+ withGateEntryClears,
27
+ takeGateEntryClears,
28
+ } from "./content-gate/gate-entry-clears.ts";
25
29
  import type { Context } from "hono";
26
30
  import { readHtmlTemplate } from "./read-html-template.ts";
27
31
  import {
@@ -123,15 +127,27 @@ export async function bindPageHandler(
123
127
 
124
128
  const nonce = c.get("nonce") as string | undefined;
125
129
 
126
- const resp = await handler(c.req.raw, {
127
- userId,
128
- orgId,
129
- visitor,
130
- searchParams: c.req.query(),
131
- htmlTemplate: await readHtmlTemplate(),
132
- nonce,
133
- });
130
+ const resp = await withGateEntryClears(async () =>
131
+ handler(c.req.raw, {
132
+ userId,
133
+ orgId,
134
+ visitor,
135
+ searchParams: c.req.query(),
136
+ htmlTemplate: await readHtmlTemplate(),
137
+ nonce,
138
+ }),
139
+ );
134
140
  const headers = new Headers(resp.headers as HeadersInit);
141
+ // Gate-entry markers are ONE-VIEW state: the render that consumed one
142
+ // clears it here, so the next load (a refresh) is unauthorized and shows
143
+ // the gate again. Without this the marker would outlive the view that
144
+ // used it and every load would skip the gate.
145
+ for (const name of takeGateEntryClears()) {
146
+ headers.append(
147
+ "Set-Cookie",
148
+ `${name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`,
149
+ );
150
+ }
135
151
  headers.set(
136
152
  "Cache-Control",
137
153
  "no-store, no-cache, no-transform, must-revalidate, max-age=0",
@@ -1,17 +1,11 @@
1
- /**
2
- * @system server-handlers
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * verifyClientBuildAssets is the capability probe the readiness flip gates on
7
- * (`a-component-may-not-report-a-state-it-has-not-verified`): a manifest is
8
- * servable only when BOTH assets it names exist on disk. Measured 2026-09-01
9
- * (the unstyled-proposal incident): a manifest that never ran — or a first-boot
10
- * tombstone carrying the unhashed FALLBACK_MANIFEST paths — read as a
11
- * legitimate bundle and the service served pages whose stylesheet 404'd while
12
- * every health surface reported ok. These tests pin the refusal for all three
13
- * unbuilt shapes and the pass for a real build.
14
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
15
9
 
16
10
  import { mkdir, mkdtemp, rm } from "node:fs/promises";
17
11
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
@@ -1,14 +1,12 @@
1
- /**
2
- * @system server-handlers
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Fixtures for the @source candidate scan. The load-bearing case is the
7
- * DIRECTORY probe: sources are directories, and a Bun.file(dir).exists()
8
- * probe answers false for them — which silently skipped every source,
9
- * zeroed the candidate set, and stripped all Tailwind utilities from every
10
- * kind:ui service's stylesheet (2026-09-01, operator-visible).
11
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
9
+
12
10
  import { describe, expect, test } from "bun:test";
13
11
  import { mkdir, rm } from "node:fs/promises";
14
12
  import { join } from "node:path";
@@ -1,18 +1,11 @@
1
- /**
2
- * @system server-handlers
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Behaviour tests for the source-map upload unit. Two measured defects are
7
- * regression-locked here: (1) the pre-2026-09 flow deleted the maps after an
8
- * upload whose responses were never checked, and (2) the direct release-files
9
- * POST it used returns HTTP 405 on GlitchTip, so NOTHING had ever uploaded.
10
- * The CLI integration is verified live on the dev host (57/57 of the
11
- * scala-roadmap release 3b940e0030 uploaded clean); in-test subprocess
12
- * verification is deferred (spawnSync blocks the bun event loop, which would
13
- * deadlock a same-process mock server). These tests cover the pure
14
- * boundaries the verification layer depends on.
15
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
16
9
 
17
10
  import { mkdtemp, mkdir, readdir, rm } from "node:fs/promises";
18
11
  import { tmpdir } from "node:os";
@@ -1,18 +1,12 @@
1
- /**
2
- * @system server-handlers
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Behavioural pins for the stylesheet resolver (operator ruling 2026-09-03:
7
- * the fallback chain IS the drift — a bare id resolved via Bun.resolve
8
- * returned whatever the package's main was (a .js for a normal package),
9
- * the resolve(base, id) tail invented nonexistent paths whose ENOENT
10
- * surfaced as misleading downstream CSS errors, and inlineCssImports
11
- * SWALLOWED unresolved imports, leaving literals that failed downstream
12
- * instead of at the build. Every case below pins the ruled contract:
13
- * resolve or THROW naming the specifier; remote https imports are skipped
14
- * explicitly, never resolved and never thrown on.
15
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
9
+
16
10
  import { describe, expect, test } from "bun:test";
17
11
  import { mkdir, rm } from "node:fs/promises";
18
12
  import { join } from "node:path";
@@ -1,15 +1,11 @@
1
- /**
2
- * @system server-handlers
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Regression guard for stripRemoteImports. Third-party package CSS must not
7
- * inject a remote @import (the dropped/CSP-blocked Inter-font class, scala-docs
8
- * 2026-06-13): @puckeditor/core/dist/index.css leads with `@import
9
- * "https://rsms.me/inter/inter.css"`, a CDN our CSP forbids. The compiled
10
- * bundle must carry zero remote @imports; the app loads its font from the
11
- * CSP-allowlisted Google Fonts <link> in index.html instead.
12
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
13
9
 
14
10
  import { mkdtemp, mkdir, rm } from "node:fs/promises";
15
11
  import { tmpdir } from "node:os";
@@ -1,3 +1,12 @@
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
9
+
1
10
  import { describe, expect, test } from "bun:test";
2
11
  import { toCapabilitySlug } from "./capability-slug.ts";
3
12
 
@@ -1,12 +1,11 @@
1
- /**
2
- * @system ui-service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Pins composePageTitle: the single canonical title composer. The title is
7
- * derived from website + page/entity name with meta_title as an explicit
8
- * per-page OVERRIDE only, and it NEVER degrades to a bare org name.
9
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
10
9
 
11
10
  import { describe, expect, test } from "bun:test";
12
11
  import { composePageTitle } from "./compose-page-title.ts";
@@ -101,6 +101,17 @@ export async function attachContentGateAccess(
101
101
  "Set-Cookie",
102
102
  `${config.cookieName}=${encodeURIComponent(token)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`,
103
103
  );
104
+ if (config.entryCookieName) {
105
+ // The ENTRY MARKER: it authorizes exactly the redirect this sets it
106
+ // on — the gate requires it and the render it consumed clears it.
107
+ // Max-Age matches the token only as a safety net; consumption is
108
+ // what ends its life, never the clock.
109
+ c.header(
110
+ "Set-Cookie",
111
+ `${config.entryCookieName}=1; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`,
112
+ { append: true },
113
+ );
114
+ }
104
115
  }
105
116
  return c.redirect(back, 302);
106
117
  });
@@ -1,11 +1,11 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The catalog's invariants — the ones that were unenforceable while the gate
7
- * list lived as two hand-written arrays in two files.
8
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
9
9
 
10
10
  import { describe, expect, test } from "bun:test";
11
11
  import {
@@ -70,6 +70,15 @@ export interface ContentGateConfig {
70
70
  * capable so a gate can load its display/behaviour settings from a registry
71
71
  * row (DB-driven, not hardcoded); sync implementations remain valid. */
72
72
  onUnauthorized(resolved: Resolved, resourceId: string): void | Promise<void>;
73
+ /**
74
+ * OPT-IN REQUIRED-GATE-ENTRY. When set, a valid token authorizes ONLY on a
75
+ * request that also carries this marker cookie — set by the access endpoint
76
+ * alongside the token and consumed (cleared) by the render it authorizes, so
77
+ * the marker lives exactly one view: the submit's own redirect releases the
78
+ * content, and every later load (a refresh) is unauthorized, showing the
79
+ * gate again. Omit for gates whose token is session-scoped by design.
80
+ */
81
+ entryCookieName?: string;
73
82
  }
74
83
 
75
84
  /** Drives the public access endpoint (access.ts / attachContentGateAccess). */
@@ -80,6 +89,12 @@ export interface AccessConfig {
80
89
  cookieName: string;
81
90
  /** Cookie lifetime in ms (Max-Age). */
82
91
  ttlMs: number;
92
+ /**
93
+ * Marker cookie set alongside the token on a match. The gate requires it
94
+ * (ContentGateConfig.entryCookieName) and the render it authorizes clears
95
+ * it, so it authorizes exactly the submit's own redirect.
96
+ */
97
+ entryCookieName?: string;
83
98
  /** Form field carrying the resource id, e.g. "proposalId". */
84
99
  recordIdField: string;
85
100
  /**
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * REQUEST-SCOPED channel for gate-entry marker clears. A gate that declares an
7
+ * entry marker (config.entryCookieName) consumes it on the render that the
8
+ * access endpoint's own redirect produces; that render's response must clear
9
+ * the marker, or the next load (a refresh) would still carry it and skip the
10
+ * gate. The gate writes the name here; bindPageHandler — which owns the page
11
+ * Response — drains it into Set-Cookie, so the marker lives exactly one view.
12
+ *
13
+ * AsyncLocalStorage rather than a module-level map: request-scoped by
14
+ * construction, no accumulated process state that a restart would silently
15
+ * reset (in-memory-state-is-not-durable).
16
+ */
17
+ import { AsyncLocalStorage } from "node:async_hooks";
18
+
19
+ const clears = new AsyncLocalStorage<string[]>();
20
+
21
+ export async function withGateEntryClears<T>(fn: () => Promise<T>): Promise<T> {
22
+ return clears.run([], fn);
23
+ }
24
+
25
+ export function recordGateEntryClear(cookieName: string): void {
26
+ const list = clears.getStore();
27
+ // Outside a bound request there is no response to write to (a direct gate
28
+ // run, a unit test). Dropping the record is the only correct answer —
29
+ // throwing would make an otherwise valid direct invocation a failure.
30
+ if (list && !list.includes(cookieName)) list.push(cookieName);
31
+ }
32
+
33
+ export function takeGateEntryClears(): string[] {
34
+ const list = clears.getStore() ?? [];
35
+ const out = [...list];
36
+ list.length = 0;
37
+ return out;
38
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Locks the OPT-IN gate-entry marker. A gate that does NOT declare
7
+ * entryCookieName must behave exactly as before — a valid token authorizes on
8
+ * any load and nothing is recorded for clearing (condition 1: every non-opting
9
+ * service unchanged). An opting gate authorizes ONLY the request that carries
10
+ * the marker (the identity submit's own redirect) and records the marker so the
11
+ * page Response clears it — one view, then the gate is presented again.
12
+ *
13
+ * Operator ruling 2026-09-14 (refresh must return to the identity gate),
14
+ * mechanism measured live: the quiz_token cookie alone was the carrier that
15
+ * skipped the gate on reload.
16
+ */
17
+ import { describe, expect, test } from "bun:test";
18
+ import type { ContentGateConfig } from "./config-types.ts";
19
+ import { runContentGate } from "./gate.ts";
20
+ import {
21
+ takeGateEntryClears,
22
+ withGateEntryClears,
23
+ } from "./gate-entry-clears.ts";
24
+
25
+ function makeConfig(entryCookieName?: string) {
26
+ const calls = { authorized: 0, unauthorized: 0 };
27
+ const cfg: ContentGateConfig = {
28
+ cookieName: "quiz_token",
29
+ entryCookieName,
30
+ getResourceId: () => "res-1",
31
+ verifyToken: (token) =>
32
+ token === "good" ? { email: "v@example.com", name: "V" } : null,
33
+ onAuthorized: () => {
34
+ calls.authorized++;
35
+ },
36
+ onUnauthorized: () => {
37
+ calls.unauthorized++;
38
+ },
39
+ };
40
+ return { cfg, calls };
41
+ }
42
+
43
+ describe("gate entry marker is opt-in and one-view", () => {
44
+ test("non-opting gate: a valid token authorizes on any load, nothing recorded — behaviour unchanged", async () => {
45
+ const { cfg, calls } = makeConfig();
46
+ await withGateEntryClears(async () => {
47
+ const out = await runContentGate({}, "quiz_token=good", Date.now(), cfg);
48
+ expect(out.authorized).toBe(true);
49
+ expect(calls.authorized).toBe(1);
50
+ expect(takeGateEntryClears()).toEqual([]);
51
+ });
52
+ });
53
+
54
+ test("opting gate: token WITHOUT the marker is unauthorized — a refresh shows the gate", async () => {
55
+ const { cfg, calls } = makeConfig("quiz_token_entered");
56
+ await withGateEntryClears(async () => {
57
+ const out = await runContentGate({}, "quiz_token=good", Date.now(), cfg);
58
+ expect(out.authorized).toBe(false);
59
+ expect(calls.unauthorized).toBe(1);
60
+ expect(calls.authorized).toBe(0);
61
+ expect(takeGateEntryClears()).toEqual([]);
62
+ });
63
+ });
64
+
65
+ test("opting gate: token + marker authorizes and records the marker for clearing, exactly once", async () => {
66
+ const { cfg, calls } = makeConfig("quiz_token_entered");
67
+ await withGateEntryClears(async () => {
68
+ const out = await runContentGate(
69
+ {},
70
+ "quiz_token=good; quiz_token_entered=1",
71
+ Date.now(),
72
+ cfg,
73
+ );
74
+ expect(out.authorized).toBe(true);
75
+ expect(calls.authorized).toBe(1);
76
+ expect(takeGateEntryClears()).toEqual(["quiz_token_entered"]);
77
+ // drained: the next load must not re-clear
78
+ expect(takeGateEntryClears()).toEqual([]);
79
+ });
80
+ });
81
+
82
+ test("opting gate: marker present but token invalid stays unauthorized and records nothing", async () => {
83
+ const { cfg, calls } = makeConfig("quiz_token_entered");
84
+ await withGateEntryClears(async () => {
85
+ const out = await runContentGate(
86
+ {},
87
+ "quiz_token=bad; quiz_token_entered=1",
88
+ Date.now(),
89
+ cfg,
90
+ );
91
+ expect(out.authorized).toBe(false);
92
+ expect(calls.unauthorized).toBe(1);
93
+ expect(takeGateEntryClears()).toEqual([]);
94
+ });
95
+ });
96
+
97
+ test("the quiz gate and its access endpoint both opt in on the same marker name", async () => {
98
+ const { quizAccessConfig, quizGateConfig } = await import("../quiz-gate.ts");
99
+ expect(quizGateConfig.entryCookieName).toBe("quiz_token_entered");
100
+ expect(quizAccessConfig.entryCookieName).toBe("quiz_token_entered");
101
+ });
102
+ });
@@ -27,6 +27,7 @@
27
27
 
28
28
  import type { ContentGateConfig } from "./config-types.ts";
29
29
  import { parseCookie } from "./parse-cookie.ts";
30
+ import { recordGateEntryClear } from "./gate-entry-clears.ts";
30
31
 
31
32
  export interface ContentGateOutcome {
32
33
  applies: boolean;
@@ -44,9 +45,24 @@ export async function runContentGate(
44
45
  if (!resourceId) return { applies: false, authorized: false };
45
46
 
46
47
  const token = parseCookie(cookieHeader, config.cookieName);
48
+
49
+ // A gate that declares an ENTRY MARKER authorizes only on the request that
50
+ // carries it — the one the access endpoint's own redirect produces. The
51
+ // marker is cleared on that render's response (bindPageHandler drains
52
+ // gate-entry-clears), so a refresh afterwards is unauthorized and the gate
53
+ // is presented again: that is the whole point of the marker.
54
+ if (config.entryCookieName) {
55
+ const entry = parseCookie(cookieHeader, config.entryCookieName);
56
+ if (entry === undefined) {
57
+ await config.onUnauthorized(resolved, resourceId);
58
+ return { applies: true, authorized: false };
59
+ }
60
+ }
61
+
47
62
  const identity = config.verifyToken(token, resourceId, nowMs);
48
63
 
49
64
  if (identity) {
65
+ if (config.entryCookieName) recordGateEntryClear(config.entryCookieName);
50
66
  await config.onAuthorized(resolved, resourceId, identity);
51
67
  return { applies: true, authorized: true, email: identity.email };
52
68
  }