@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.
- package/package.json +2 -1
- package/src/agreement-gate/signing-shell.test.ts +9 -15
- package/src/agreement-gate.test.ts +8 -15
- package/src/asset-proxy.test.ts +9 -9
- package/src/attach-portal-members.test.ts +9 -13
- package/src/attach-transfers-upload.test.ts +9 -10
- package/src/auth-gate.test.ts +8 -7
- package/src/bind-page-handler.ts +24 -8
- package/src/build-client-assets/manifest.test.ts +8 -14
- package/src/build-client-assets/source-candidates.test.ts +9 -11
- package/src/build-client-assets/sourcemaps-upload.test.ts +8 -15
- package/src/build-client-assets/styles-build.test.ts +9 -15
- package/src/build-client-assets.test.ts +8 -12
- package/src/capability-slug.test.ts +9 -0
- package/src/compose-page-title.test.ts +8 -9
- package/src/content-gate/access.ts +11 -0
- package/src/content-gate/catalog.test.ts +8 -8
- package/src/content-gate/config-types.ts +15 -0
- package/src/content-gate/gate-entry-clears.ts +38 -0
- package/src/content-gate/gate-entry-marker.test.ts +102 -0
- package/src/content-gate/gate.ts +16 -0
- package/src/content-gate/token.test.ts +8 -13
- package/src/content-page-floor.test.ts +9 -11
- package/src/csrf-body-limit.test.ts +9 -15
- package/src/handlers/messaging-send-reconcile-logic.test.ts +9 -11
- package/src/handlers/messaging-send-recording-logic.test.ts +9 -9
- package/src/handlers/messaging-send-recording-watch.test.ts +9 -11
- package/src/handlers/messaging-send-sweep-selection.test.ts +9 -7
- package/src/handlers/platform-fault-escalation/probe.test.ts +9 -10
- package/src/handlers/platform-fault-escalation-refresh.test.ts +9 -22
- package/src/handlers/platform-fault-escalation.test.ts +9 -10
- package/src/handlers/scala-tools-currency-watch.test.ts +9 -0
- package/src/handlers/singleton-tools-currency-watch.test.ts +9 -0
- package/src/handlers/whatsapp-liveness-handler.test.ts +9 -12
- package/src/handlers/whatsapp-liveness.test.ts +9 -9
- package/src/interpolate-meta.ts +123 -0
- package/src/is-react-element-type.test.ts +8 -9
- package/src/loader-errors.ts +41 -0
- package/src/module-not-found.test.ts +8 -12
- package/src/mount-orpc-router.test.ts +8 -9
- package/src/page-server-orpc/server-client.test.ts +8 -17
- package/src/quiz-gate.ts +10 -0
- package/src/read-html-template.test.ts +8 -12
- package/src/register-registry-page-route/apply-page-branding.test.ts +8 -19
- package/src/register-registry-page-route/captured-loader-config.test.ts +9 -13
- package/src/register-registry-page-route/graph-cache.test.ts +9 -12
- package/src/register-registry-page-route/live-loader-config.test.ts +8 -24
- package/src/register-registry-page-route/meta-title.ts +1 -1
- package/src/register-registry-page-route/page-data-shared.test.ts +9 -12
- package/src/register-registry-page-route/public-shell-page-data.test.ts +8 -19
- package/src/register-registry-page-route/resolve-entity-title.test.ts +8 -7
- package/src/register-registry-page-route/resolve-page-data.test.ts +9 -13
- package/src/register-registry-page-route/resolve-request-graph.test.ts +9 -13
- package/src/resolve-from-loader-config.test.ts +117 -14
- package/src/resolve-from-loader-config.ts +28 -115
- package/src/resolve-service-port.test.ts +8 -12
- package/src/resolvers/app-header.test.ts +9 -5
- package/src/resolvers/fetch-org-brand.ts +18 -2
- package/src/resolvers/portal-route-paths.test.ts +8 -13
- package/src/resource-chat-inject.page-assistant.test.ts +8 -28
- package/src/resource-chat-inject.test.ts +8 -25
- package/src/resource-not-found.test.ts +9 -8
- package/src/security-headers.test.ts +9 -12
- package/src/select-service-routes.test.ts +9 -0
- package/src/server-error-page.test.ts +8 -10
- package/src/theme-fonts-link.test.ts +8 -10
- package/src/two-phase-full-app.test.ts +8 -16
- package/src/two-phase-health-gate-client-only.test.ts +8 -28
- 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.
|
|
1134
|
+
"version": "0.23.506"
|
|
1134
1135
|
}
|
|
@@ -1,18 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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";
|
package/src/asset-proxy.test.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
package/src/auth-gate.test.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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";
|
package/src/bind-page-handler.ts
CHANGED
|
@@ -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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
+
});
|
package/src/content-gate/gate.ts
CHANGED
|
@@ -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
|
}
|