@teamscala/service-boot 0.23.523 → 0.23.525

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.
Files changed (127) hide show
  1. package/package.json +1 -9
  2. package/src/agreement-gate/agreement-lookup.ts +0 -7
  3. package/src/agreement-gate/authorized-emails.ts +0 -14
  4. package/src/agreement-gate/placeholder-strip.ts +0 -10
  5. package/src/agreement-gate/resolution-caches.ts +0 -5
  6. package/src/agreement-gate/settings.ts +0 -22
  7. package/src/agreement-gate/signing-session.ts +0 -8
  8. package/src/agreement-gate/signing-shell.ts +0 -9
  9. package/src/agreement-gate.ts +0 -52
  10. package/src/apply-logger-config.ts +1 -9
  11. package/src/asset-cache-key.ts +1 -5
  12. package/src/asset-optimize.ts +1 -7
  13. package/src/asset-proxy.ts +1 -10
  14. package/src/asset-social-card.ts +1 -9
  15. package/src/attach-agreement-magic.ts +0 -14
  16. package/src/attach-portal-members.ts +0 -17
  17. package/src/attach-proposal-accept.ts +1 -21
  18. package/src/attach-proposal-magic.ts +1 -13
  19. package/src/attach-transfers-upload.ts +0 -21
  20. package/src/auth/sign-out.ts +1 -44
  21. package/src/auth-gate/public-paths.ts +1 -96
  22. package/src/auth-gate.ts +1 -41
  23. package/src/bind-page-handler.ts +1 -83
  24. package/src/build-client-assets/build-lock.ts +1 -11
  25. package/src/build-client-assets/hashed-output.ts +1 -11
  26. package/src/build-client-assets/hydrate-bundle.ts +1 -137
  27. package/src/build-client-assets/manifest.ts +1 -30
  28. package/src/build-client-assets/source-candidates.ts +0 -51
  29. package/src/build-client-assets/sourcemaps-upload.ts +1 -52
  30. package/src/build-client-assets/styles-build.ts +1 -71
  31. package/src/build-client-assets.ts +1 -30
  32. package/src/capability-slug.ts +1 -45
  33. package/src/compose-page-title.ts +1 -34
  34. package/src/content-gate/access.ts +0 -34
  35. package/src/content-gate/catalog.ts +0 -52
  36. package/src/content-gate/config-types.ts +3 -90
  37. package/src/content-gate/gate-entry-clears.ts +0 -18
  38. package/src/content-gate/gate.ts +0 -27
  39. package/src/content-gate/parse-cookie.ts +0 -4
  40. package/src/content-page-floor.ts +0 -28
  41. package/src/csrf-protection.ts +1 -28
  42. package/src/error-page-logo.ts +2 -35
  43. package/src/expand-roster.ts +1 -25
  44. package/src/gate-ttls.ts +0 -12
  45. package/src/gpt-gate.ts +1 -19
  46. package/src/hono-error-handler.ts +0 -19
  47. package/src/install-harness.ts +1 -12
  48. package/src/install-logging.ts +1 -47
  49. package/src/load-page-manifest.ts +1 -29
  50. package/src/load-service-install-row.ts +42 -10
  51. package/src/module-not-found.ts +0 -17
  52. package/src/mount-orpc-router.ts +1 -94
  53. package/src/mount-server-packages.ts +1 -37
  54. package/src/mount.ts +1 -4
  55. package/src/mutable-app-ref.ts +0 -21
  56. package/src/orpc-contract-rows.json +289 -0
  57. package/src/orpc-openapi-document.ts +1 -19
  58. package/src/orpc-openapi-gate.ts +1 -23
  59. package/src/page-route-shell-types.ts +1 -8
  60. package/src/page-server-loader-graph.ts +0 -10
  61. package/src/page-server-orpc/server-client.ts +1 -23
  62. package/src/pg-boss-handler-registry.ts +1 -24
  63. package/src/pg-boss-mount.ts +1 -109
  64. package/src/proposal-authorized-emails.ts +0 -32
  65. package/src/proposal-gate.ts +1 -52
  66. package/src/quiz-gate.ts +1 -31
  67. package/src/read-html-template.ts +1 -49
  68. package/src/readiness-gate.ts +0 -8
  69. package/src/register-api-routes.ts +1 -11
  70. package/src/register-crash-handlers.ts +1 -6
  71. package/src/register-page-routes.ts +1 -12
  72. package/src/register-page-server-client.ts +1 -66
  73. package/src/register-registry-page-route/ai-chat-prefetch.ts +1 -24
  74. package/src/register-registry-page-route/apply-page-branding.ts +1 -100
  75. package/src/register-registry-page-route/captured-loader-config.ts +1 -12
  76. package/src/register-registry-page-route/graph-cache.ts +1 -38
  77. package/src/register-registry-page-route/live-loader-config.ts +1 -22
  78. package/src/register-registry-page-route/meta-title.ts +1 -36
  79. package/src/register-registry-page-route/page-data-shared.ts +1 -18
  80. package/src/register-registry-page-route/prefetch-org-id.ts +1 -24
  81. package/src/register-registry-page-route/proposal-approval-email-token.ts +1 -22
  82. package/src/register-registry-page-route/proposal-brand-stamping.ts +1 -31
  83. package/src/register-registry-page-route/public-shell-page-data.ts +1 -34
  84. package/src/register-registry-page-route/region-slots-page-data.ts +1 -8
  85. package/src/register-registry-page-route/resolve-page-data.ts +1 -10
  86. package/src/register-registry-page-route/resolve-panel-page.ts +1 -10
  87. package/src/register-registry-page-route/resolve-request-graph.ts +1 -68
  88. package/src/register-registry-page-route/run-content-gates.ts +3 -64
  89. package/src/register-registry-page-route/single-page-data.ts +1 -9
  90. package/src/register-registry-page-route/stamp-session-context.ts +1 -10
  91. package/src/register-registry-page-route/warn-uncovered-blocks.ts +1 -18
  92. package/src/register-registry-page-route.ts +1 -93
  93. package/src/register-static-assets.ts +1 -53
  94. package/src/register-ws-routes.ts +1 -21
  95. package/src/resolve-brand-favicon.ts +0 -22
  96. package/src/resolve-service-port.ts +1 -61
  97. package/src/resolve-theme-mode.ts +1 -30
  98. package/src/resolvers/app-header.ts +1 -20
  99. package/src/resolvers/fetch-org-brand.ts +1 -11
  100. package/src/resolvers/portal-route-paths.ts +1 -29
  101. package/src/resolvers/website-by-host-map.ts +1 -7
  102. package/src/resource-not-found.ts +0 -48
  103. package/src/run-boot-hook-list.ts +1 -13
  104. package/src/run-boot-hooks.ts +1 -19
  105. package/src/scala-tools-currency-watchdog.ts +1 -7
  106. package/src/security-headers.ts +1 -7
  107. package/src/select-service-routes.ts +1 -31
  108. package/src/server-error-page.ts +0 -18
  109. package/src/theme-fonts-link.ts +1 -21
  110. package/src/transfers/presigned-upload.ts +1 -27
  111. package/src/two-phase-full-app.ts +0 -82
  112. package/src/two-phase-minimal-app.ts +1 -111
  113. package/src/two-phase-readiness.ts +0 -6
  114. package/src/types.ts +0 -17
  115. package/src/ui-service.ts +2 -48
  116. package/src/ws-boot-self-test.ts +1 -14
  117. package/src/content-gate/gate-entry-marker.test.ts +0 -111
  118. package/src/content-gate/token.segmentation.test.ts +0 -76
  119. package/src/content-gate/token.test.ts +0 -65
  120. package/src/content-gate/token.ts +0 -219
  121. package/src/is-react-element-type.test.ts +0 -45
  122. package/src/is-react-element-type.ts +0 -20
  123. package/src/page-server-orpc/hub-mcp-client.ts +0 -20
  124. package/src/pg-boss-mount.test.ts +0 -101
  125. package/src/resource-chat-inject.page-assistant.test.ts +0 -145
  126. package/src/resource-chat-inject.test.ts +0 -240
  127. package/src/resource-chat-inject.ts +0 -374
@@ -1,12 +1,6 @@
1
1
  /**
2
2
  * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Phase-2 readiness state box for the two-phase boot: the real page handler +
7
- * readiness flags (private lets), the writers/flippers, the `/health` endpoint
8
- * that gates on them, and the phase-1 page handler that delegates to the real
9
- * handler once phase 2 completes. Every reader goes through these exports.
10
4
  */
11
5
 
12
6
  /**
package/src/types.ts CHANGED
@@ -1,9 +1,6 @@
1
1
  /**
2
2
  * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Shared type definitions for service-boot boot helpers.
7
4
  */
8
5
 
9
6
  type RendererComponent = (props: Record<string, unknown>) => unknown;
@@ -11,9 +8,6 @@ type RendererComponent = (props: Record<string, unknown>) => unknown;
11
8
  export interface RendererComponentEntry {
12
9
  render: RendererComponent;
13
10
  fields?: Record<string, { type: string }>;
14
- /** block_schema.defaults, carried onto the entry by discoverBlocks so
15
- * buildPuckConfig merges them under authored props at SSR
16
- * (blocks-fully-programmable). */
17
11
  defaultProps?: Record<string, unknown>;
18
12
  }
19
13
 
@@ -34,18 +28,7 @@ export interface PageHandlerConfig {
34
28
  providers?: string[];
35
29
  loadData: (request: Request) => Promise<unknown>;
36
30
  renderMode?: "public" | "portals" | "utility";
37
- /**
38
- * Owning service slug — used by the page-server render catch to resolve
39
- * the branded logo on the render-error page. Mirrors service-runtime's
40
- * PageHandlerConfig.
41
- */
42
31
  serviceSlug?: string;
43
- /**
44
- * Redirect a signed-in user's `/` to their org-scoped portal dashboard.
45
- * Set only for services serving a `/*` catchall page_route (the portal);
46
- * single-site portal-mode services serve their real page at `/`.
47
- * Mirrors service-runtime's PageHandlerConfig (1.15.82).
48
- */
49
32
  portalHomeRedirect?: boolean;
50
33
  }
51
34
 
package/src/ui-service.ts CHANGED
@@ -1,11 +1,6 @@
1
1
  /**
2
- * @system ui-service-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Boots a `kind: "ui"` service. Reads the service_install registry row and
7
- * orchestrates a fixed pipeline of single-purpose stages onto one Hono app
8
- * served by @teamscala/service-runtime/serve (one Bun.serve in dev and prod).
9
4
  */
10
5
 
11
6
  /**
@@ -22,32 +17,7 @@
22
17
  * place and is NOT undone.
23
18
  */
24
19
 
25
- // Resolve the listen port through the SHARED resolver (`resolveServicePort`),
26
- // the same one the backend branch uses. The registry wins wherever it has an
27
- // opinion and a contradicting PORT env is reported, never silently obeyed —
28
- // full reasoning in that file.
29
- //
30
- // This branch previously took `process.env.PORT` unconditionally, which is
31
- // how scala-quiz (declared 5203) booted onto session-picker's 3021 from a
32
- // leaked env and silently split that daemon's traffic (2026-07-31). On the
33
- // dev host the registry wins a conflict (below). On Railway the injected PORT
34
- // wins instead — Railway healthchecks exactly that port, and the row's port
35
- // is the service's LOCAL dev port (e.g. portal 5200), so binding it on Railway
36
- // makes the healthcheck probe a dead port and the deploy fails (`isRailway`
37
- // path in the resolver).
38
-
39
- // NO-SILENT-REVERTS — surface the failed client build to OBSERVABILITY
40
- // (event-log → GlitchTip, per event-log-is-the-only-durable-event-log).
41
- // The minimal-app fallback keeps serving SSR from the LAST-successful
42
- // manifest (now a tombstone, see build-client-assets.ts), so the page
43
- // looks "up" while the client bundle is stale. Without this signal the
44
- // failure is buried in boot stderr and a published fix that reached
45
- // node_modules but failed to bundle ships invisibly stale (the
46
- // scala-agents 2026-07-18 incident: ui-foundation@3.4.5 installed but
47
- // served pre-fix bytes across restarts while node_modules was
48
- // mid-install). The boot.client-build-failed event routes into
49
- // event-log's 7-day retention → surfaces in GlitchTip as an incident
50
- // trail the operator actually watches.
20
+
51
21
 
52
22
  import { recordEvent } from "@teamscala/event-log/record-event";
53
23
  import { getAppLogger } from "@teamscala/logger/app-loggers";
@@ -96,7 +66,6 @@ export async function bootUiService(
96
66
  const log = getAppLogger();
97
67
  const startTime = Date.now();
98
68
 
99
- // Phase 1: Build minimal tool-only app
100
69
  const minimalAppResult = await buildMinimalApp(
101
70
  config,
102
71
  serviceSlug,
@@ -107,12 +76,6 @@ export async function bootUiService(
107
76
 
108
77
  const isRailway = Boolean(process.env.RAILWAY_ENVIRONMENT);
109
78
  const envPort = process.env.PORT ? Number(process.env.PORT) : undefined;
110
- // The APP binds its PRIVATE port (app_port) when present — for scale-to-zero
111
- // services `port` is the ACTIVATOR's ingress (what callers resolve), not the
112
- // app's bind. Falling back to `port` keeps RESIDENT + unmigrated services
113
- // unchanged. Without this, a migrated row (port=activator) would make the app
114
- // bind the activator's port → SO_REUSEPORT silent load-balancing (the
115
- // service-ports-from-registry trap: clean boot, two owners in ss -ltnp).
116
79
  const declaredAppPort = config.app_port ?? config.port;
117
80
  const resolution = resolveServicePort(envPort, declaredAppPort, isRailway);
118
81
  if ("error" in resolution) {
@@ -134,21 +97,17 @@ export async function bootUiService(
134
97
  }
135
98
  const port = requireValidPort(resolution.port, serviceSlug);
136
99
 
137
- // service-runtime owns the TCP listener. service-boot only composes the app
138
- // and starts the two-phase build; the runtime binds the returned app.
139
100
  const minimalReadyTime = Date.now();
140
101
  const minimalDuration = minimalReadyTime - startTime;
141
102
  log.info(
142
103
  `[ui-service-boot:${serviceSlug}] Minimal app composed on port ${port} in ${minimalDuration}ms`,
143
104
  );
144
105
 
145
- // FLIP READY SWITCH: minimal app is ready, MCP tools can connect
146
106
  setReady();
147
107
  log.info(
148
108
  `[ui-service-boot:${serviceSlug}] Ready gate flipped - MCP tools can now connect (full app building in background)`,
149
109
  );
150
110
 
151
- // Phase 2: Build full app in background (non-blocking)
152
111
  ;(async () => {
153
112
  try {
154
113
  const PHASE2_TIMEOUT_MS = 120_000;
@@ -164,7 +123,6 @@ export async function bootUiService(
164
123
  const fullBuildTime = Date.now();
165
124
  const fullDuration = fullBuildTime - startTime;
166
125
 
167
- // Swap the app reference atomically
168
126
  swapToFullApp(fullAppResult.app, serviceSlug);
169
127
 
170
128
  log.info(
@@ -183,13 +141,9 @@ export async function bootUiService(
183
141
  at: new Date().toISOString(),
184
142
  },
185
143
  });
186
- // Continue with minimal app - better than crashing
187
144
  }
188
145
  })();
189
146
 
190
- // Return result compatible with the existing UiServiceBootResult interface
191
- // Note: blocks/routes will be empty initially since they're built in the background.
192
- // The service is operational for MCP tools.
193
147
  return {
194
148
  kind: "ui",
195
149
  blocks: [], // Will be populated when full app builds
@@ -1,19 +1,6 @@
1
1
  /**
2
- * @system server-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Post-listen WebSocket self-test. Runs after serveStart binds the port.
7
- * For each declared websocket_routes path, opens a synthetic WebSocket
8
- * connection and asserts 101 upgrade. Non-101 → BootFatalError.
9
- *
10
- * This catches the class of bugs where:
11
- * - WS route registered after ORPC catch-all (order-dependent 404)
12
- * - fetchWithPeerPort clones the Request on WS upgrade (breaks Bun.upgrade identity)
13
- * - websocket_routes config row is stale / missing a handler
14
- *
15
- * Runs synchronously after serveStart so the boot aborts before the service
16
- * reports healthy.
17
4
  */
18
5
 
19
6
  import type { ServiceInstallConfig } from "./load-service-install-row";
@@ -1,111 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Locks the OPT-IN gate-entry marker. A gate that does NOT declare
7
- * entryCookieName must behave exactly as before — a valid token authorizes on
8
- * any load and nothing is recorded for clearing (condition 1: every non-opting
9
- * service unchanged). An opting gate authorizes ONLY the request that carries
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).
16
- *
17
- * Operator ruling 2026-09-14 (refresh must return to the identity gate),
18
- * mechanism measured live: the quiz_token cookie alone was the carrier that
19
- * skipped the gate on reload.
20
- */
21
- import { describe, expect, test } from "bun:test";
22
- import type { ContentGateConfig } from "./config-types.ts";
23
- import { runContentGate } from "./gate.ts";
24
- import { withGateEntryClears } from "./gate-entry-clears.ts";
25
-
26
- function makeConfig(entryCookieName?: string) {
27
- const calls = { authorized: 0, unauthorized: 0 };
28
- const cfg: ContentGateConfig = {
29
- cookieName: "quiz_token",
30
- entryCookieName,
31
- getResourceId: () => "res-1",
32
- verifyToken: (token) =>
33
- token === "good" ? { email: "v@example.com", name: "V" } : null,
34
- onAuthorized: () => {
35
- calls.authorized++;
36
- },
37
- onUnauthorized: () => {
38
- calls.unauthorized++;
39
- },
40
- };
41
- return { cfg, calls };
42
- }
43
-
44
- describe("gate entry marker is opt-in and one-view", () => {
45
- test("non-opting gate: a valid token authorizes on any load, nothing cleared — behaviour unchanged", async () => {
46
- const { cfg, calls } = makeConfig();
47
- const { clears } = await withGateEntryClears(async () => {
48
- const out = await runContentGate({}, "quiz_token=good", Date.now(), cfg);
49
- expect(out.authorized).toBe(true);
50
- expect(calls.authorized).toBe(1);
51
- });
52
- expect(clears).toEqual([]);
53
- });
54
-
55
- test("opting gate: token WITHOUT the marker is unauthorized — a refresh shows the gate", async () => {
56
- const { cfg, calls } = makeConfig("quiz_token_entered");
57
- const { clears } = await withGateEntryClears(async () => {
58
- const out = await runContentGate({}, "quiz_token=good", Date.now(), cfg);
59
- expect(out.authorized).toBe(false);
60
- expect(calls.unauthorized).toBe(1);
61
- expect(calls.authorized).toBe(0);
62
- });
63
- expect(clears).toEqual([]);
64
- });
65
-
66
- test("opting gate: token + marker authorizes and RETURNS the marker for clearing — one view", async () => {
67
- const { cfg, calls } = makeConfig("quiz_token_entered");
68
- const { clears } = await withGateEntryClears(async () => {
69
- const out = await runContentGate(
70
- {},
71
- "quiz_token=good; quiz_token_entered=1",
72
- Date.now(),
73
- cfg,
74
- );
75
- expect(out.authorized).toBe(true);
76
- expect(calls.authorized).toBe(1);
77
- });
78
- expect(clears).toEqual(["quiz_token_entered"]);
79
- });
80
-
81
- test("opting gate: marker present but token invalid stays unauthorized and clears nothing", async () => {
82
- const { cfg, calls } = makeConfig("quiz_token_entered");
83
- const { clears } = await withGateEntryClears(async () => {
84
- const out = await runContentGate(
85
- {},
86
- "quiz_token=bad; quiz_token_entered=1",
87
- Date.now(),
88
- cfg,
89
- );
90
- expect(out.authorized).toBe(false);
91
- expect(calls.unauthorized).toBe(1);
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"]);
104
- });
105
-
106
- test("the quiz gate and its access endpoint both opt in on the same marker name", async () => {
107
- const { quizAccessConfig, quizGateConfig } = await import("../quiz-gate.ts");
108
- expect(quizGateConfig.entryCookieName).toBe("quiz_token_entered");
109
- expect(quizAccessConfig.entryCookieName).toBe("quiz_token_entered");
110
- });
111
- });
@@ -1,76 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Segmentation carriage in the content-gate token. The quiz's composed gate
7
- * screen captures company/linkedin_url/website/team_size alongside the
8
- * identity; the token carries them to the next render so the submit can put
9
- * all seven fields on the ONE response row with no client storage. The
10
- * generated `token.test.ts` owns the identity/binding suite; these are the
11
- * segmentation-specific cases (including the backward-compatibility contract:
12
- * a token minted before `s` existed verifies identically).
13
- */
14
-
15
- process.env.AUTH_SECRET = "local-test-fixture-entropy-0001";
16
-
17
- import { describe, expect, test } from "bun:test";
18
- import { signAccessToken, verifyAccessTokenIdentity } from "./token.ts";
19
-
20
- const NOW = 1_000_000_000_000;
21
- const TTL = 60_000;
22
-
23
- function decodePayload(token: string): Record<string, unknown> {
24
- return JSON.parse(Buffer.from(token.split(".")[0], "base64url").toString());
25
- }
26
-
27
- describe("content-gate token — segmentation carriage", () => {
28
- test("round-trips the four captured fields", () => {
29
- const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, "Ann", "+614", {
30
- company: "Acme",
31
- linkedinUrl: "https://www.linkedin.com/in/ann",
32
- website: "https://acme.test",
33
- teamSize: "Me + team of 2–5",
34
- });
35
- const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
36
- expect(id?.email).toBe("a@b.com");
37
- expect(id?.name).toBe("Ann");
38
- expect(id?.phone).toBe("+614");
39
- expect(id?.segmentation).toEqual({
40
- company: "Acme",
41
- linkedinUrl: "https://www.linkedin.com/in/ann",
42
- website: "https://acme.test",
43
- teamSize: "Me + team of 2–5",
44
- });
45
- });
46
-
47
- test("BACKWARD COMPATIBILITY: a pre-widening token (no s key) verifies with segmentation undefined — never an error", () => {
48
- // Mint with the old shape by omitting segmentation entirely, then assert
49
- // the exact contract a deploy-window survivor must satisfy.
50
- const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, "Ann", "+614");
51
- expect(decodePayload(t as string).s).toBeUndefined();
52
- const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
53
- expect(id).toEqual({ email: "a@b.com", name: "Ann", phone: "+614", segmentation: undefined });
54
- });
55
-
56
- test("empty/whitespace segmentation values produce no s key at all", () => {
57
- const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, undefined, undefined, {
58
- company: " ",
59
- linkedinUrl: "",
60
- website: undefined,
61
- teamSize: undefined,
62
- });
63
- expect(decodePayload(t as string).s).toBeUndefined();
64
- const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
65
- expect(id?.segmentation).toBeUndefined();
66
- });
67
-
68
- test("values are trimmed on sign", () => {
69
- const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, undefined, undefined, {
70
- company: " Acme ",
71
- teamSize: "Just me",
72
- });
73
- const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
74
- expect(id?.segmentation).toEqual({ company: "Acme", teamSize: "Just me" });
75
- });
76
- });
@@ -1,65 +0,0 @@
1
- // @system codegen
2
- // @status generated
3
- // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
- //
5
- // This suite's assertions are OWNED by the codegen band: the band module
6
- // carries them verbatim, this file is the emission, and hand edits here are
7
- // overwritten on the next run. The rationale each assertion carries moved
8
- // with it into the band.
9
-
10
- process.env.AUTH_SECRET = "test-secret-at-least-16-chars-long";
11
-
12
- import { describe, expect, test } from "bun:test";
13
- import {
14
- signAccessToken,
15
- verifyAccessToken,
16
- verifyAccessTokenIdentity,
17
- } from "./token.ts";
18
-
19
- const NOW = 1_000_000_000_000;
20
- const TTL = 60_000;
21
-
22
- describe("content-gate token — identity", () => {
23
- test("signs + verifies email AND name (GPT capture)", () => {
24
- const t = signAccessToken("gpt_token", "gpt-1", "A@B.com", NOW, TTL, "Ann");
25
- expect(t).toBeTruthy();
26
- const id = verifyAccessTokenIdentity(t, "gpt_token", "gpt-1", NOW + 1);
27
- expect(id).toEqual({ email: "a@b.com", name: "Ann" });
28
- });
29
-
30
- test("name omitted → identity has email, name undefined", () => {
31
- const t = signAccessToken("gpt_token", "gpt-1", "a@b.com", NOW, TTL);
32
- const id = verifyAccessTokenIdentity(t, "gpt_token", "gpt-1", NOW + 1);
33
- expect(id).toEqual({ email: "a@b.com", name: undefined });
34
- });
35
-
36
- test("email-only verifyAccessToken still works (proposals/agreements)", () => {
37
- const t = signAccessToken("prop_token", "p-1", "a@b.com", NOW, TTL);
38
- expect(verifyAccessToken(t, "prop_token", "p-1", NOW + 1)).toBe("a@b.com");
39
- });
40
- });
41
-
42
- describe("content-gate token — binding (withholds content)", () => {
43
- const t = signAccessToken("gpt_token", "gpt-1", "a@b.com", NOW, TTL, "Ann");
44
-
45
- test("wrong gate (cookieName) → null", () => {
46
- expect(verifyAccessTokenIdentity(t, "prop_token", "gpt-1", NOW + 1)).toBeNull();
47
- });
48
-
49
- test("wrong resource → null", () => {
50
- expect(verifyAccessTokenIdentity(t, "gpt_token", "gpt-2", NOW + 1)).toBeNull();
51
- });
52
-
53
- test("expired → null", () => {
54
- expect(verifyAccessTokenIdentity(t, "gpt_token", "gpt-1", NOW + TTL + 1)).toBeNull();
55
- });
56
-
57
- test("tampered signature → null", () => {
58
- const bad = `${t?.split(".")[0]}.deadbeef`;
59
- expect(verifyAccessTokenIdentity(bad, "gpt_token", "gpt-1", NOW + 1)).toBeNull();
60
- });
61
-
62
- test("missing token → null", () => {
63
- expect(verifyAccessTokenIdentity(undefined, "gpt_token", "gpt-1", NOW)).toBeNull();
64
- });
65
- });
@@ -1,219 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The single signed, short-lived access token for EVERY content gate (agreement,
7
- * proposal, and any future gated content). A gate withholds page content
8
- * server-side until the request carries a valid token for that resource; this is
9
- * the only thing that releases the content into the SSR, so without it a gated
10
- * page is never readable/scrapable by a link holder.
11
- *
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.
15
- * `cookieName` is bound INTO the payload so a token minted for one gate
16
- * (e.g. ag_token) can never satisfy another gate's check (e.g. prop_token).
17
- *
18
- * Secret resolver, in preference order: AUTH_SECRET → ENCRYPTION_KEY →
19
- * the tenant DB address via @teamscala/db's named-need resolver (first one
20
- * ≥16 chars). A gate signs AND verifies inside one
21
- * service, so it only needs a stable per-deployment secret; the fallback keeps a
22
- * gate working on a service that was never given AUTH_SECRET (e.g. the former
23
- * public-only proposals viewer) instead of failing closed and locking out every
24
- * authorized viewer. Any service that has AUTH_SECRET transparently prefers it.
25
- * Fail-closed: no usable secret yields null (no token, no access).
26
- */
27
-
28
- import type { GateSegmentation } from "./config-types.ts";
29
- import { resolveDbUrlOrNull } from "@teamscala/db/db-url";
30
-
31
- function secret(): string | null {
32
- // The last resort is the tenant DB address, read through the named-need
33
- // resolver (wrong-database guard, 2026-09-27) — never `process.env` here;
34
- // the resolver is the only env-aware surface and it names the plane. It is
35
- // used only as a stable per-deployment string (≥16 chars), never as a
36
- // connection target.
37
- for (const s of [
38
- process.env.AUTH_SECRET,
39
- process.env.ENCRYPTION_KEY,
40
- resolveDbUrlOrNull("tenant"),
41
- ]) {
42
- if (s && s.length >= 16) return s;
43
- }
44
- return null;
45
- }
46
-
47
- function hmac(payloadB64: string, key: string): string {
48
- return new Bun.CryptoHasher("sha256", key).update(payloadB64).digest("base64url");
49
- }
50
-
51
- /** Constant-time string compare (equal-length); avoids node:crypto. */
52
- function safeEqual(a: string, b: string): boolean {
53
- if (a.length !== b.length) return false;
54
- let diff = 0;
55
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
56
- return diff === 0;
57
- }
58
-
59
- /** Mint a token proving `email` is authorized for `resourceId` under `cookieName`. */
60
- export function signAccessToken(
61
- cookieName: string,
62
- resourceId: string,
63
- email: string,
64
- nowMs: number,
65
- ttlMs: number,
66
- /**
67
- * Optional display name to carry in the token (the public GPT lead-capture
68
- * gate uses this so a returning visitor's name is known at SSR — first paint
69
- * renders the full identity, not just the email). Allow-list gates
70
- * (proposals/agreements) omit it.
71
- */
72
- name?: string,
73
- /**
74
- * Optional captured phone, same reasoning as `name`. It exists so a capture
75
- * gate needs NO browser storage to remember it: the operator's ruling is
76
- * that identity lives in the database and the request, never in
77
- * localStorage.
78
- */
79
- phone?: string,
80
- /**
81
- * Optional prospect segmentation captured alongside the identity on the
82
- * quiz's composed gate screen (company / LinkedIn / website / team size).
83
- * Same reasoning as name/phone: it rides the signed request, never client
84
- * storage. OPTIONAL and absent from older tokens — a token without it
85
- * verifies exactly as before.
86
- */
87
- segmentation?: GateSegmentation,
88
- ): string | null {
89
- const key = secret();
90
- if (!key) return null;
91
- const payload: {
92
- c: string;
93
- r: string;
94
- e: string;
95
- x: number;
96
- n?: string;
97
- p?: string;
98
- s?: GateSegmentation;
99
- } = {
100
- c: cookieName,
101
- r: resourceId,
102
- e: email.trim().toLowerCase(),
103
- x: nowMs + ttlMs,
104
- };
105
- const trimmedName = name?.trim();
106
- if (trimmedName) payload.n = trimmedName;
107
- const trimmedPhone = phone?.trim();
108
- if (trimmedPhone) payload.p = trimmedPhone;
109
- const seg: GateSegmentation = {};
110
- const segCompany = segmentation?.company?.trim();
111
- if (segCompany) seg.company = segCompany;
112
- const segLinkedin = segmentation?.linkedinUrl?.trim();
113
- if (segLinkedin) seg.linkedinUrl = segLinkedin;
114
- const segWebsite = segmentation?.website?.trim();
115
- if (segWebsite) seg.website = segWebsite;
116
- const segTeamSize = segmentation?.teamSize?.trim();
117
- if (segTeamSize) seg.teamSize = segTeamSize;
118
- if (Object.keys(seg).length > 0) payload.s = seg;
119
- const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
120
- return `${payloadB64}.${hmac(payloadB64, key)}`;
121
- }
122
-
123
- /**
124
- * Validate a token for `cookieName` + `resourceId`. Returns the verified email
125
- * on success, null otherwise (bad signature, wrong gate, wrong resource,
126
- * expired, no secret).
127
- */
128
- export function verifyAccessToken(
129
- token: string | undefined | null,
130
- cookieName: string,
131
- resourceId: string,
132
- nowMs: number,
133
- ): string | null {
134
- if (!token) return null;
135
- const key = secret();
136
- if (!key) return null;
137
- const dot = token.indexOf(".");
138
- if (dot <= 0) return null;
139
- const payloadB64 = token.slice(0, dot);
140
- const sig = token.slice(dot + 1);
141
- if (!safeEqual(sig, hmac(payloadB64, key))) return null;
142
- try {
143
- const p = JSON.parse(Buffer.from(payloadB64, "base64url").toString()) as {
144
- c?: string;
145
- r?: string;
146
- e?: string;
147
- x?: number;
148
- };
149
- if (p.c !== cookieName) return null;
150
- if (p.r !== resourceId) return null;
151
- if (typeof p.x !== "number" || p.x < nowMs) return null;
152
- return typeof p.e === "string" ? p.e : null;
153
- } catch {
154
- return null;
155
- }
156
- }
157
-
158
- /**
159
- * Like `verifyAccessToken` but returns the full verified identity
160
- * ({email, name, phone, segmentation}) so a gate that carries a display name in
161
- * the token (the public GPT lead-capture gate) can seed the visitor's name — not
162
- * just the email — at SSR, and the quiz gate can seed the captured prospect
163
- * segmentation the same way. Same signature/expiry/gate-binding checks; null on
164
- * any failure. A token minted BEFORE segmentation existed carries no `s` key and
165
- * verifies identically (segmentation comes back undefined) — never an error.
166
- */
167
- export function verifyAccessTokenIdentity(
168
- token: string | undefined | null,
169
- cookieName: string,
170
- resourceId: string,
171
- nowMs: number,
172
- ): {
173
- email: string;
174
- name?: string;
175
- phone?: string;
176
- segmentation?: GateSegmentation;
177
- } | null {
178
- if (!token) return null;
179
- const key = secret();
180
- if (!key) return null;
181
- const dot = token.indexOf(".");
182
- if (dot <= 0) return null;
183
- const payloadB64 = token.slice(0, dot);
184
- const sig = token.slice(dot + 1);
185
- if (!safeEqual(sig, hmac(payloadB64, key))) return null;
186
- try {
187
- const p = JSON.parse(Buffer.from(payloadB64, "base64url").toString()) as {
188
- c?: string;
189
- r?: string;
190
- e?: string;
191
- x?: number;
192
- n?: string;
193
- p?: string;
194
- s?: GateSegmentation;
195
- };
196
- if (p.c !== cookieName) return null;
197
- if (p.r !== resourceId) return null;
198
- if (typeof p.x !== "number" || p.x < nowMs) return null;
199
- if (typeof p.e !== "string") return null;
200
- const segmentation =
201
- p.s && typeof p.s === "object"
202
- ? {
203
- company: typeof p.s.company === "string" ? p.s.company : undefined,
204
- linkedinUrl:
205
- typeof p.s.linkedinUrl === "string" ? p.s.linkedinUrl : undefined,
206
- website: typeof p.s.website === "string" ? p.s.website : undefined,
207
- teamSize: typeof p.s.teamSize === "string" ? p.s.teamSize : undefined,
208
- }
209
- : undefined;
210
- return {
211
- email: p.e,
212
- name: typeof p.n === "string" ? p.n : undefined,
213
- phone: typeof p.p === "string" ? p.p : undefined,
214
- segmentation,
215
- };
216
- } catch {
217
- return null;
218
- }
219
- }