@teamscala/service-boot 0.23.524 → 0.23.526

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 (83) hide show
  1. package/package.json +1 -11
  2. package/src/agreement-gate/authorized-emails.ts +0 -7
  3. package/src/agreement-gate/placeholder-strip.ts +0 -2
  4. package/src/agreement-gate/settings.ts +0 -15
  5. package/src/agreement-gate.ts +0 -34
  6. package/src/attach-proposal-accept.ts +1 -1
  7. package/src/attach-proposal-magic.ts +1 -1
  8. package/src/auth/sign-out.ts +0 -2
  9. package/src/auth-gate/public-paths.ts +0 -86
  10. package/src/auth-gate.ts +0 -19
  11. package/src/bind-page-handler.ts +0 -76
  12. package/src/build-client-assets/hydrate-bundle.ts +0 -126
  13. package/src/build-client-assets/manifest.ts +0 -18
  14. package/src/build-client-assets/source-candidates.ts +0 -34
  15. package/src/build-client-assets/sourcemaps-upload.ts +0 -42
  16. package/src/build-client-assets/styles-build.ts +0 -50
  17. package/src/capability-slug.ts +0 -10
  18. package/src/compose-page-title.ts +0 -9
  19. package/src/content-gate/access.ts +0 -26
  20. package/src/content-gate/catalog.ts +0 -29
  21. package/src/content-gate/config-types.ts +3 -84
  22. package/src/content-gate/gate.ts +0 -20
  23. package/src/content-page-floor.ts +0 -11
  24. package/src/error-page-logo.ts +2 -12
  25. package/src/expand-roster.ts +0 -9
  26. package/src/gpt-gate.ts +1 -1
  27. package/src/hono-error-handler.ts +0 -4
  28. package/src/install-logging.ts +0 -32
  29. package/src/load-page-manifest.ts +0 -6
  30. package/src/load-service-install-row.ts +41 -4
  31. package/src/module-not-found.ts +0 -11
  32. package/src/mount-orpc-router.ts +1 -88
  33. package/src/mount-server-packages.ts +0 -29
  34. package/src/mutable-app-ref.ts +0 -11
  35. package/src/orpc-contract-rows.json +4102 -61
  36. package/src/orpc-openapi-document.ts +0 -6
  37. package/src/orpc-openapi-gate.ts +0 -10
  38. package/src/pg-boss-handler-registry.ts +0 -12
  39. package/src/pg-boss-mount.ts +0 -101
  40. package/src/proposal-gate.ts +1 -41
  41. package/src/quiz-gate.ts +1 -9
  42. package/src/register-page-server-client.ts +0 -53
  43. package/src/register-registry-page-route/ai-chat-prefetch.ts +0 -18
  44. package/src/register-registry-page-route/apply-page-branding.ts +0 -90
  45. package/src/register-registry-page-route/captured-loader-config.ts +0 -1
  46. package/src/register-registry-page-route/live-loader-config.ts +0 -16
  47. package/src/register-registry-page-route/meta-title.ts +0 -24
  48. package/src/register-registry-page-route/page-data-shared.ts +0 -6
  49. package/src/register-registry-page-route/prefetch-org-id.ts +0 -17
  50. package/src/register-registry-page-route/proposal-approval-email-token.ts +0 -11
  51. package/src/register-registry-page-route/proposal-brand-stamping.ts +0 -20
  52. package/src/register-registry-page-route/public-shell-page-data.ts +0 -26
  53. package/src/register-registry-page-route/resolve-request-graph.ts +2 -155
  54. package/src/register-registry-page-route/run-content-gates.ts +2 -54
  55. package/src/register-registry-page-route/warn-uncovered-blocks.ts +0 -10
  56. package/src/register-registry-page-route.ts +0 -85
  57. package/src/register-static-assets.ts +0 -45
  58. package/src/resolve-brand-favicon.ts +0 -9
  59. package/src/resolve-service-port.ts +0 -11
  60. package/src/resolve-theme-mode.ts +0 -8
  61. package/src/resolvers/app-header.ts +0 -1
  62. package/src/resolvers/fetch-org-brand.ts +0 -1
  63. package/src/resolvers/portal-route-paths.ts +0 -17
  64. package/src/resource-not-found.ts +0 -24
  65. package/src/select-service-routes.ts +0 -14
  66. package/src/theme-fonts-link.ts +0 -5
  67. package/src/two-phase-full-app.ts +0 -76
  68. package/src/two-phase-minimal-app.ts +1 -105
  69. package/src/types.ts +0 -14
  70. package/src/ui-service.ts +1 -42
  71. package/src/content-gate/gate-entry-marker.test.ts +0 -111
  72. package/src/content-gate/token.segmentation.test.ts +0 -76
  73. package/src/content-gate/token.test.ts +0 -65
  74. package/src/content-gate/token.ts +0 -197
  75. package/src/is-react-element-type.test.ts +0 -45
  76. package/src/is-react-element-type.ts +0 -10
  77. package/src/page-server-orpc/hub-mcp-client.ts +0 -14
  78. package/src/pg-boss-mount.test.ts +0 -101
  79. package/src/register-registry-page-route/graph-cache.test.ts +0 -81
  80. package/src/register-registry-page-route/graph-cache.ts +0 -71
  81. package/src/resource-chat-inject.page-assistant.test.ts +0 -145
  82. package/src/resource-chat-inject.test.ts +0 -240
  83. package/src/resource-chat-inject.ts +0 -357
@@ -3,39 +3,6 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
- /**
7
- * Build the minimal tool-only app. This excludes the expensive operations:
8
- * - Client asset building (Vite build)
9
- * - Boot hooks (may have expensive initialization)
10
- * - Scheduled jobs (can start later)
11
- * - pg-boss (can start later)
12
- *
13
- * CRITICAL: All route registration MUST happen here before serving, because
14
- * Hono's RegExpRouter compiles on the first request and becomes immutable.
15
- * Once compiled, no new routes can be added. So we include:
16
- * - Block discovery and page routes (registers all page paths)
17
- * - All other routes (API, WS, data, static assets)
18
- *
19
- * The minimal app includes everything needed for MCP tools and ORPC:
20
- * - Error handling, readiness gate, health check
21
- * - Security headers, CORS, compression, timing
22
- * - Content gate access, auth gates, static assets
23
- * - ORPC router, API routes, WS routes, data routes, page routes
24
- */
25
-
26
- // Fire independent I/O + compilation NOW, overlapping with the gate chain
27
- // below. These three touch NO app route and have no ordering dependency on
28
- // the gate/mount sequence, so they run concurrently with it:
29
- // - preloadServerPackages: warms Bun's module cache (CPU-bound compilation
30
- // that overlaps the gate chain's cross-internet DB I/O — same speculative
31
- // pattern as create-boot-pipeline's preloads). mountServerPackages later
32
- // hits the cache (instant imports), paying only the ~33ms of mount work.
33
- // - initGateTtls: loads config rows into module-level vars. The TTL getters
34
- // are read at REQUEST time (inside route-handler closures), not at boot,
35
- // so this only needs to finish before serving — safe to run in parallel.
36
- // - discoverBlocks: loads the page-manifest (no app mutation, no gate
37
- // dependency). Its result is consumed by registerPageRoutes much later.
38
-
39
6
  import { getAppLogger } from "@teamscala/logger/app-loggers";
40
7
  import { BootFatalError } from "@teamscala/os/errors/boot-error";
41
8
  import { createTimer } from "@teamscala/timing/create-timer";
@@ -55,7 +22,7 @@ import { attachPortalMembers } from "./attach-portal-members.ts";
55
22
  import { attachTransfersUpload } from "./attach-transfers-upload.ts";
56
23
  import { attachAuthGate } from "./auth-gate.ts";
57
24
  import { csrfProtection } from "./csrf-protection.ts";
58
- import { attachRequestLogger } from "./install-logging.ts";
25
+ import { attachRequestLogger } from "@teamscala/logger/install-logging";
59
26
  import type { ServiceInstallConfig } from "./load-service-install-row.ts";
60
27
  import { mountOrpcRouter } from "./mount-orpc-router.ts";
61
28
  import { mountServerPackages, preloadServerPackages } from "./mount-server-packages.ts";
@@ -101,13 +68,6 @@ export async function buildMinimalApp(
101
68
 
102
69
  const app = new Hono();
103
70
 
104
- // Per-step instrumentation inside the boot-ui-service phase. Same
105
- // createTimer + wall-clock convention as create-boot-pipeline's runPhase
106
- // (timing-is-the-only-timing, per-phase-instrumentation) — NOT a second
107
- // telemetry surface. The boot-ui-service phase is 60% of boot; this
108
- // breakdown localises the cost so optimisation targets the real hotspot
109
- // instead of guessing. Spans flow to GlitchTip (timing's trace-emitter is
110
- // wired by the time we reach here); the breakdown line is `pm2 logs`-visible.
111
71
  const stepTimings: Record<string, number> = {};
112
72
  async function runStep<T>(step: string, fn: () => Promise<T>): Promise<T> {
113
73
  const timer = createTimer(`${serviceSlug}:boot.minimal.${step}`);
@@ -119,7 +79,6 @@ export async function buildMinimalApp(
119
79
  }
120
80
  }
121
81
 
122
- // Error handlers
123
82
  app.onError(createRequestErrorHandler("service-boot:minimal"));
124
83
 
125
84
  app.notFound(async (c) => {
@@ -131,7 +90,6 @@ export async function buildMinimalApp(
131
90
  return c.json({ error: "Not Found" }, 404);
132
91
  });
133
92
 
134
- // Core middleware
135
93
  app.use("*", readinessGate);
136
94
  app.get("/health", healthHandler);
137
95
  app.use("*", injectSecurityHeaders);
@@ -151,19 +109,7 @@ export async function buildMinimalApp(
151
109
  );
152
110
  app.use("*", compress());
153
111
  app.use("*", timing());
154
- // CSRF (`security-csrf-protection`): state-changing form-element requests
155
- // must carry a trusted Origin — derived set (managed base domain +
156
- // builda_websites custom domains via isTrustedAuthOrigin), never an
157
- // allowlist beside better-auth's. Registered after security headers, before
158
- // every route + gate.
159
112
  app.use("*", csrfProtection());
160
- // GLOBAL body limit (`security-body-limit`): every request body is bounded
161
- // BEFORE any handler buffers it — a per-route file.size check AFTER
162
- // formData() has buffered is a report, not a limit. 30MB sits above the
163
- // 25MB transfers-upload maximum; routes wanting a smaller bound mount their
164
- // own bodyLimit BEFORE this one (Hono runs matching middleware in
165
- // registration order, and /api/rpc registers its 10MB limit in
166
- // mount-orpc-router).
167
113
  app.use(
168
114
  "*",
169
115
  bodyLimit({
@@ -180,20 +126,8 @@ export async function buildMinimalApp(
180
126
  );
181
127
  const ttlPromise = runStep("init-gate-ttls", () => initGateTtls());
182
128
 
183
- // Public access endpoints — MUST be registered before the auth gate (Hono
184
- // dispatches middleware + handlers in registration order; the content-gate
185
- // POST routes and magic-link endpoints are public and must be reachable
186
- // without a session, so they precede the auth gate's app.use("*") wildcard).
187
- // These are mutually independent — distinct paths, no shared mutable state
188
- // beyond the Hono app's synchronous route array — so they parallelise.
189
129
  await runStep("public-routes", () =>
190
130
  Promise.all([
191
- // EVERY REGISTERED GATE'S ACCESS ENDPOINT (content-gate/catalog.ts).
192
- // The same catalog the SSR gate sequence iterates, so the endpoint that
193
- // MINTS a gate's token and the gate that VERIFIES it cannot disagree
194
- // about which gates exist — this list and that one were two copies of
195
- // one fact, and a gate added to one and missed in the other is a page
196
- // that gates content with no way to unlock it.
197
131
  ...CONTENT_GATES.map((entry) =>
198
132
  attachContentGateAccess(app, serviceSlug, entry.access),
199
133
  ),
@@ -205,27 +139,14 @@ export async function buildMinimalApp(
205
139
  ]),
206
140
  );
207
141
 
208
- // Auth gate — registers /api/auth/* + the session-check wildcard middleware.
209
- // MUST follow the public routes above (load-bearing Hono registration order)
210
- // and MUST precede mountServerPackages: server-package daemon routes (e.g.
211
- // cli-session's /rpc/*) are subject to the session gate, which requires the
212
- // gate's wildcard registered first to apply. Running these concurrently would
213
- // make that ordering non-deterministic — so the gate stays serial here.
214
142
  await runStep("attach-auth-gate", () =>
215
143
  attachAuthGate(app, config, serviceSlug),
216
144
  );
217
145
  registerStaticAssets(app, serviceSlug, config.brand_org);
218
146
 
219
- // Server packages — the cold imports were preloaded above and are now in
220
- // Bun's module cache, so this pays only the mount work (~33ms, not ~2.8s of
221
- // serial compilation). Route registration happens HERE (after the auth gate),
222
- // preserving the gate-vs-daemon-route ordering.
223
147
  const mountRegistry = await runStep("mount-server-packages", () =>
224
148
  mountServerPackages(app, config, serviceSlug, getSharedDb),
225
149
  );
226
- // Ensure the speculative preload finished before proceeding (it populates
227
- // the same cache mountServerPackages just consumed; awaiting now is a no-op
228
- // if the preload already settled, and makes the ordering guarantee explicit).
229
150
  await preloadPromise;
230
151
  const apiRouteCount = registerApiRoutes(app, config, mountRegistry, serviceSlug);
231
152
 
@@ -233,17 +154,10 @@ export async function buildMinimalApp(
233
154
  await runStep("mount-orpc-router", () => mountOrpcRouter(app, serviceSlug));
234
155
  }
235
156
 
236
- // Register the in-process ORPC server client BEFORE discoverBlocks — it
237
- // calls getServerClient() which needs the client set here. Must follow
238
- // mount-orpc-router (which builds the router getAppRouter uses).
239
- // Previously ran inside register-page-routes (too late — discoverBlocks
240
- // fired before it, hitting the empty-router fallback).
241
157
  await runStep("register-page-server-client", () =>
242
158
  registerPageServerClient(serviceSlug),
243
159
  );
244
160
 
245
- // NOW load the generated manifest (getServerClient is set). Awaited below
246
- // before register-page-routes consumes the result.
247
161
  const discoverPromise = runStep("load-page-manifest", () =>
248
162
  loadPageManifest(serviceSlug),
249
163
  );
@@ -252,14 +166,8 @@ export async function buildMinimalApp(
252
166
  registerWsRoutes(app, config, mountRegistry, serviceSlug, upgradeWebSocket),
253
167
  );
254
168
 
255
- // Resolve the deferred TTL load (started at the top — should be long done,
256
- // overlapped with the gate chain + mount work). The getters are read at
257
- // request time, but the cache must be populated before the first request.
258
169
  await ttlPromise;
259
170
 
260
- // CRITICAL: Block discovery and page routes MUST be registered BEFORE serving,
261
- // because Hono's RegExpRouter compiles on first request and becomes immutable.
262
- // We defer only client asset building (expensive Vite build) to Phase 2.
263
171
  let components: Awaited<ReturnType<typeof loadPageManifest>>;
264
172
  try {
265
173
  components = await discoverPromise;
@@ -276,15 +184,10 @@ export async function buildMinimalApp(
276
184
  );
277
185
 
278
186
 
279
- // Run boot hooks BEFORE serving - some hooks may register routes and must
280
- // run before Hono's matcher is compiled by the first request
281
187
  await runStep("run-boot-hooks", () =>
282
188
  runBootHookList(config, mountRegistry, serviceSlug),
283
189
  );
284
190
 
285
- // Register page routes with a dynamic handler that delegates to the real handler once Phase 2 completes.
286
- // All route paths must be registered before serving to avoid Hono
287
- // RegExpRouter compilation issues (matcher becomes immutable).
288
191
  const registeredPageRoutes = await runStep("register-page-routes", () =>
289
192
  registerPageRoutes({
290
193
  app,
@@ -299,10 +202,6 @@ export async function buildMinimalApp(
299
202
  const steps = Object.entries(stepTimings)
300
203
  .map(([step, ms]) => `${step}=${ms}ms`)
301
204
  .join(" ");
302
- // NOTE: several steps run concurrently (speculative preload, init-gate-ttls,
303
- // discover-blocks overlap the gate chain), so the step sum exceeds the
304
- // wall-clock buildMinimalApp duration. Compare to the boot-ui-service phase
305
- // timing in the Boot complete line for the true wall-clock cost.
306
205
  log.info(
307
206
  `[two-phase-boot:${serviceSlug}] buildMinimalApp breakdown (steps overlap) — ${steps}`,
308
207
  );
@@ -322,9 +221,6 @@ export async function buildMinimalApp(
322
221
  databaseUrl,
323
222
  blockPackages,
324
223
  components,
325
- // The generated manifest IS the loaded set — every entry it exports is a
326
- // block this service renders, so there is no "skipped" half any more. A
327
- // row that cannot produce a component fails codegen, not boot.
328
224
  loaded: Object.keys(components),
329
225
  skipped: [] as string[],
330
226
  };
package/src/types.ts CHANGED
@@ -8,9 +8,6 @@ type RendererComponent = (props: Record<string, unknown>) => unknown;
8
8
  export interface RendererComponentEntry {
9
9
  render: RendererComponent;
10
10
  fields?: Record<string, { type: string }>;
11
- /** block_schema.defaults, carried onto the entry by discoverBlocks so
12
- * buildPuckConfig merges them under authored props at SSR
13
- * (blocks-fully-programmable). */
14
11
  defaultProps?: Record<string, unknown>;
15
12
  }
16
13
 
@@ -31,18 +28,7 @@ export interface PageHandlerConfig {
31
28
  providers?: string[];
32
29
  loadData: (request: Request) => Promise<unknown>;
33
30
  renderMode?: "public" | "portals" | "utility";
34
- /**
35
- * Owning service slug — used by the page-server render catch to resolve
36
- * the branded logo on the render-error page. Mirrors service-runtime's
37
- * PageHandlerConfig.
38
- */
39
31
  serviceSlug?: string;
40
- /**
41
- * Redirect a signed-in user's `/` to their org-scoped portal dashboard.
42
- * Set only for services serving a `/*` catchall page_route (the portal);
43
- * single-site portal-mode services serve their real page at `/`.
44
- * Mirrors service-runtime's PageHandlerConfig (1.15.82).
45
- */
46
32
  portalHomeRedirect?: boolean;
47
33
  }
48
34
 
package/src/ui-service.ts CHANGED
@@ -17,32 +17,7 @@
17
17
  * place and is NOT undone.
18
18
  */
19
19
 
20
- // Resolve the listen port through the SHARED resolver (`resolveServicePort`),
21
- // the same one the backend branch uses. The registry wins wherever it has an
22
- // opinion and a contradicting PORT env is reported, never silently obeyed —
23
- // full reasoning in that file.
24
- //
25
- // This branch previously took `process.env.PORT` unconditionally, which is
26
- // how scala-quiz (declared 5203) booted onto session-picker's 3021 from a
27
- // leaked env and silently split that daemon's traffic (2026-07-31). On the
28
- // dev host the registry wins a conflict (below). On Railway the injected PORT
29
- // wins instead — Railway healthchecks exactly that port, and the row's port
30
- // is the service's LOCAL dev port (e.g. portal 5200), so binding it on Railway
31
- // makes the healthcheck probe a dead port and the deploy fails (`isRailway`
32
- // path in the resolver).
33
-
34
- // NO-SILENT-REVERTS — surface the failed client build to OBSERVABILITY
35
- // (event-log → GlitchTip, per event-log-is-the-only-durable-event-log).
36
- // The minimal-app fallback keeps serving SSR from the LAST-successful
37
- // manifest (now a tombstone, see build-client-assets.ts), so the page
38
- // looks "up" while the client bundle is stale. Without this signal the
39
- // failure is buried in boot stderr and a published fix that reached
40
- // node_modules but failed to bundle ships invisibly stale (the
41
- // scala-agents 2026-07-18 incident: ui-foundation@3.4.5 installed but
42
- // served pre-fix bytes across restarts while node_modules was
43
- // mid-install). The boot.client-build-failed event routes into
44
- // event-log's 7-day retention → surfaces in GlitchTip as an incident
45
- // trail the operator actually watches.
20
+
46
21
 
47
22
  import { recordEvent } from "@teamscala/event-log/record-event";
48
23
  import { getAppLogger } from "@teamscala/logger/app-loggers";
@@ -91,7 +66,6 @@ export async function bootUiService(
91
66
  const log = getAppLogger();
92
67
  const startTime = Date.now();
93
68
 
94
- // Phase 1: Build minimal tool-only app
95
69
  const minimalAppResult = await buildMinimalApp(
96
70
  config,
97
71
  serviceSlug,
@@ -102,12 +76,6 @@ export async function bootUiService(
102
76
 
103
77
  const isRailway = Boolean(process.env.RAILWAY_ENVIRONMENT);
104
78
  const envPort = process.env.PORT ? Number(process.env.PORT) : undefined;
105
- // The APP binds its PRIVATE port (app_port) when present — for scale-to-zero
106
- // services `port` is the ACTIVATOR's ingress (what callers resolve), not the
107
- // app's bind. Falling back to `port` keeps RESIDENT + unmigrated services
108
- // unchanged. Without this, a migrated row (port=activator) would make the app
109
- // bind the activator's port → SO_REUSEPORT silent load-balancing (the
110
- // service-ports-from-registry trap: clean boot, two owners in ss -ltnp).
111
79
  const declaredAppPort = config.app_port ?? config.port;
112
80
  const resolution = resolveServicePort(envPort, declaredAppPort, isRailway);
113
81
  if ("error" in resolution) {
@@ -129,21 +97,17 @@ export async function bootUiService(
129
97
  }
130
98
  const port = requireValidPort(resolution.port, serviceSlug);
131
99
 
132
- // service-runtime owns the TCP listener. service-boot only composes the app
133
- // and starts the two-phase build; the runtime binds the returned app.
134
100
  const minimalReadyTime = Date.now();
135
101
  const minimalDuration = minimalReadyTime - startTime;
136
102
  log.info(
137
103
  `[ui-service-boot:${serviceSlug}] Minimal app composed on port ${port} in ${minimalDuration}ms`,
138
104
  );
139
105
 
140
- // FLIP READY SWITCH: minimal app is ready, MCP tools can connect
141
106
  setReady();
142
107
  log.info(
143
108
  `[ui-service-boot:${serviceSlug}] Ready gate flipped - MCP tools can now connect (full app building in background)`,
144
109
  );
145
110
 
146
- // Phase 2: Build full app in background (non-blocking)
147
111
  ;(async () => {
148
112
  try {
149
113
  const PHASE2_TIMEOUT_MS = 120_000;
@@ -159,7 +123,6 @@ export async function bootUiService(
159
123
  const fullBuildTime = Date.now();
160
124
  const fullDuration = fullBuildTime - startTime;
161
125
 
162
- // Swap the app reference atomically
163
126
  swapToFullApp(fullAppResult.app, serviceSlug);
164
127
 
165
128
  log.info(
@@ -178,13 +141,9 @@ export async function bootUiService(
178
141
  at: new Date().toISOString(),
179
142
  },
180
143
  });
181
- // Continue with minimal app - better than crashing
182
144
  }
183
145
  })();
184
146
 
185
- // Return result compatible with the existing UiServiceBootResult interface
186
- // Note: blocks/routes will be empty initially since they're built in the background.
187
- // The service is operational for MCP tools.
188
147
  return {
189
148
  kind: "ui",
190
149
  blocks: [], // Will be populated when full app builds
@@ -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
- });