@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
@@ -1131,5 +1131,5 @@
1131
1131
  "repository": "https://github.com/teamgbg/service-boot",
1132
1132
  "sideEffects": false,
1133
1133
  "type": "module",
1134
- "version": "0.23.506"
1134
+ "version": "0.23.507"
1135
1135
  }
@@ -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 takeGateEntryClears()) {
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>(fn: () => Promise<T>): Promise<T> {
22
- return clears.run([], fn);
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 records the marker so the
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 recorded — behaviour unchanged", async () => {
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 records the marker for clearing, exactly once", async () => {
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 records nothing", async () => {
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 () => {