@teamscala/service-boot 0.23.500 → 0.23.502
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 +1 -1
- package/src/capability-slug.test.ts +46 -0
- package/src/capability-slug.ts +59 -0
- package/src/content-gate/access.ts +21 -2
- package/src/content-gate/catalog.ts +2 -0
- package/src/content-gate/config-types.ts +6 -4
- package/src/content-gate/token.ts +21 -5
- package/src/gate-ttls.ts +11 -2
- package/src/quiz-gate.ts +87 -0
- package/src/register-page-routes.ts +15 -0
- package/src/register-registry-page-route.ts +2 -21
- package/src/select-service-routes.test.ts +58 -0
- package/src/select-service-routes.ts +66 -0
package/package.json
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { toCapabilitySlug } from "./capability-slug.ts";
|
|
3
|
+
|
|
4
|
+
describe("toCapabilitySlug", () => {
|
|
5
|
+
test("strips the language+tier suffix from a dev deployable", () => {
|
|
6
|
+
expect(toCapabilitySlug("scala-projects-bun-dev")).toBe("scala-projects");
|
|
7
|
+
expect(toCapabilitySlug("scala-ai-bun-dev")).toBe("scala-ai");
|
|
8
|
+
expect(toCapabilitySlug("scala-image-optimizer-rust-dev")).toBe(
|
|
9
|
+
"scala-image-optimizer",
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("strips the language+tier suffix from a prod deployable", () => {
|
|
14
|
+
expect(toCapabilitySlug("scala-projects-bun-prod")).toBe("scala-projects");
|
|
15
|
+
expect(toCapabilitySlug("scala-messaging-service-rust-prod")).toBe(
|
|
16
|
+
"scala-messaging-service",
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("returns an already-capability slug unchanged", () => {
|
|
21
|
+
// The prod lane is still bare while the tier migration is in flight, so
|
|
22
|
+
// the SAME function must be correct before and after the rename lands.
|
|
23
|
+
expect(toCapabilitySlug("scala-projects")).toBe("scala-projects");
|
|
24
|
+
expect(toCapabilitySlug("scala-otp")).toBe("scala-otp");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("both lanes of one application normalize to the same identity", () => {
|
|
28
|
+
// This is the property the page_route join depends on.
|
|
29
|
+
expect(toCapabilitySlug("scala-projects-bun-dev")).toBe(
|
|
30
|
+
toCapabilitySlug("scala-projects"),
|
|
31
|
+
);
|
|
32
|
+
expect(toCapabilitySlug("scala-quiz-bun-dev")).toBe(
|
|
33
|
+
toCapabilitySlug("scala-quiz-bun-prod"),
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("leaves a name whose tail is not the closed grammar alone", () => {
|
|
38
|
+
// Not a language segment.
|
|
39
|
+
expect(toCapabilitySlug("scala-node-dev")).toBe("scala-node-dev");
|
|
40
|
+
// Not a tier segment.
|
|
41
|
+
expect(toCapabilitySlug("scala-bun-staging")).toBe("scala-bun-staging");
|
|
42
|
+
// Too short to carry both segments.
|
|
43
|
+
expect(toCapabilitySlug("bun-dev")).toBe("bun-dev");
|
|
44
|
+
expect(toCapabilitySlug("supavisor")).toBe("supavisor");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Deployable slug -> capability slug.
|
|
7
|
+
*
|
|
8
|
+
* A DEPLOYABLE is named `<capability>-<language>-<tier>`
|
|
9
|
+
* (`deployables-carry-language-and-tier`): `scala-projects-bun-dev` and
|
|
10
|
+
* `scala-projects-bun-prod` are two DEPLOYMENTS of one APPLICATION. The
|
|
11
|
+
* ecosystem generator emits that name into `SERVICE_SLUG` per lane, so one
|
|
12
|
+
* service arrives here under a DIFFERENT identity in each lane.
|
|
13
|
+
*
|
|
14
|
+
* A `page_route` row describes the APPLICATION url space, which is the same
|
|
15
|
+
* in both lanes, so it carries the CAPABILITY name and there is exactly one
|
|
16
|
+
* row per route rather than one per lane. Joining the two by raw string
|
|
17
|
+
* equality therefore matches at most one lane, and does so SILENTLY:
|
|
18
|
+
* measured 2026-09-11, 8 of 10 dev UI services registered ZERO page routes
|
|
19
|
+
* and served 404 on every path -- including `/sign-in` -- while booting
|
|
20
|
+
* clean and reporting healthy, because the dev lane ran as
|
|
21
|
+
* `scala-projects-bun-dev` while every page_route row said `scala-projects`.
|
|
22
|
+
*
|
|
23
|
+
* Both sides normalize through this one function, so the join is
|
|
24
|
+
* tier-agnostic by construction and a row may name either form.
|
|
25
|
+
*
|
|
26
|
+
* The suffix grammar is a CLOSED SET defined by doctrine and emitted by the
|
|
27
|
+
* generator, so this is a parse of a documented contract rather than a
|
|
28
|
+
* pattern heuristic. A slug that does not end in one is already a capability
|
|
29
|
+
* name and is returned unchanged -- which is why this is correct both BEFORE
|
|
30
|
+
* the tier migration completes (prod is still bare `scala-projects`) and
|
|
31
|
+
* after it (prod becomes `scala-projects-bun-prod`).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Language segment of a deployable name. */
|
|
35
|
+
const LANGUAGES = ["bun", "rust"] as const;
|
|
36
|
+
|
|
37
|
+
/** Tier segment of a deployable name. */
|
|
38
|
+
const TIERS = ["dev", "prod"] as const;
|
|
39
|
+
|
|
40
|
+
type Language = (typeof LANGUAGES)[number];
|
|
41
|
+
type Tier = (typeof TIERS)[number];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The capability half of a deployable slug.
|
|
45
|
+
*
|
|
46
|
+
* `scala-projects-bun-dev` -> `scala-projects`
|
|
47
|
+
* `scala-projects-bun-prod` -> `scala-projects`
|
|
48
|
+
* `scala-projects` -> `scala-projects` (already a capability name)
|
|
49
|
+
*/
|
|
50
|
+
export function toCapabilitySlug(slug: string): string {
|
|
51
|
+
const parts = slug.split("-");
|
|
52
|
+
if (parts.length < 3) return slug;
|
|
53
|
+
|
|
54
|
+
const tier = parts[parts.length - 1] as Tier;
|
|
55
|
+
const language = parts[parts.length - 2] as Language;
|
|
56
|
+
if (!TIERS.includes(tier) || !LANGUAGES.includes(language)) return slug;
|
|
57
|
+
|
|
58
|
+
return parts.slice(0, -2).join("-");
|
|
59
|
+
}
|
|
@@ -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
|
+
};
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* `page_route` rows (all 9 services carry empty arrays).
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
17
18
|
import type { Hono } from "hono";
|
|
18
19
|
import type { ServiceInstallConfig } from "./load-service-install-row.ts";
|
|
19
20
|
import type { RenderModeLiteral } from "./page-route-shell-types.ts";
|
|
@@ -58,5 +59,19 @@ export async function registerPageRoutes(
|
|
|
58
59
|
portalScoped,
|
|
59
60
|
});
|
|
60
61
|
|
|
62
|
+
// A kind:ui SERVICE THAT RESOLVES NO PAGES IS AIMED AT NOTHING, and until
|
|
63
|
+
// now that state booted clean: zero matched rows was treated as a valid
|
|
64
|
+
// shape, so the service reported healthy and answered 404 on every path,
|
|
65
|
+
// /sign-in included. Measured 2026-09-11: 8 of 10 dev UI services sat in
|
|
66
|
+
// exactly this state and nothing anywhere said so
|
|
67
|
+
// (`a-component-may-not-report-a-state-it-has-not-verified`).
|
|
68
|
+
if ((config as { kind?: string }).kind === "ui" && registryPatterns.length === 0) {
|
|
69
|
+
throw new BootFatalError(
|
|
70
|
+
"ui-service.no-page-routes",
|
|
71
|
+
serviceSlug,
|
|
72
|
+
`No active page_route row resolves to this service. A kind:ui service must serve at least one page. Check that a page_route row names this capability (rows are matched on the capability half of the deployable slug -- see capability-slug.ts).`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
61
76
|
return registryPatterns;
|
|
62
77
|
}
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
32
32
|
import type { Hono } from "hono";
|
|
33
33
|
import { bindPageHandler } from "./bind-page-handler.ts";
|
|
34
|
+
import { selectServiceRoutes } from "./select-service-routes.ts";
|
|
34
35
|
import { resolveRequestGraph } from "./register-registry-page-route/resolve-request-graph.ts";
|
|
35
36
|
import { stampSessionContext } from "./register-registry-page-route/stamp-session-context.ts";
|
|
36
37
|
import { runContentGatesForPage } from "./register-registry-page-route/run-content-gates.ts";
|
|
@@ -134,29 +135,9 @@ export async function registerRegistryPageRoute(
|
|
|
134
135
|
"page_route",
|
|
135
136
|
PageRouteConfigSchema,
|
|
136
137
|
);
|
|
137
|
-
const serviceRoutes = allRoutes
|
|
138
|
-
(r) => r.config.service === serviceSlug,
|
|
139
|
-
);
|
|
138
|
+
const serviceRoutes = selectServiceRoutes(allRoutes, serviceSlug);
|
|
140
139
|
if (serviceRoutes.length === 0) return [];
|
|
141
140
|
|
|
142
|
-
// Sort routes so more specific patterns register before wildcard/param routes.
|
|
143
|
-
// Hono matches by registration order, so `/*` (the portal catchall) registered
|
|
144
|
-
// before `/sign-in` would swallow the sign-in page and render it inside the
|
|
145
|
-
// PortalTemplate instead of the standalone PublicTemplate shell.
|
|
146
|
-
serviceRoutes.sort((a, b) => {
|
|
147
|
-
const pa = a.config.pattern;
|
|
148
|
-
const pb = b.config.pattern;
|
|
149
|
-
// Wildcard `/*` goes last
|
|
150
|
-
if (pa === "/*") return 1;
|
|
151
|
-
if (pb === "/*") return -1;
|
|
152
|
-
// Param routes `/:param` sort after literal routes
|
|
153
|
-
const aIsLiteral = !pa.includes(":") && !pa.includes("*");
|
|
154
|
-
const bIsLiteral = !pb.includes(":") && !pb.includes("*");
|
|
155
|
-
if (aIsLiteral && !bIsLiteral) return -1;
|
|
156
|
-
if (!aIsLiteral && bIsLiteral) return 1;
|
|
157
|
-
return 0;
|
|
158
|
-
});
|
|
159
|
-
|
|
160
141
|
const registeredPatterns: string[] = [];
|
|
161
142
|
|
|
162
143
|
for (const routeRow of serviceRoutes) {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { selectServiceRoutes } from "./select-service-routes.ts";
|
|
3
|
+
|
|
4
|
+
const row = (service: string, pattern: string) => ({ config: { service, pattern } });
|
|
5
|
+
|
|
6
|
+
const PROJECTS = [
|
|
7
|
+
row("scala-projects", "/:orgId"),
|
|
8
|
+
row("scala-projects", "/:orgId/kanban"),
|
|
9
|
+
row("scala-projects", "/sign-in"),
|
|
10
|
+
row("scala-portal", "/*"),
|
|
11
|
+
row("scala-quiz", "/"),
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
describe("selectServiceRoutes", () => {
|
|
15
|
+
test("a dev lane resolves the rows that name the capability", () => {
|
|
16
|
+
// The regression this module exists for: before the capability join, a
|
|
17
|
+
// dev lane matched ZERO rows and served 404 on every path.
|
|
18
|
+
const got = selectServiceRoutes(PROJECTS, "scala-projects-bun-dev");
|
|
19
|
+
expect(got.map((r) => r.config.pattern).sort()).toEqual([
|
|
20
|
+
"/:orgId",
|
|
21
|
+
"/:orgId/kanban",
|
|
22
|
+
"/sign-in",
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("both lanes of one application resolve the same set", () => {
|
|
27
|
+
const dev = selectServiceRoutes(PROJECTS, "scala-projects-bun-dev");
|
|
28
|
+
const prodBare = selectServiceRoutes(PROJECTS, "scala-projects");
|
|
29
|
+
const prodTiered = selectServiceRoutes(PROJECTS, "scala-projects-bun-prod");
|
|
30
|
+
expect(dev).toEqual(prodBare);
|
|
31
|
+
expect(dev).toEqual(prodTiered);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a row naming a deployable form still matches its lane", () => {
|
|
35
|
+
const rows = [row("scala-roadmap-bun-dev", "/")];
|
|
36
|
+
expect(selectServiceRoutes(rows, "scala-roadmap")).toHaveLength(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("never returns another service's rows", () => {
|
|
40
|
+
const got = selectServiceRoutes(PROJECTS, "scala-projects-bun-dev");
|
|
41
|
+
expect(got.every((r) => r.config.service === "scala-projects")).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("orders literal before param before wildcard", () => {
|
|
45
|
+
const rows = [
|
|
46
|
+
row("scala-portal", "/*"),
|
|
47
|
+
row("scala-portal", "/:orgId"),
|
|
48
|
+
row("scala-portal", "/sign-in"),
|
|
49
|
+
];
|
|
50
|
+
expect(
|
|
51
|
+
selectServiceRoutes(rows, "scala-portal").map((r) => r.config.pattern),
|
|
52
|
+
).toEqual(["/sign-in", "/:orgId", "/*"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("an unknown service resolves nothing", () => {
|
|
56
|
+
expect(selectServiceRoutes(PROJECTS, "scala-nonexistent-bun-dev")).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Selecting and ordering the `page_route` rows one service serves.
|
|
7
|
+
*
|
|
8
|
+
* Two decisions live here, both about WHICH rows and IN WHAT ORDER — never
|
|
9
|
+
* about what a route does once matched:
|
|
10
|
+
*
|
|
11
|
+
* 1. SELECTION joins on the CAPABILITY, never on the deployable name. A
|
|
12
|
+
* service arrives under its LANE identity (`scala-projects-bun-dev` in
|
|
13
|
+
* dev, `scala-projects` in prod), while a page_route row names the
|
|
14
|
+
* APPLICATION and there is ONE row for both lanes. Raw string equality
|
|
15
|
+
* therefore matched at most one lane, silently — see `./capability-slug.ts`
|
|
16
|
+
* for the measured incident.
|
|
17
|
+
*
|
|
18
|
+
* 2. ORDERING puts specific patterns before parameterised and wildcard ones,
|
|
19
|
+
* because Hono matches in REGISTRATION order.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { toCapabilitySlug } from "./capability-slug.ts";
|
|
23
|
+
|
|
24
|
+
/** The shape this module needs from a loaded page_route row. */
|
|
25
|
+
export interface SelectableRoute {
|
|
26
|
+
config: { pattern: string; service: string };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Specificity rank for registration order. Hono matches by registration
|
|
31
|
+
* order, so `/*` (the portal catchall) registered before `/sign-in` would
|
|
32
|
+
* swallow the sign-in page and render it inside the PortalTemplate instead
|
|
33
|
+
* of the standalone PublicTemplate shell.
|
|
34
|
+
*/
|
|
35
|
+
function comparePatternSpecificity(a: string, b: string): number {
|
|
36
|
+
// Wildcard `/*` goes last.
|
|
37
|
+
if (a === "/*") return 1;
|
|
38
|
+
if (b === "/*") return -1;
|
|
39
|
+
// Param routes `/:param` sort after literal routes.
|
|
40
|
+
const aIsLiteral = !a.includes(":") && !a.includes("*");
|
|
41
|
+
const bIsLiteral = !b.includes(":") && !b.includes("*");
|
|
42
|
+
if (aIsLiteral && !bIsLiteral) return -1;
|
|
43
|
+
if (!aIsLiteral && bIsLiteral) return 1;
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The page_route rows this service serves, in registration order.
|
|
49
|
+
*
|
|
50
|
+
* `serviceSlug` is the deployable identity of the calling lane; both sides
|
|
51
|
+
* of the join normalize through `toCapabilitySlug`, so a row may name either
|
|
52
|
+
* the capability or a deployable form and both lanes resolve the same set.
|
|
53
|
+
*/
|
|
54
|
+
export function selectServiceRoutes<T extends SelectableRoute>(
|
|
55
|
+
allRoutes: readonly T[],
|
|
56
|
+
serviceSlug: string,
|
|
57
|
+
): T[] {
|
|
58
|
+
const capability = toCapabilitySlug(serviceSlug);
|
|
59
|
+
const serviceRoutes = allRoutes.filter(
|
|
60
|
+
(r) => toCapabilitySlug(r.config.service) === capability,
|
|
61
|
+
);
|
|
62
|
+
serviceRoutes.sort((a, b) =>
|
|
63
|
+
comparePatternSpecificity(a.config.pattern, b.config.pattern),
|
|
64
|
+
);
|
|
65
|
+
return serviceRoutes;
|
|
66
|
+
}
|