@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
|
@@ -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(
|
|
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`
|
|
100
|
-
* in the token; allow-list gates ignore
|
|
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 {
|
|
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
|
+
}
|
package/src/quiz-gate.ts
ADDED
|
@@ -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
|
+
};
|