@teamscala/service-boot 0.23.500 → 0.23.501

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
@@ -1130,5 +1130,5 @@
1130
1130
  "repository": "https://github.com/teamgbg/service-boot",
1131
1131
  "sideEffects": false,
1132
1132
  "type": "module",
1133
- "version": "0.23.500"
1133
+ "version": "0.23.501"
1134
1134
  }
@@ -37,14 +37,27 @@ export async function attachContentGateAccess(
37
37
  let resourceId = "";
38
38
  let email = "";
39
39
  let name = "";
40
+ let phone = "";
40
41
  let bodyFailed = false;
42
+ // The resource id may ride on the URL instead of the body. A gate whose
43
+ // form is COMPOSED FROM BLOCKS has no place to put a hidden field — the
44
+ // generic submit action collects the visible inputs and adds only the
45
+ // static fields its row declares, and a row is shared by every page so it
46
+ // cannot carry a per-page id. The gate therefore publishes its own access
47
+ // URL with the id already on it (see quiz-gate onUnauthorized), which
48
+ // keeps the id single-sourced from the resolved graph. A body field still
49
+ // wins where one is posted (proposals, agreements, the GPT).
50
+ const queryResourceId = String(c.req.query(config.recordIdField) ?? "").trim();
41
51
  try {
42
52
  const form = await c.req.parseBody();
43
- resourceId = String(form[config.recordIdField] ?? "").trim();
53
+ resourceId = String(form[config.recordIdField] ?? "").trim() || queryResourceId;
44
54
  email = String(form.email ?? "").trim().toLowerCase();
45
55
  // Optional captured display name (open-capture gates carry it in the
46
56
  // token; allow-list gates ignore it via signToken).
47
57
  name = String(form.name ?? "").trim();
58
+ // Captured alongside the email by an open-capture gate's composed
59
+ // form. It rides in the signed token rather than any client store.
60
+ phone = String(form.phone ?? "").trim();
48
61
  } catch (error) {
49
62
  log.warn(`[content-gate:${config.cookieName}] body parse failed — redirecting back: ${String(error)}`);
50
63
  bodyFailed = true;
@@ -75,7 +88,13 @@ export async function attachContentGateAccess(
75
88
  return c.redirect(`${back}${sep}gate_error=1`, 302);
76
89
  }
77
90
 
78
- const token = config.signToken(resourceId, email, Date.now(), name || undefined);
91
+ const token = config.signToken(
92
+ resourceId,
93
+ email,
94
+ Date.now(),
95
+ name || undefined,
96
+ phone || undefined,
97
+ );
79
98
  if (token) {
80
99
  const maxAge = Math.floor(config.ttlMs / 1000);
81
100
  c.header(
@@ -58,6 +58,7 @@ import type { AccessConfig, ContentGateConfig } from "./config-types.ts";
58
58
  import { agreementAccessConfig, agreementGateConfig } from "../agreement-gate.ts";
59
59
  import { gptAccessConfig, gptGateConfig } from "../gpt-gate.ts";
60
60
  import { proposalAccessConfig, proposalGateConfig } from "../proposal-gate.ts";
61
+ import { quizAccessConfig, quizGateConfig } from "../quiz-gate.ts";
61
62
 
62
63
  /** One gated content type: its SSR gate and its public access endpoint. */
63
64
  export interface ContentGateEntry {
@@ -71,6 +72,7 @@ export const CONTENT_GATES: readonly ContentGateEntry[] = [
71
72
  { slug: "proposal", gate: proposalGateConfig, access: proposalAccessConfig },
72
73
  { slug: "agreement", gate: agreementGateConfig, access: agreementAccessConfig },
73
74
  { slug: "gpt", gate: gptGateConfig, access: gptAccessConfig },
75
+ { slug: "quiz", gate: quizGateConfig, access: quizAccessConfig },
74
76
  ];
75
77
 
76
78
  export function withholdsContent(entry: ContentGateEntry): boolean {
@@ -35,7 +35,7 @@ export interface ContentGateConfig {
35
35
  token: string | undefined,
36
36
  resourceId: string,
37
37
  nowMs: number,
38
- ): { email: string; name?: string } | null;
38
+ ): { email: string; name?: string; phone?: string } | null;
39
39
  /**
40
40
  * BAKED-IN unified auth. When a gate provides this resolver, the engine
41
41
  * AUTOMATICALLY releases a logged-in viewer whose session email is in the
@@ -64,7 +64,7 @@ export interface ContentGateConfig {
64
64
  onAuthorized(
65
65
  resolved: Resolved,
66
66
  resourceId: string,
67
- identity: { email: string; name?: string },
67
+ identity: { email: string; name?: string; phone?: string },
68
68
  ): void | Promise<void>;
69
69
  /** Withhold content (strip/inject the gate) — token absent/invalid. Async-
70
70
  * capable so a gate can load its display/behaviour settings from a registry
@@ -96,13 +96,15 @@ export interface AccessConfig {
96
96
  resolveAuthorizedEmails(resourceId: string): Promise<Set<string>>;
97
97
  /**
98
98
  * Mint the access token for a matched email (token-agnostic per gate).
99
- * `name` is the optional captured display name (open-capture gates carry it
100
- * in the token; allow-list gates ignore it).
99
+ * `name` and `phone` are the optional captured identity (open-capture gates
100
+ * carry them in the token; allow-list gates ignore them). They exist so a
101
+ * capture gate needs no browser storage to remember who this is.
101
102
  */
102
103
  signToken(
103
104
  resourceId: string,
104
105
  email: string,
105
106
  nowMs: number,
106
107
  name?: string,
108
+ phone?: string,
107
109
  ): string | null;
108
110
  }
@@ -9,7 +9,9 @@
9
9
  * the only thing that releases the content into the SSR, so without it a gated
10
10
  * page is never readable/scrapable by a link holder.
11
11
  *
12
- * HMAC-SHA256 (Bun-native) over {c:cookieName, r:resourceId, e:email, x:exp}.
12
+ * HMAC-SHA256 (Bun-native) over {c:cookieName, r:resourceId, e:email, x:exp},
13
+ * plus the optional captured identity a lead-capture gate carries (n:name,
14
+ * p:phone) so a returning visitor is fully known at SSR with NO client storage.
13
15
  * `cookieName` is bound INTO the payload so a token minted for one gate
14
16
  * (e.g. ag_token) can never satisfy another gate's check (e.g. prop_token).
15
17
  *
@@ -59,10 +61,17 @@ export function signAccessToken(
59
61
  * (proposals/agreements) omit it.
60
62
  */
61
63
  name?: string,
64
+ /**
65
+ * Optional captured phone, same reasoning as `name`. It exists so a capture
66
+ * gate needs NO browser storage to remember it: the operator's ruling is
67
+ * that identity lives in the database and the request, never in
68
+ * localStorage.
69
+ */
70
+ phone?: string,
62
71
  ): string | null {
63
72
  const key = secret();
64
73
  if (!key) return null;
65
- const payload: { c: string; r: string; e: string; x: number; n?: string } = {
74
+ const payload: { c: string; r: string; e: string; x: number; n?: string; p?: string } = {
66
75
  c: cookieName,
67
76
  r: resourceId,
68
77
  e: email.trim().toLowerCase(),
@@ -70,6 +79,8 @@ export function signAccessToken(
70
79
  };
71
80
  const trimmedName = name?.trim();
72
81
  if (trimmedName) payload.n = trimmedName;
82
+ const trimmedPhone = phone?.trim();
83
+ if (trimmedPhone) payload.p = trimmedPhone;
73
84
  const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
74
85
  return `${payloadB64}.${hmac(payloadB64, key)}`;
75
86
  }
@@ -110,7 +121,7 @@ export function verifyAccessToken(
110
121
  }
111
122
 
112
123
  /**
113
- * Like `verifyAccessToken` but returns the full verified identity ({email, name})
124
+ * Like `verifyAccessToken` but returns the full verified identity ({email, name, phone})
114
125
  * so a gate that carries a display name in the token (the public GPT lead-capture
115
126
  * gate) can seed the visitor's name — not just the email — at SSR. Same
116
127
  * signature/expiry/gate-binding checks; null on any failure.
@@ -120,7 +131,7 @@ export function verifyAccessTokenIdentity(
120
131
  cookieName: string,
121
132
  resourceId: string,
122
133
  nowMs: number,
123
- ): { email: string; name?: string } | null {
134
+ ): { email: string; name?: string; phone?: string } | null {
124
135
  if (!token) return null;
125
136
  const key = secret();
126
137
  if (!key) return null;
@@ -136,12 +147,17 @@ export function verifyAccessTokenIdentity(
136
147
  e?: string;
137
148
  x?: number;
138
149
  n?: string;
150
+ p?: string;
139
151
  };
140
152
  if (p.c !== cookieName) return null;
141
153
  if (p.r !== resourceId) return null;
142
154
  if (typeof p.x !== "number" || p.x < nowMs) return null;
143
155
  if (typeof p.e !== "string") return null;
144
- return { email: p.e, name: typeof p.n === "string" ? p.n : undefined };
156
+ return {
157
+ email: p.e,
158
+ name: typeof p.n === "string" ? p.n : undefined,
159
+ phone: typeof p.p === "string" ? p.p : undefined,
160
+ };
145
161
  } catch {
146
162
  return null;
147
163
  }
package/src/gate-ttls.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * @edit edit directly
5
5
  *
6
6
  * Cached readers for the content-gate access-token TTL config rows
7
- * (agreement-gate / proposal-gate / gpt-gate). initGateTtls() runs once at boot
7
+ * (agreement-gate / proposal-gate / gpt-gate / quiz-gate). initGateTtls() runs once at boot
8
8
  * (two-phase-boot, before the gates mount) to populate the cache from the
9
9
  * registry rows; the sync getters (agTtlMs/propTtlMs/gptTtlMs) return the
10
10
  * cached value — or the former hardcoded default before init / on row-miss — so
@@ -20,10 +20,14 @@ import * as v from "valibot";
20
20
  const DEFAULT_AG_TTL_MS = 30 * 60 * 1000;
21
21
  const DEFAULT_PROP_TTL_MS = 24 * 60 * 60 * 1000;
22
22
  const DEFAULT_GPT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
23
+ // A quiz visitor returns to finish or to re-read their result over days, so the
24
+ // identity cookie outlives one sitting — same lead-capture shape as the GPT.
25
+ const DEFAULT_QUIZ_TTL_MS = 30 * 24 * 60 * 60 * 1000;
23
26
 
24
27
  let _agTtlMs = DEFAULT_AG_TTL_MS;
25
28
  let _propTtlMs = DEFAULT_PROP_TTL_MS;
26
29
  let _gptTtlMs = DEFAULT_GPT_TTL_MS;
30
+ let _quizTtlMs = DEFAULT_QUIZ_TTL_MS;
27
31
  let _initialized = false;
28
32
 
29
33
  /** Populate the TTL cache from the registry rows. Idempotent; keeps defaults on
@@ -32,14 +36,16 @@ export async function initGateTtls(): Promise<void> {
32
36
  if (_initialized) return;
33
37
  _initialized = true;
34
38
  try {
35
- const [ag, prop, gpt] = await Promise.all([
39
+ const [ag, prop, gpt, quiz] = await Promise.all([
36
40
  loadRegistryConfig("config", "agreement-gate", v.looseObject({})),
37
41
  loadRegistryConfig("config", "proposal-gate", v.looseObject({})),
38
42
  loadRegistryConfig("config", "gpt-gate", v.looseObject({})),
43
+ loadRegistryConfig("config", "quiz-gate", v.looseObject({})),
39
44
  ]);
40
45
  if (typeof ag?.ag_ttl_ms === "number") _agTtlMs = ag.ag_ttl_ms;
41
46
  if (typeof prop?.prop_ttl_ms === "number") _propTtlMs = prop.prop_ttl_ms;
42
47
  if (typeof gpt?.gpt_ttl_ms === "number") _gptTtlMs = gpt.gpt_ttl_ms;
48
+ if (typeof quiz?.quiz_ttl_ms === "number") _quizTtlMs = quiz.quiz_ttl_ms;
43
49
  } catch {
44
50
  // keep defaults
45
51
  }
@@ -54,3 +60,6 @@ export function propTtlMs(): number {
54
60
  export function gptTtlMs(): number {
55
61
  return _gptTtlMs;
56
62
  }
63
+ export function quizTtlMs(): number {
64
+ return _quizTtlMs;
65
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The PUBLIC QUIZ identity gate, expressed as config for the content-gate
7
+ * primitive. Like the GPT gate this is lead capture, not access control: every
8
+ * email is a valid visitor. Its job is to make the visitor KNOWN AT SSR so the
9
+ * page's identity blocks hide themselves and the quiz blocks reveal themselves
10
+ * — by block condition on `initialVisitor`, never by a component deciding which
11
+ * screen to render.
12
+ *
13
+ * That is the whole point of this file existing. The quiz identity form used to
14
+ * be a composite React component that hand-assembled a logo, a heading, three
15
+ * inputs and a button, and then branched in TypeScript on whether a visitor was
16
+ * known. Every one of those pieces is already a Puck block, and the branch is
17
+ * already expressible as a block condition — what was missing was only the
18
+ * server half: something to turn a submitted email into a visitor the next
19
+ * render can see. This is that half, and it is 60 lines of config because the
20
+ * engine (content-gate/gate.ts + access.ts) already does the work.
21
+ *
22
+ * Flow: the composed form POSTs {quizId, name, email, phone} to
23
+ * /api/quiz-access (openCapture → any email matches); the access endpoint signs
24
+ * a `quiz_token` cookie and redirects back. On the next request runContentGate
25
+ * verifies it for this assessment and seeds resolved.initialVisitor.
26
+ */
27
+
28
+ import type { AccessConfig, ContentGateConfig } from "./content-gate/config-types.ts";
29
+ import { signAccessToken, verifyAccessTokenIdentity } from "./content-gate/token.ts";
30
+ import { quizTtlMs } from "./gate-ttls.ts";
31
+
32
+ const QUIZ_COOKIE = "quiz_token";
33
+ const QUIZ_ACCESS_PATH = "/api/quiz-access";
34
+
35
+ /** The assessment this page is for, from the quiz-with-brand loader's graph. */
36
+ function quizId(resolved: Record<string, unknown>): string {
37
+ const quiz = resolved.quiz as { id?: unknown } | null | undefined;
38
+ return typeof quiz?.id === "string" ? quiz.id : "";
39
+ }
40
+
41
+ export const quizGateConfig: ContentGateConfig = {
42
+ cookieName: QUIZ_COOKIE,
43
+ getResourceId(resolved) {
44
+ // No quiz in the graph → not a quiz page → the engine no-ops.
45
+ return quizId(resolved);
46
+ },
47
+ verifyToken(token, resourceId, nowMs) {
48
+ return verifyAccessTokenIdentity(token, QUIZ_COOKIE, resourceId, nowMs);
49
+ },
50
+ onAuthorized(resolved, _resourceId, { email, name, phone }) {
51
+ // The one fact the page's block conditions read. Present → the identity
52
+ // container hides and the quiz container shows; absent → the reverse.
53
+ (resolved as {
54
+ initialVisitor?: { name?: string; email: string; phone?: string };
55
+ }).initialVisitor = { name, email, phone };
56
+ },
57
+ onUnauthorized(resolved, resourceId) {
58
+ // The endpoint the identity form posts to, PUBLISHED INTO THE PAGE DATA
59
+ // rather than typed into the page. The assessment id is per-page, so a
60
+ // static row could not carry it and a literal in the page tree would be
61
+ // a second copy of the id the URL already states. The submit Button
62
+ // resolves this by field path (page-data → loaderData), exactly as it
63
+ // resolves any other live value.
64
+ (resolved as { quizAccess?: { url: string } }).quizAccess = {
65
+ url: `${QUIZ_ACCESS_PATH}?quizId=${encodeURIComponent(resourceId)}`,
66
+ };
67
+ },
68
+ };
69
+
70
+ export const quizAccessConfig: AccessConfig = {
71
+ path: QUIZ_ACCESS_PATH,
72
+ cookieName: QUIZ_COOKIE,
73
+ get ttlMs() {
74
+ return quizTtlMs();
75
+ },
76
+ recordIdField: "quizId",
77
+ // Lead capture: any email identifies a visitor. Nothing is withheld — the
78
+ // quiz is public, only the answers are attributed.
79
+ openCapture: true,
80
+ // Never called under openCapture; present to satisfy the config contract.
81
+ async resolveAuthorizedEmails() {
82
+ return new Set<string>();
83
+ },
84
+ signToken(resourceId, email, nowMs, name, phone) {
85
+ return signAccessToken(QUIZ_COOKIE, resourceId, email, nowMs, quizTtlMs(), name, phone);
86
+ },
87
+ };