@teamscala/service-boot 0.23.506 → 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
CHANGED
package/src/bind-page-handler.ts
CHANGED
|
@@ -22,10 +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 {
|
|
26
|
-
withGateEntryClears,
|
|
27
|
-
takeGateEntryClears,
|
|
28
|
-
} from "./content-gate/gate-entry-clears.ts";
|
|
25
|
+
import { withGateEntryClears } from "./content-gate/gate-entry-clears.ts";
|
|
29
26
|
import type { Context } from "hono";
|
|
30
27
|
import { readHtmlTemplate } from "./read-html-template.ts";
|
|
31
28
|
import {
|
|
@@ -127,7 +124,7 @@ export async function bindPageHandler(
|
|
|
127
124
|
|
|
128
125
|
const nonce = c.get("nonce") as string | undefined;
|
|
129
126
|
|
|
130
|
-
const resp = await withGateEntryClears(async () =>
|
|
127
|
+
const { result: resp, clears: gateEntryClears } = await withGateEntryClears(async () =>
|
|
131
128
|
handler(c.req.raw, {
|
|
132
129
|
userId,
|
|
133
130
|
orgId,
|
|
@@ -142,7 +139,7 @@ export async function bindPageHandler(
|
|
|
142
139
|
// clears it here, so the next load (a refresh) is unauthorized and shows
|
|
143
140
|
// the gate again. Without this the marker would outlive the view that
|
|
144
141
|
// used it and every load would skip the gate.
|
|
145
|
-
for (const name of
|
|
142
|
+
for (const name of gateEntryClears) {
|
|
146
143
|
headers.append(
|
|
147
144
|
"Set-Cookie",
|
|
148
145
|
`${name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`,
|
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* gate. The gate writes the name here; bindPageHandler — which owns the page
|
|
11
11
|
* Response — drains it into Set-Cookie, so the marker lives exactly one view.
|
|
12
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
|
+
*
|
|
13
19
|
* AsyncLocalStorage rather than a module-level map: request-scoped by
|
|
14
20
|
* construction, no accumulated process state that a restart would silently
|
|
15
21
|
* reset (in-memory-state-is-not-durable).
|
|
@@ -18,8 +24,12 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
18
24
|
|
|
19
25
|
const clears = new AsyncLocalStorage<string[]>();
|
|
20
26
|
|
|
21
|
-
export async function withGateEntryClears<T>(
|
|
22
|
-
|
|
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] };
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
export function recordGateEntryClear(cookieName: string): void {
|
|
@@ -29,10 +39,3 @@ export function recordGateEntryClear(cookieName: string): void {
|
|
|
29
39
|
// throwing would make an otherwise valid direct invocation a failure.
|
|
30
40
|
if (list && !list.includes(cookieName)) list.push(cookieName);
|
|
31
41
|
}
|
|
32
|
-
|
|
33
|
-
export function takeGateEntryClears(): string[] {
|
|
34
|
-
const list = clears.getStore() ?? [];
|
|
35
|
-
const out = [...list];
|
|
36
|
-
list.length = 0;
|
|
37
|
-
return out;
|
|
38
|
-
}
|
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
* entryCookieName must behave exactly as before — a valid token authorizes on
|
|
8
8
|
* any load and nothing is recorded for clearing (condition 1: every non-opting
|
|
9
9
|
* service unchanged). An opting gate authorizes ONLY the request that carries
|
|
10
|
-
* the marker (the identity submit's own redirect) and
|
|
11
|
-
* page Response clears it — one view, then the gate is presented again.
|
|
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).
|
|
12
16
|
*
|
|
13
17
|
* Operator ruling 2026-09-14 (refresh must return to the identity gate),
|
|
14
18
|
* mechanism measured live: the quiz_token cookie alone was the carrier that
|
|
@@ -17,10 +21,7 @@
|
|
|
17
21
|
import { describe, expect, test } from "bun:test";
|
|
18
22
|
import type { ContentGateConfig } from "./config-types.ts";
|
|
19
23
|
import { runContentGate } from "./gate.ts";
|
|
20
|
-
import {
|
|
21
|
-
takeGateEntryClears,
|
|
22
|
-
withGateEntryClears,
|
|
23
|
-
} from "./gate-entry-clears.ts";
|
|
24
|
+
import { withGateEntryClears } from "./gate-entry-clears.ts";
|
|
24
25
|
|
|
25
26
|
function makeConfig(entryCookieName?: string) {
|
|
26
27
|
const calls = { authorized: 0, unauthorized: 0 };
|
|
@@ -41,30 +42,30 @@ function makeConfig(entryCookieName?: string) {
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
describe("gate entry marker is opt-in and one-view", () => {
|
|
44
|
-
test("non-opting gate: a valid token authorizes on any load, nothing
|
|
45
|
+
test("non-opting gate: a valid token authorizes on any load, nothing cleared — behaviour unchanged", async () => {
|
|
45
46
|
const { cfg, calls } = makeConfig();
|
|
46
|
-
await withGateEntryClears(async () => {
|
|
47
|
+
const { clears } = await withGateEntryClears(async () => {
|
|
47
48
|
const out = await runContentGate({}, "quiz_token=good", Date.now(), cfg);
|
|
48
49
|
expect(out.authorized).toBe(true);
|
|
49
50
|
expect(calls.authorized).toBe(1);
|
|
50
|
-
expect(takeGateEntryClears()).toEqual([]);
|
|
51
51
|
});
|
|
52
|
+
expect(clears).toEqual([]);
|
|
52
53
|
});
|
|
53
54
|
|
|
54
55
|
test("opting gate: token WITHOUT the marker is unauthorized — a refresh shows the gate", async () => {
|
|
55
56
|
const { cfg, calls } = makeConfig("quiz_token_entered");
|
|
56
|
-
await withGateEntryClears(async () => {
|
|
57
|
+
const { clears } = await withGateEntryClears(async () => {
|
|
57
58
|
const out = await runContentGate({}, "quiz_token=good", Date.now(), cfg);
|
|
58
59
|
expect(out.authorized).toBe(false);
|
|
59
60
|
expect(calls.unauthorized).toBe(1);
|
|
60
61
|
expect(calls.authorized).toBe(0);
|
|
61
|
-
expect(takeGateEntryClears()).toEqual([]);
|
|
62
62
|
});
|
|
63
|
+
expect(clears).toEqual([]);
|
|
63
64
|
});
|
|
64
65
|
|
|
65
|
-
test("opting gate: token + marker authorizes and
|
|
66
|
+
test("opting gate: token + marker authorizes and RETURNS the marker for clearing — one view", async () => {
|
|
66
67
|
const { cfg, calls } = makeConfig("quiz_token_entered");
|
|
67
|
-
await withGateEntryClears(async () => {
|
|
68
|
+
const { clears } = await withGateEntryClears(async () => {
|
|
68
69
|
const out = await runContentGate(
|
|
69
70
|
{},
|
|
70
71
|
"quiz_token=good; quiz_token_entered=1",
|
|
@@ -73,15 +74,13 @@ describe("gate entry marker is opt-in and one-view", () => {
|
|
|
73
74
|
);
|
|
74
75
|
expect(out.authorized).toBe(true);
|
|
75
76
|
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
77
|
});
|
|
78
|
+
expect(clears).toEqual(["quiz_token_entered"]);
|
|
80
79
|
});
|
|
81
80
|
|
|
82
|
-
test("opting gate: marker present but token invalid stays unauthorized and
|
|
81
|
+
test("opting gate: marker present but token invalid stays unauthorized and clears nothing", async () => {
|
|
83
82
|
const { cfg, calls } = makeConfig("quiz_token_entered");
|
|
84
|
-
await withGateEntryClears(async () => {
|
|
83
|
+
const { clears } = await withGateEntryClears(async () => {
|
|
85
84
|
const out = await runContentGate(
|
|
86
85
|
{},
|
|
87
86
|
"quiz_token=bad; quiz_token_entered=1",
|
|
@@ -90,8 +89,18 @@ describe("gate entry marker is opt-in and one-view", () => {
|
|
|
90
89
|
);
|
|
91
90
|
expect(out.authorized).toBe(false);
|
|
92
91
|
expect(calls.unauthorized).toBe(1);
|
|
93
|
-
expect(takeGateEntryClears()).toEqual([]);
|
|
94
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"]);
|
|
95
104
|
});
|
|
96
105
|
|
|
97
106
|
test("the quiz gate and its access endpoint both opt in on the same marker name", async () => {
|