@teamscala/service-boot 0.23.505 → 0.23.507
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 +8 -8
- package/src/agreement-gate.test.ts +8 -8
- package/src/asset-proxy.test.ts +8 -8
- package/src/attach-portal-members.test.ts +8 -8
- package/src/attach-transfers-upload.test.ts +8 -8
- package/src/auth-gate.test.ts +8 -8
- package/src/bind-page-handler.ts +21 -8
- package/src/build-client-assets/manifest.test.ts +8 -8
- package/src/build-client-assets/source-candidates.test.ts +8 -8
- package/src/build-client-assets/sourcemaps-upload.test.ts +8 -8
- package/src/build-client-assets/styles-build.test.ts +8 -8
- package/src/build-client-assets.test.ts +8 -8
- package/src/capability-slug.test.ts +8 -8
- package/src/compose-page-title.test.ts +8 -8
- 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 +41 -0
- package/src/content-gate/gate-entry-marker.test.ts +111 -0
- package/src/content-gate/gate.ts +16 -0
- package/src/content-gate/token.test.ts +8 -8
- package/src/content-page-floor.test.ts +8 -8
- package/src/csrf-body-limit.test.ts +8 -8
- package/src/handlers/messaging-send-reconcile-logic.test.ts +8 -8
- package/src/handlers/messaging-send-recording-logic.test.ts +8 -8
- package/src/handlers/messaging-send-recording-watch.test.ts +8 -8
- package/src/handlers/messaging-send-sweep-selection.test.ts +8 -8
- package/src/handlers/platform-fault-escalation/probe.test.ts +8 -8
- package/src/handlers/platform-fault-escalation-refresh.test.ts +8 -8
- package/src/handlers/platform-fault-escalation.test.ts +8 -8
- package/src/handlers/scala-tools-currency-watch.test.ts +8 -8
- package/src/handlers/singleton-tools-currency-watch.test.ts +8 -8
- package/src/handlers/whatsapp-liveness-handler.test.ts +8 -8
- package/src/handlers/whatsapp-liveness.test.ts +8 -8
- package/src/is-react-element-type.test.ts +8 -8
- package/src/module-not-found.test.ts +8 -8
- package/src/mount-orpc-router.test.ts +8 -8
- package/src/page-server-orpc/server-client.test.ts +8 -8
- package/src/quiz-gate.ts +10 -0
- package/src/read-html-template.test.ts +8 -8
- package/src/register-registry-page-route/apply-page-branding.test.ts +8 -8
- package/src/register-registry-page-route/captured-loader-config.test.ts +8 -8
- package/src/register-registry-page-route/graph-cache.test.ts +8 -8
- package/src/register-registry-page-route/live-loader-config.test.ts +8 -8
- package/src/register-registry-page-route/page-data-shared.test.ts +8 -8
- package/src/register-registry-page-route/public-shell-page-data.test.ts +8 -8
- package/src/register-registry-page-route/resolve-entity-title.test.ts +8 -8
- package/src/register-registry-page-route/resolve-page-data.test.ts +8 -8
- package/src/register-registry-page-route/resolve-request-graph.test.ts +8 -8
- package/src/resolve-from-loader-config.test.ts +115 -8
- package/src/resolve-service-port.test.ts +8 -8
- package/src/resolvers/app-header.test.ts +8 -8
- package/src/resolvers/portal-route-paths.test.ts +8 -8
- package/src/resource-chat-inject.page-assistant.test.ts +8 -8
- package/src/resource-chat-inject.test.ts +8 -8
- package/src/resource-not-found.test.ts +8 -8
- package/src/security-headers.test.ts +8 -8
- package/src/select-service-routes.test.ts +8 -8
- package/src/server-error-page.test.ts +8 -8
- package/src/theme-fonts-link.test.ts +8 -8
- package/src/two-phase-full-app.test.ts +8 -8
- package/src/two-phase-health-gate-client-only.test.ts +8 -8
- package/src/two-phase-health-gate.test.ts +8 -8
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.507"
|
|
1134
1135
|
}
|
|
@@ -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, mock, test } from "bun:test";
|
|
11
11
|
|
|
@@ -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 { agreementGateConfig } from "./agreement-gate.ts";
|
package/src/asset-proxy.test.ts
CHANGED
|
@@ -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, it } from "bun:test";
|
|
11
11
|
import { handleAssetProxy } from "./asset-proxy.ts";
|
|
@@ -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, mock, test } from "bun:test";
|
|
11
11
|
import { Hono } from "hono";
|
|
@@ -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, it } from "bun:test";
|
|
11
11
|
import { attachTransfersUpload } from "./attach-transfers-upload.ts";
|
package/src/auth-gate.test.ts
CHANGED
|
@@ -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 { isStaticAssetPath } from "./auth-gate.ts";
|
package/src/bind-page-handler.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
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 { withGateEntryClears } from "./content-gate/gate-entry-clears.ts";
|
|
25
26
|
import type { Context } from "hono";
|
|
26
27
|
import { readHtmlTemplate } from "./read-html-template.ts";
|
|
27
28
|
import {
|
|
@@ -123,15 +124,27 @@ export async function bindPageHandler(
|
|
|
123
124
|
|
|
124
125
|
const nonce = c.get("nonce") as string | undefined;
|
|
125
126
|
|
|
126
|
-
const resp = await
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
127
|
+
const { result: resp, clears: gateEntryClears } = await withGateEntryClears(async () =>
|
|
128
|
+
handler(c.req.raw, {
|
|
129
|
+
userId,
|
|
130
|
+
orgId,
|
|
131
|
+
visitor,
|
|
132
|
+
searchParams: c.req.query(),
|
|
133
|
+
htmlTemplate: await readHtmlTemplate(),
|
|
134
|
+
nonce,
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
134
137
|
const headers = new Headers(resp.headers as HeadersInit);
|
|
138
|
+
// Gate-entry markers are ONE-VIEW state: the render that consumed one
|
|
139
|
+
// clears it here, so the next load (a refresh) is unauthorized and shows
|
|
140
|
+
// the gate again. Without this the marker would outlive the view that
|
|
141
|
+
// used it and every load would skip the gate.
|
|
142
|
+
for (const name of gateEntryClears) {
|
|
143
|
+
headers.append(
|
|
144
|
+
"Set-Cookie",
|
|
145
|
+
`${name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
135
148
|
headers.set(
|
|
136
149
|
"Cache-Control",
|
|
137
150
|
"no-store, no-cache, no-transform, must-revalidate, max-age=0",
|
|
@@ -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 { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
11
11
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
@@ -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 { mkdir, rm } from "node:fs/promises";
|
|
@@ -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 { mkdtemp, mkdir, readdir, rm } from "node:fs/promises";
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
@@ -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 { mkdir, rm } from "node:fs/promises";
|
|
@@ -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 { mkdtemp, mkdir, rm } from "node:fs/promises";
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
@@ -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 { toCapabilitySlug } from "./capability-slug.ts";
|
|
@@ -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 { 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,41 @@
|
|
|
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
|
+
* THE CLEARS COME BACK AS THE CALL'S RETURN VALUE, never from a post-hoc
|
|
14
|
+
* read: the store only exists inside the run() scope, so a reader called after
|
|
15
|
+
* the awaited call has already left it and would see an empty list — which is
|
|
16
|
+
* exactly how the first version of this file shipped a marker that was never
|
|
17
|
+
* cleared (measured live 2026-09-14: the cookie survived the render).
|
|
18
|
+
*
|
|
19
|
+
* AsyncLocalStorage rather than a module-level map: request-scoped by
|
|
20
|
+
* construction, no accumulated process state that a restart would silently
|
|
21
|
+
* reset (in-memory-state-is-not-durable).
|
|
22
|
+
*/
|
|
23
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
24
|
+
|
|
25
|
+
const clears = new AsyncLocalStorage<string[]>();
|
|
26
|
+
|
|
27
|
+
export async function withGateEntryClears<T>(
|
|
28
|
+
fn: () => Promise<T>,
|
|
29
|
+
): Promise<{ result: T; clears: string[] }> {
|
|
30
|
+
const list: string[] = [];
|
|
31
|
+
const result = await clears.run(list, fn);
|
|
32
|
+
return { result, clears: [...list] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function recordGateEntryClear(cookieName: string): void {
|
|
36
|
+
const list = clears.getStore();
|
|
37
|
+
// Outside a bound request there is no response to write to (a direct gate
|
|
38
|
+
// run, a unit test). Dropping the record is the only correct answer —
|
|
39
|
+
// throwing would make an otherwise valid direct invocation a failure.
|
|
40
|
+
if (list && !list.includes(cookieName)) list.push(cookieName);
|
|
41
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
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 returns the marker name
|
|
11
|
+
* so the page Response clears it — one view, then the gate is presented again.
|
|
12
|
+
*
|
|
13
|
+
* The clears are asserted from the CALL'S RETURN VALUE: the store lives only
|
|
14
|
+
* inside the run() scope, and the first shipped version read it afterwards,
|
|
15
|
+
* saw nothing, and left the marker in the browser (measured live 2026-09-14).
|
|
16
|
+
*
|
|
17
|
+
* Operator ruling 2026-09-14 (refresh must return to the identity gate),
|
|
18
|
+
* mechanism measured live: the quiz_token cookie alone was the carrier that
|
|
19
|
+
* skipped the gate on reload.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, expect, test } from "bun:test";
|
|
22
|
+
import type { ContentGateConfig } from "./config-types.ts";
|
|
23
|
+
import { runContentGate } from "./gate.ts";
|
|
24
|
+
import { withGateEntryClears } from "./gate-entry-clears.ts";
|
|
25
|
+
|
|
26
|
+
function makeConfig(entryCookieName?: string) {
|
|
27
|
+
const calls = { authorized: 0, unauthorized: 0 };
|
|
28
|
+
const cfg: ContentGateConfig = {
|
|
29
|
+
cookieName: "quiz_token",
|
|
30
|
+
entryCookieName,
|
|
31
|
+
getResourceId: () => "res-1",
|
|
32
|
+
verifyToken: (token) =>
|
|
33
|
+
token === "good" ? { email: "v@example.com", name: "V" } : null,
|
|
34
|
+
onAuthorized: () => {
|
|
35
|
+
calls.authorized++;
|
|
36
|
+
},
|
|
37
|
+
onUnauthorized: () => {
|
|
38
|
+
calls.unauthorized++;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
return { cfg, calls };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("gate entry marker is opt-in and one-view", () => {
|
|
45
|
+
test("non-opting gate: a valid token authorizes on any load, nothing cleared — behaviour unchanged", async () => {
|
|
46
|
+
const { cfg, calls } = makeConfig();
|
|
47
|
+
const { clears } = await withGateEntryClears(async () => {
|
|
48
|
+
const out = await runContentGate({}, "quiz_token=good", Date.now(), cfg);
|
|
49
|
+
expect(out.authorized).toBe(true);
|
|
50
|
+
expect(calls.authorized).toBe(1);
|
|
51
|
+
});
|
|
52
|
+
expect(clears).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("opting gate: token WITHOUT the marker is unauthorized — a refresh shows the gate", async () => {
|
|
56
|
+
const { cfg, calls } = makeConfig("quiz_token_entered");
|
|
57
|
+
const { clears } = await withGateEntryClears(async () => {
|
|
58
|
+
const out = await runContentGate({}, "quiz_token=good", Date.now(), cfg);
|
|
59
|
+
expect(out.authorized).toBe(false);
|
|
60
|
+
expect(calls.unauthorized).toBe(1);
|
|
61
|
+
expect(calls.authorized).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
expect(clears).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("opting gate: token + marker authorizes and RETURNS the marker for clearing — one view", async () => {
|
|
67
|
+
const { cfg, calls } = makeConfig("quiz_token_entered");
|
|
68
|
+
const { clears } = await withGateEntryClears(async () => {
|
|
69
|
+
const out = await runContentGate(
|
|
70
|
+
{},
|
|
71
|
+
"quiz_token=good; quiz_token_entered=1",
|
|
72
|
+
Date.now(),
|
|
73
|
+
cfg,
|
|
74
|
+
);
|
|
75
|
+
expect(out.authorized).toBe(true);
|
|
76
|
+
expect(calls.authorized).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
expect(clears).toEqual(["quiz_token_entered"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("opting gate: marker present but token invalid stays unauthorized and clears nothing", async () => {
|
|
82
|
+
const { cfg, calls } = makeConfig("quiz_token_entered");
|
|
83
|
+
const { clears } = await withGateEntryClears(async () => {
|
|
84
|
+
const out = await runContentGate(
|
|
85
|
+
{},
|
|
86
|
+
"quiz_token=bad; quiz_token_entered=1",
|
|
87
|
+
Date.now(),
|
|
88
|
+
cfg,
|
|
89
|
+
);
|
|
90
|
+
expect(out.authorized).toBe(false);
|
|
91
|
+
expect(calls.unauthorized).toBe(1);
|
|
92
|
+
});
|
|
93
|
+
expect(clears).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("two gates in one request each report their own marker, drained once", async () => {
|
|
97
|
+
const first = makeConfig("gate_a_entered");
|
|
98
|
+
const second = makeConfig("gate_b_entered");
|
|
99
|
+
const { clears } = await withGateEntryClears(async () => {
|
|
100
|
+
await runContentGate({}, "quiz_token=good; gate_a_entered=1", Date.now(), first.cfg);
|
|
101
|
+
await runContentGate({}, "quiz_token=good; gate_b_entered=1", Date.now(), second.cfg);
|
|
102
|
+
});
|
|
103
|
+
expect(clears).toEqual(["gate_a_entered", "gate_b_entered"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("the quiz gate and its access endpoint both opt in on the same marker name", async () => {
|
|
107
|
+
const { quizAccessConfig, quizGateConfig } = await import("../quiz-gate.ts");
|
|
108
|
+
expect(quizGateConfig.entryCookieName).toBe("quiz_token_entered");
|
|
109
|
+
expect(quizAccessConfig.entryCookieName).toBe("quiz_token_entered");
|
|
110
|
+
});
|
|
111
|
+
});
|
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
|
}
|
|
@@ -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
|
process.env.AUTH_SECRET = "test-secret-at-least-16-chars-long";
|
|
11
11
|
|
|
@@ -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 { test, expect } from "bun:test";
|
|
11
11
|
import {
|