@teamscala/service-boot 0.23.318 → 0.23.319

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 (165) hide show
  1. package/package.json +526 -526
  2. package/src/agreement-gate.ts +12 -0
  3. package/src/auth-gate.test.ts +70 -0
  4. package/src/auth-gate.ts +68 -6
  5. package/src/build-client-assets.test.ts +1 -1
  6. package/src/build-client-assets.ts +1 -1
  7. package/src/discover-blocks.ts +1 -1
  8. package/src/fn-routers/index.ts +2 -0
  9. package/src/fn-routers/task-sync-assign.test.ts +101 -1
  10. package/src/fn-routers/task-sync-assign.ts +62 -9
  11. package/src/fn-routers/task-sync-bind.test.ts +18 -18
  12. package/src/fn-routers/task-sync-brief.test.ts +65 -22
  13. package/src/fn-routers/task-sync-brief.ts +77 -23
  14. package/src/fn-routers/task-sync-close.test.ts +44 -0
  15. package/src/fn-routers/task-sync-close.ts +82 -0
  16. package/src/fn-routers/task-sync-dispatch.test.ts +51 -33
  17. package/src/fn-routers/task-sync-hook-lifecycle.test.ts +204 -0
  18. package/src/fn-routers/task-sync-lane.test.ts +36 -0
  19. package/src/fn-routers/task-sync-projection.test.ts +26 -0
  20. package/src/fn-routers/task-sync-projection.ts +51 -0
  21. package/src/fn-routers/task-sync-rename.test.ts +53 -0
  22. package/src/fn-routers/task-sync-rename.ts +111 -0
  23. package/src/fn-routers/task-sync-router.test.ts +14 -2
  24. package/src/handlers/singleton-tools-currency-watch.ts +114 -11
  25. package/src/handlers/sweep-host-cache.test.ts +133 -0
  26. package/src/handlers/sweep-host-cache.ts +143 -26
  27. package/src/install-logging.ts +3 -0
  28. package/src/mount-orpc-router.ts +1 -1
  29. package/src/register-api-routes.ts +8 -2
  30. package/src/register-page-routes.ts +0 -3
  31. package/src/resolve-brand-favicon.ts +1 -1
  32. package/src/resource-not-found.ts +10 -0
  33. package/src/serve/stamp-transport-identity.test.ts +24 -0
  34. package/src/serve/stamp-transport-identity.ts +20 -7
  35. package/src/two-phase-boot.ts +85 -17
  36. package/src/two-phase-health-gate-client-only.test.ts +77 -0
  37. package/src/two-phase-health-gate.test.ts +13 -2
  38. package/src/agreement-gate.d.ts +0 -19
  39. package/src/asset-cache-key.d.ts +0 -11
  40. package/src/asset-optimize.d.ts +0 -17
  41. package/src/asset-proxy.d.ts +0 -11
  42. package/src/asset-social-card.d.ts +0 -17
  43. package/src/attach-agreement-magic.d.ts +0 -20
  44. package/src/attach-portal-members.d.ts +0 -23
  45. package/src/attach-proposal-accept.d.ts +0 -26
  46. package/src/attach-proposal-magic.d.ts +0 -18
  47. package/src/attach-transfers-upload.d.ts +0 -27
  48. package/src/auth-gate.d.ts +0 -28
  49. package/src/bind-page-handler.d.ts +0 -18
  50. package/src/build-client-assets/input-hashing.d.ts +0 -25
  51. package/src/build-client-assets/source-candidates.d.ts +0 -27
  52. package/src/build-client-assets.d.ts +0 -50
  53. package/src/compose-page-title.d.ts +0 -41
  54. package/src/content-gate/access.d.ts +0 -15
  55. package/src/content-gate/config-types.d.ts +0 -99
  56. package/src/content-gate/gate.d.ts +0 -13
  57. package/src/content-gate/parse-cookie.d.ts +0 -9
  58. package/src/content-gate/token.d.ts +0 -48
  59. package/src/content-page-floor.d.ts +0 -39
  60. package/src/discover-blocks.d.ts +0 -28
  61. package/src/expand-roster.d.ts +0 -34
  62. package/src/fn-routers/agreement-completion-email.d.ts +0 -14
  63. package/src/fn-routers/agreement-send-router.d.ts +0 -82
  64. package/src/fn-routers/ai-health-router.d.ts +0 -35
  65. package/src/fn-routers/auth-router.d.ts +0 -47
  66. package/src/fn-routers/cache-admin-router.d.ts +0 -57
  67. package/src/fn-routers/chat-session-router.d.ts +0 -219
  68. package/src/fn-routers/email-defaults.d.ts +0 -1
  69. package/src/fn-routers/email-link-ttl.d.ts +0 -1
  70. package/src/fn-routers/index.d.ts +0 -18
  71. package/src/fn-routers/panel-data-router.d.ts +0 -19
  72. package/src/fn-routers/persist-chat-messages.d.ts +0 -36
  73. package/src/fn-routers/plans-router.d.ts +0 -25
  74. package/src/fn-routers/proposal-send-router.d.ts +0 -58
  75. package/src/fn-routers/public-agreement-router.d.ts +0 -289
  76. package/src/fn-routers/public-assessment-router.d.ts +0 -68
  77. package/src/fn-routers/public-gpt-session-router.d.ts +0 -143
  78. package/src/fn-routers/sign-in-flow-router.d.ts +0 -107
  79. package/src/fn-routers/task-sync-assign.d.ts +0 -283
  80. package/src/fn-routers/task-sync-brief.d.ts +0 -69
  81. package/src/fn-routers/task-sync-dispatch.d.ts +0 -61
  82. package/src/fn-routers/task-sync-dispatch.ts +0 -124
  83. package/src/fn-routers/task-sync-lane.d.ts +0 -28
  84. package/src/fn-routers/task-sync-lane.ts +0 -91
  85. package/src/fn-routers/task-sync-projection.d.ts +0 -121
  86. package/src/fn-routers/task-sync-router.d.ts +0 -375
  87. package/src/fn-routers/task-sync-router.ts +0 -1011
  88. package/src/fn-routers/tasks-router.d.ts +0 -25
  89. package/src/fn-routers/user-preferences-router.d.ts +0 -77
  90. package/src/fn-routers/work-items-router.d.ts +0 -102
  91. package/src/gate-ttls.d.ts +0 -6
  92. package/src/gpt-gate.d.ts +0 -25
  93. package/src/handlers/bun-stable-watch.d.ts +0 -1
  94. package/src/handlers/pane-outbox-drain.d.ts +0 -1
  95. package/src/handlers/projects-digest.d.ts +0 -26
  96. package/src/handlers/prune-stale-data.d.ts +0 -30
  97. package/src/handlers/registry-stale-inactive-sweep.d.ts +0 -1
  98. package/src/handlers/scala-tools-currency-watch.d.ts +0 -1
  99. package/src/handlers/singleton-tools-currency-watch.d.ts +0 -1
  100. package/src/handlers/sweep-event-log.d.ts +0 -1
  101. package/src/handlers/sweep-host-cache.d.ts +0 -1
  102. package/src/handlers/upstream-issue-sync.d.ts +0 -1
  103. package/src/handlers/whatsapp-liveness-logic.d.ts +0 -34
  104. package/src/handlers/whatsapp-liveness.d.ts +0 -4
  105. package/src/hono-error-handler.d.ts +0 -25
  106. package/src/install-harness.d.ts +0 -45
  107. package/src/install-logging.d.ts +0 -34
  108. package/src/load-service-install-row.d.ts +0 -24
  109. package/src/load-service-prisma-client.d.ts +0 -18
  110. package/src/mount-orpc-router.d.ts +0 -15
  111. package/src/mount-server-packages.d.ts +0 -29
  112. package/src/mount.d.ts +0 -9
  113. package/src/mutable-app-ref.d.ts +0 -44
  114. package/src/page-route-shell-types.d.ts +0 -12
  115. package/src/page-server-orpc/audit-revert-router.d.ts +0 -37
  116. package/src/page-server-orpc/hub-mcp-client.d.ts +0 -12
  117. package/src/page-server-orpc/portal-diagnostics-router.d.ts +0 -49
  118. package/src/page-server-orpc/puck-pages-router.d.ts +0 -58
  119. package/src/page-server-orpc/server-client.d.ts +0 -16
  120. package/src/page-server-orpc/templates-router.d.ts +0 -34
  121. package/src/page-server-orpc/user-preferences-router.d.ts +0 -79
  122. package/src/pg-boss-handler-registry.d.ts +0 -27
  123. package/src/pg-boss-mount.d.ts +0 -42
  124. package/src/proposal-authorized-emails.d.ts +0 -28
  125. package/src/proposal-chat-inject.d.ts +0 -17
  126. package/src/proposal-gate.d.ts +0 -18
  127. package/src/read-html-template.d.ts +0 -38
  128. package/src/read-viewport-from-request.d.ts +0 -18
  129. package/src/readiness-gate.d.ts +0 -16
  130. package/src/register-api-routes.d.ts +0 -18
  131. package/src/register-crash-handlers.d.ts +0 -15
  132. package/src/register-page-routes.d.ts +0 -27
  133. package/src/register-page-server-client.d.ts +0 -11
  134. package/src/register-registry-page-route/ai-chat-prefetch.d.ts +0 -13
  135. package/src/register-registry-page-route/graph-cache.d.ts +0 -30
  136. package/src/register-registry-page-route/meta-title.d.ts +0 -2
  137. package/src/register-registry-page-route/proposal-approval-email-token.d.ts +0 -14
  138. package/src/register-registry-page-route/proposal-brand-stamping.d.ts +0 -28
  139. package/src/register-registry-page-route/resolve-page-data.d.ts +0 -1
  140. package/src/register-registry-page-route.d.ts +0 -27
  141. package/src/register-static-assets.d.ts +0 -13
  142. package/src/register-ws-routes.d.ts +0 -28
  143. package/src/resolve-app-base-url.d.ts +0 -13
  144. package/src/resolve-brand-favicon.d.ts +0 -28
  145. package/src/resolve-from-loader-config.d.ts +0 -32
  146. package/src/resolve-service-context.d.ts +0 -17
  147. package/src/resolve-service-port.d.ts +0 -75
  148. package/src/resolve-theme-mode.d.ts +0 -42
  149. package/src/resolvers/fetch-org-brand.d.ts +0 -2
  150. package/src/resolvers/portal-route-paths.d.ts +0 -39
  151. package/src/resolvers/website-by-host-map.d.ts +0 -12
  152. package/src/resource-chat-inject.d.ts +0 -46
  153. package/src/resource-not-found.d.ts +0 -60
  154. package/src/run-boot-hook-list.d.ts +0 -19
  155. package/src/run-boot-hooks.d.ts +0 -31
  156. package/src/scala-tools-currency-watchdog.d.ts +0 -10
  157. package/src/security-headers.d.ts +0 -12
  158. package/src/serve/serve.d.ts +0 -46
  159. package/src/serve/stamp-transport-identity.d.ts +0 -32
  160. package/src/server-error-page.d.ts +0 -29
  161. package/src/start-scheduled-jobs.d.ts +0 -18
  162. package/src/two-phase-boot.d.ts +0 -136
  163. package/src/types.d.ts +0 -46
  164. package/src/ui-service.d.ts +0 -46
  165. package/src/ws-boot-self-test.d.ts +0 -19
@@ -26,10 +26,23 @@
26
26
  * identity — a clone can never upgrade).
27
27
  */
28
28
 
29
+ import { CALLER_HEADERS } from "@teamscala/os/contracts/mcp";
29
30
  import { resolveSocketPeer } from "@teamscala/proc-walker/socket-peer";
30
31
 
31
32
  const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
32
33
 
34
+ /**
35
+ * Preserve useful process attribution without copying arbitrary argv into logs.
36
+ * Arguments routinely contain bearer headers, inline scripts, URLs and secret
37
+ * values. There is no complete flag-name redaction list, so the safe boundary
38
+ * is structural: retain argv[0] and state that arguments were removed.
39
+ */
40
+ export function summariseLocalCommand(cmdline: readonly string[]): string | null {
41
+ const executable = cmdline[0]?.trim();
42
+ if (!executable) return null;
43
+ return cmdline.length > 1 ? `${executable} [arguments redacted]` : executable;
44
+ }
45
+
33
46
  /**
34
47
  * Return a Request with transport-identity headers stamped, or the original
35
48
  * `req` when no stamping is needed/possible. Never throws.
@@ -53,15 +66,15 @@ export function stampTransportIdentity(
53
66
  const port = peer?.port;
54
67
  const address = peer?.address ?? null;
55
68
  const needsPort =
56
- typeof port === "number" && port > 0 && !req.headers.has("x-caller-remote-port");
69
+ typeof port === "number" && port > 0 && !req.headers.has(CALLER_HEADERS.REMOTE_PORT);
57
70
  // Nothing to stamp when there is no peer address and no port to add.
58
71
  if (!needsPort && address === null) return req;
59
72
 
60
73
  const headers = new Headers(req.headers);
61
- if (needsPort) headers.set("x-caller-remote-port", String(port));
74
+ if (needsPort) headers.set(CALLER_HEADERS.REMOTE_PORT, String(port));
62
75
  if (address) {
63
- headers.delete("x-caller-remote-address");
64
- headers.set("x-caller-remote-address", String(address));
76
+ headers.delete(CALLER_HEADERS.REMOTE_ADDRESS);
77
+ headers.set(CALLER_HEADERS.REMOTE_ADDRESS, String(address));
65
78
  }
66
79
  // Local-caller attribution: loopback peer + no forwarding header => a
67
80
  // direct local fetch whose owning process we can name via /proc.
@@ -75,9 +88,9 @@ export function stampTransportIdentity(
75
88
  ) {
76
89
  const owner = resolveSocketPeer(port);
77
90
  if (owner) {
78
- headers.set("x-caller-local-comm", owner.comm);
79
- const cmd = owner.cmdline.join(" ").trim();
80
- if (cmd) headers.set("x-caller-local-cmd", cmd.slice(0, 160));
91
+ headers.set(CALLER_HEADERS.LOCAL_COMM, owner.comm);
92
+ const cmd = summariseLocalCommand(owner.cmdline);
93
+ if (cmd) headers.set(CALLER_HEADERS.LOCAL_CMD, cmd);
81
94
  }
82
95
  }
83
96
  return new Request(req.url, {
@@ -54,6 +54,7 @@ import { registerPageRoutes } from "./register-page-routes.ts";
54
54
  import { runBootHookList } from "./run-boot-hook-list.ts";
55
55
  import { buildClientAssets } from "./build-client-assets.ts";
56
56
  import { discoverBlocks } from "./discover-blocks.ts";
57
+ import { registerPageServerClient } from "./register-page-server-client.ts";
57
58
  import { swapApp } from "./mutable-app-ref.ts";
58
59
  import type { CreatePageHandler } from "./types.ts";
59
60
 
@@ -63,13 +64,60 @@ let realPageHandler: CreatePageHandler | null = null;
63
64
  let fullAppReady = false;
64
65
 
65
66
  /**
66
- * Set the real page handler (called when Phase 2 completes).
67
+ * Set the real page handler (called when Phase 2 completes on an SSR service).
68
+ *
69
+ * Registering the SSR handler implies the full app is built, so this also flips
70
+ * readiness — but the reverse does NOT hold, which is why `markFullAppReady`
71
+ * exists separately. See its note.
67
72
  */
68
73
  export function setPageHandler(handler: CreatePageHandler): void {
69
74
  realPageHandler = handler;
75
+ markFullAppReady();
76
+ }
77
+
78
+ /**
79
+ * Flip readiness once phase 2 has built the full app.
80
+ *
81
+ * SEPARATE FROM `setPageHandler` BECAUSE THE TWO FACTS CAME APART. Readiness
82
+ * means "phase 2 finished"; the page handler means "this service renders pages
83
+ * server-side". Every service was SSR, so one flag honestly carried both — until
84
+ * `render_mode=client` (`granular runtime`) made a service that completes phase 2
85
+ * and never registers an SSR handler.
86
+ *
87
+ * Leaving readiness inside `setPageHandler` made such a service PERMANENTLY
88
+ * un-ready: `/health` answered `{"status":"starting"}` forever while the process
89
+ * was healthy and serving. That is the failure `readiness-gated-boot` is written
90
+ * against — a service must be "binary: fully-ready or cleanly-unavailable", and
91
+ * a never-ready health signal makes the activator's wake gate and every
92
+ * health-gated router treat a working service as down.
93
+ *
94
+ * Measured 2026-08-07 on scala-agents-ui: boot completed in 5023ms, "Full app
95
+ * built: 50 block(s)" logged, `GET /` correctly 302'd to sign-in, and `/health`
96
+ * still reported starting — while three untouched UI services reported ok.
97
+ */
98
+ export function markFullAppReady(): void {
70
99
  fullAppReady = true;
71
100
  }
72
101
 
102
+ /**
103
+ * Reset both phase-2 flags. TEST-ONLY.
104
+ *
105
+ * `fullAppReady` is module state that only ever transitions false -> true in
106
+ * production, and Bun shares a module instance across every test file in a run —
107
+ * so the SSR readiness test and the client-only readiness test contaminate each
108
+ * other purely by file order. Splitting them across files does NOT isolate them;
109
+ * it only hides the coupling until CI runs them together.
110
+ *
111
+ * A test that passes alone and fails in a suite is worse than no test, so the
112
+ * reset is exported rather than left as an ordering convention nobody can see.
113
+ * Never called from production code — `setPageHandler`/`markFullAppReady` are
114
+ * the only writers on the boot path.
115
+ */
116
+ export function __resetPhase2StateForTest(): void {
117
+ realPageHandler = null;
118
+ fullAppReady = false;
119
+ }
120
+
73
121
  /**
74
122
  * Get the current page handler state.
75
123
  */
@@ -247,16 +295,6 @@ export async function buildMinimalApp(
247
295
  preloadServerPackages(serverPackages),
248
296
  );
249
297
  const ttlPromise = runStep("init-gate-ttls", () => initGateTtls());
250
- const discoverPromise = runStep("discover-blocks", () =>
251
- discoverBlocks(blockPackages),
252
- );
253
- // Attach an immediate rejection sink: discoverPromise is fired here but
254
- // awaited much later (after the gate chain + mounts). If discoverBlocks
255
- // rejects during that overlap window, the rejection would surface as an
256
- // unhandledRejection before the await below can catch it. The sink is a
257
- // no-op — the real error still propagates at the `await discoverPromise`
258
- // site because each consumer of a rejected promise receives the rejection.
259
- discoverPromise.catch(() => {});
260
298
 
261
299
  // Public access endpoints — MUST be registered before the auth gate (Hono
262
300
  // dispatches middleware + handlers in registration order; the content-gate
@@ -305,6 +343,21 @@ export async function buildMinimalApp(
305
343
  await runStep("mount-orpc-router", () => mountOrpcRouter(app, serviceSlug));
306
344
  }
307
345
 
346
+ // Register the in-process ORPC server client BEFORE discoverBlocks — it
347
+ // calls getServerClient() which needs the client set here. Must follow
348
+ // mount-orpc-router (which builds the router getAppRouter uses).
349
+ // Previously ran inside register-page-routes (too late — discoverBlocks
350
+ // fired before it, hitting the empty-router fallback).
351
+ await runStep("register-page-server-client", () =>
352
+ registerPageServerClient(serviceSlug),
353
+ );
354
+
355
+ // NOW fire discoverBlocks (getServerClient is set). Awaited below before
356
+ // register-page-routes consumes the result.
357
+ const discoverPromise = runStep("discover-blocks", () =>
358
+ discoverBlocks(blockPackages),
359
+ );
360
+
308
361
  const wsRouteCount = await runStep("register-ws-routes", () =>
309
362
  registerWsRoutes(app, config, mountRegistry, serviceSlug),
310
363
  );
@@ -376,7 +429,8 @@ export async function buildMinimalApp(
376
429
  // (the same pulsating-logo holding page scale-to-zero uses) —
377
430
  // never bare plain text (loading-via-logo-loading).
378
431
  if (!fullAppReady || !realPageHandler) {
379
- return new Response(buildWakingPageHtml({}), {
432
+ const logo = await resolveErrorPageLogo(serviceSlug);
433
+ return new Response(buildWakingPageHtml({ logoUrl: logo }), {
380
434
  status: 503,
381
435
  headers: {
382
436
  "content-type": "text/html; charset=utf-8",
@@ -498,11 +552,25 @@ export async function buildFullApp(
498
552
  `${Object.keys(components).length} component(s)`,
499
553
  );
500
554
 
501
- // Set the real page handler so the dynamic handler in Phase 1 can delegate to it
502
- setPageHandler(deps.createPageHandler);
503
- log.info(
504
- `[two-phase-boot:${serviceSlug}] Real page handler registered - page routes now delegate to full app`,
505
- );
555
+ // Set the real page handler so the dynamic handler in Phase 1 can delegate to it.
556
+ // Client-only services (render_mode=client) have no SSR page handler — skip
557
+ // (they serve via the client bundle, never SSR). Guarded so undefined is a
558
+ // no-op rather than a crash.
559
+ if (deps.createPageHandler) {
560
+ setPageHandler(deps.createPageHandler);
561
+ log.info(
562
+ `[two-phase-boot:${serviceSlug}] Real page handler registered - page routes now delegate to full app`,
563
+ );
564
+ } else {
565
+ // The full app IS built — this service simply never renders server-side,
566
+ // so readiness is flipped directly rather than as a side effect of
567
+ // registering an SSR handler that will never exist. Without this a
568
+ // client-only service is permanently un-ready (see markFullAppReady).
569
+ markFullAppReady();
570
+ log.info(
571
+ `[two-phase-boot:${serviceSlug}] Client-only mode (render_mode=client) — no SSR page handler registered`,
572
+ );
573
+ }
506
574
 
507
575
  const resolvedBlocks = loaded.map((slug) => ({
508
576
  slug,
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * A CLIENT-ONLY service (`render_mode=client`) must reach ready, even though it
7
+ * never registers an SSR page handler.
8
+ *
9
+ * Separate file from `two-phase-health-gate.test.ts` on purpose: `fullAppReady`
10
+ * is module state that only transitions false -> true and has no reset hook, so
11
+ * the two ready-paths cannot both be exercised in one module instance. A reset
12
+ * hook would exist only for tests.
13
+ *
14
+ * THE REGRESSION THIS LOCKS (measured 2026-08-07 on scala-agents-ui). Readiness
15
+ * used to be flipped INSIDE `setPageHandler`, which was honest while every
16
+ * service was SSR. `render_mode=client` made a service that completes phase 2
17
+ * and never calls it, so `/health` answered `{"status":"starting"}` FOREVER
18
+ * while the process was healthy and serving — boot completed in 5023ms, "Full
19
+ * app built: 50 block(s)" logged, `GET /` correctly 302'd to sign-in, and three
20
+ * untouched UI services reported ok beside it.
21
+ *
22
+ * That is precisely what `readiness-gated-boot` forbids ("binary: fully-ready or
23
+ * cleanly-unavailable"): a permanently-un-ready health signal makes the
24
+ * activator's wake gate and every health-gated router treat a working service as
25
+ * down. Exercised through a real Hono request against the REAL exported handler
26
+ * (`proxy-is-not-behaviour`) — a source grep would prove the call exists, never
27
+ * that the endpoint answers 200.
28
+ */
29
+
30
+ import { beforeAll, describe, expect, test } from "bun:test";
31
+ import { Hono } from "hono";
32
+ import {
33
+ __resetPhase2StateForTest,
34
+ getPageHandlerState,
35
+ healthHandler,
36
+ markFullAppReady,
37
+ } from "./two-phase-boot.ts";
38
+
39
+ /** Mount the real handler exactly as buildMinimalApp does. */
40
+ const app = new Hono().get("/health", healthHandler);
41
+
42
+ describe("/health for a client-only service", () => {
43
+ // Bun shares one module instance across every test file in a run, so the SSR
44
+ // readiness test's setPageHandler leaks into this file when both run together.
45
+ // Without this reset the suite passes file-by-file and fails in CI.
46
+ beforeAll(() => {
47
+ __resetPhase2StateForTest();
48
+ });
49
+
50
+ test("before phase 2 completes it reports 503, not ready", async () => {
51
+ expect(getPageHandlerState().ready).toBe(false);
52
+
53
+ const res = await app.request("/health");
54
+
55
+ expect(res.status).toBe(503);
56
+ expect(await res.json()).toEqual({ status: "starting" });
57
+ });
58
+
59
+ test("reaches ready without an SSR page handler", async () => {
60
+ // The real client-only phase-2 path: buildFullApp finishes and marks the
61
+ // app ready WITHOUT setPageHandler, because there is no SSR handler.
62
+ markFullAppReady();
63
+
64
+ const state = getPageHandlerState();
65
+ expect(state.ready).toBe(true);
66
+ // The discriminator: ready is true while hasHandler stays false. Asserting
67
+ // both is what separates this from the SSR path — a fix that flipped ready
68
+ // by registering a dummy handler would pass `ready` and fail here.
69
+ expect(state.hasHandler).toBe(false);
70
+
71
+ const res = await app.request("/health");
72
+
73
+ expect(res.status).toBe(200);
74
+ expect(res.ok).toBe(true);
75
+ expect(await res.json()).toEqual({ status: "ok" });
76
+ });
77
+ });
@@ -27,9 +27,14 @@
27
27
  * 137ms warm), which is why one app looked healthy and the others did not.
28
28
  */
29
29
 
30
- import { describe, expect, test } from "bun:test";
30
+ import { beforeAll, describe, expect, test } from "bun:test";
31
31
  import { Hono } from "hono";
32
- import { healthHandler, setPageHandler, getPageHandlerState } from "./two-phase-boot.ts";
32
+ import {
33
+ __resetPhase2StateForTest,
34
+ getPageHandlerState,
35
+ healthHandler,
36
+ setPageHandler,
37
+ } from "./two-phase-boot.ts";
33
38
 
34
39
  /** Mount the real handler exactly as buildMinimalApp does. */
35
40
  const app = new Hono().get("/health", healthHandler);
@@ -41,6 +46,12 @@ const app = new Hono().get("/health", healthHandler);
41
46
  * hook and adding one would exist only for tests.
42
47
  */
43
48
  describe("/health is gated on page-serving readiness", () => {
49
+ // Bun shares one module instance across test files, so a sibling file that
50
+ // marks the app ready would otherwise decide this file's first assertion.
51
+ beforeAll(() => {
52
+ __resetPhase2StateForTest();
53
+ });
54
+
44
55
  test("before the phase-2 swap it reports 503, not ready", async () => {
45
56
  expect(getPageHandlerState().ready).toBe(false);
46
57
 
@@ -1,19 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The AGREEMENT content gate, expressed as config for the content-gate primitive
7
- * (content-gate/). The agreement-specific bits: which resource (the placed
8
- * SignIn gate block's id), who's authorized (the signer roster), what to seed
9
- * (the verified signer's signing session into every SignatureField), and how to
10
- * withhold (strip the gate region to gate-only + strip {{agreement.*}} chrome).
11
- * The engine handles cookie/token/verify/orchestration.
12
- *
13
- * The signing logic (buildSigningSession, seedSigningBlocks, roster expansion,
14
- * placeholder strip) is preserved verbatim from the former gate-agreement-
15
- * content.ts — only the cookie/token orchestration moved into the shared engine.
16
- */
17
- import type { AccessConfig, ContentGateConfig } from "./content-gate/config-types.ts";
18
- export declare const agreementGateConfig: ContentGateConfig;
19
- export declare const agreementAccessConfig: AccessConfig;
@@ -1,11 +0,0 @@
1
- /**
2
- * @system server-handlers
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * ETag derivation for the asset proxy that incorporates the negotiated
7
- * image format so per-Accept variants cache independently.
8
- */
9
- export type TargetFormat = "webp" | "avif" | "jpeg" | "png" | "gif" | "svg" | "ico" | "original";
10
- export declare function negotiateFormat(acceptHeader: string | null, storedContentType: string): TargetFormat;
11
- export declare function buildEtag(baseEtag: string, format: TargetFormat): string;
@@ -1,17 +0,0 @@
1
- /**
2
- * @system server-handlers
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * On-the-fly image optimisation for the asset proxy. Converts stored
7
- * PNG/JPEG to WebP or AVIF when the browser signals support, using
8
- * @teamscala/image-optimizer's compressImage primitive. Returns null
9
- * on failure so the caller can fall back to the original stream.
10
- */
11
- import type { TargetFormat } from "./asset-cache-key.ts";
12
- export interface OptimizeResult {
13
- data: Buffer;
14
- format: string;
15
- processedSize: number;
16
- }
17
- export declare function optimizeImage(buffer: Buffer, targetFormat: TargetFormat): Promise<OptimizeResult | null>;
@@ -1,11 +0,0 @@
1
- /**
2
- * @system server-handlers
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Same-origin S3 asset proxy with on-the-fly image optimisation.
7
- * Streams objects from the configured S3 bucket, converting PNG/JPEG
8
- * to WebP/AVIF when the browser declares support via Accept header.
9
- * Returns 404 for missing keys, 502 for S3 failures. Never redirects.
10
- */
11
- export declare function handleAssetProxy(req: Request): Promise<Response>;
@@ -1,17 +0,0 @@
1
- /**
2
- * @system server-handlers
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Dynamic-import wrapper around image-optimizer's in-process sharp
7
- * social-card op. Mirrors asset-optimize.ts's optional-dependency shape:
8
- * services without image-optimizer installed transparently get null and
9
- * fall back to the raw asset stream. In-process (not HTTP) because the
10
- * og:image transform is a cold path per shared link — no auth handshake,
11
- * no per-consumer service config.
12
- */
13
- export interface SocialCardOutcome {
14
- data: Buffer;
15
- processedSize: number;
16
- }
17
- export declare function renderSocialCard(buffer: Buffer): Promise<SocialCardOutcome | null>;
@@ -1,20 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The agreement MAGIC-LINK endpoint. A recipient who clicks an emailed
7
- * "Review & sign" link — which carries a pre-authorised, signed ag_token for
8
- * THEIR party — lands here: the token is verified for the agreement, the
9
- * ag_token cookie is set on the shared parent domain, and the browser is
10
- * 302-redirected straight into the signing page. No email-entry gate, no OTP —
11
- * "came from our email" IS the proof of identity, exactly like the
12
- * proposal-accept hand-off (attach-proposal-accept.ts).
13
- *
14
- * The token is the same HMAC ag_token the gate verifies (30-min TTL, scoped to
15
- * {agreementId, email}); an invalid/expired/forged token sets no cookie and the
16
- * destination page simply shows the normal email gate. GET so it is a plain
17
- * clickable link in an email.
18
- */
19
- import type { Hono } from "hono";
20
- export declare function attachAgreementMagic(app: Hono, serviceSlug: string): Promise<void>;
@@ -1,23 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Portal-secured member directory endpoint. Resolves members of an org via a
7
- * parameterized raw join over better_auth_member + "user" — NOT the typed
8
- * `member` ORPC model, which is deliberately omitted from the portal's
9
- * service_schema (security: the raw table is not exposed as a typed CRUD
10
- * surface). Mirrors the agreement-gate.ts:109-150 precedent.
11
- *
12
- * The portal's auth middleware (better-auth-cookie) gates the endpoint — only
13
- * authenticated users can call it. The orgId comes from the caller's context
14
- * (the portal resolves it from the session + passes it).
15
- *
16
- * Endpoint: GET /api/portal/members?orgId=<uuid>
17
- * - orgId omitted: super-admin all-orgs mode (returns every member across
18
- * all orgs, strictly gated to super-admin via getSession; used by the
19
- * "View As" impersonation dropdown).
20
- * Response: { members: Array<{ id, user_id, organization_id, role, email, name, image }> }
21
- */
22
- import type { Hono } from "hono";
23
- export declare function attachPortalMembers(app: Hono): Promise<void>;
@@ -1,26 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The proposal ACCEPT endpoint. A viewer who has passed the proposal gate (holds
7
- * a valid prop_token) POSTs {proposalId} here to accept; the server generates a
8
- * signing document from the proposal (createAgreementFromProposal) and
9
- * 302-redirects them straight into signing it on the sign site. This replaces
10
- * the old mailto CTA. Mounted BEFORE the auth gate so it is publicly reachable —
11
- * the prop_token (not a login) is the proof of identity, exactly like the
12
- * content-gate access endpoint.
13
- *
14
- * Single sign-on across the journey: the viewer already proved their email at
15
- * the proposal gate (prop_token), so we MUST NOT make them re-enter it on the
16
- * sign site. Before redirecting, we mint the agreement's ag_token for that same
17
- * verified email and set it on the SHARED parent domain (Domain=.scala.business)
18
- * so the browser carries it from proposals.scala.business to sign.scala.business
19
- * — the agreement gate then auto-reveals + seeds the signing session with zero
20
- * extra prompts. This requires both hosts to resolve the SAME token secret
21
- * (AUTH_SECRET→ENCRYPTION_KEY→DATABASE_URL): scala-sign must carry ENCRYPTION_KEY
22
- * (also required to decrypt the org's Stripe key on full sign), so both hosts
23
- * land on ENCRYPTION_KEY and the carried token verifies.
24
- */
25
- import type { Hono } from "hono";
26
- export declare function attachProposalAccept(app: Hono, serviceSlug: string): Promise<void>;
@@ -1,18 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The proposal MAGIC-LINK endpoint — the proposal twin of attach-agreement-magic.
7
- * A recipient who clicks an emailed "View your proposal" link (carrying a signed,
8
- * per-recipient prop_token) lands here: the token is verified for the proposal,
9
- * the prop_token cookie is set, and the browser is 302-redirected straight into
10
- * the proposal page. No email-entry gate, no OTP. An invalid/expired token sets
11
- * no cookie and the page falls through to the normal email gate.
12
- *
13
- * The prop_token is host-scoped (proposals are served on the proposals host only),
14
- * unlike the agreement ag_token which spans .scala.business for the proposal→sign
15
- * hand-off. GET so it is a plain clickable link in an email.
16
- */
17
- import type { Hono } from "hono";
18
- export declare function attachProposalMagic(app: Hono, serviceSlug: string): Promise<void>;
@@ -1,27 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Authenticated asset upload — POST /api/transfers/upload. Restores the
7
- * handler deleted with @teamscala/server-files (commit ca5643ef); avatar/logo
8
- * upload had 404'd on every service since. Writes to S3 via
9
- * @teamscala/object-storage/create/write-ops and returns the STORED-form URL
10
- * (`/api/files/<key>`) the client persists into avatar_url/logo_url — the
11
- * display path rewrites it to the public `/api/assets/<encoded-key>` proxy
12
- * via `toAssetPath` (@teamscala/formatters/asset-url). Self-authenticates
13
- * (getSession) so it mounts BEFORE the auth gate.
14
- *
15
- * Key scheme is USER-scoped (`users/<userId>/<type>/<file>-<ts>.<ext>`) per
16
- * the operator ruling: a profile picture belongs to the individual, not any
17
- * org (the prior handler's org-scoped `<orgId>/avatars/...` encoded the
18
- * uploading org into a personal asset). Spec + history:
19
- * `reference/asset-upload-system.md`.
20
- *
21
- * `thumbUrl` mirrors `url` — the full image is served for both; a separate
22
- * generated thumbnail is a future refinement (the prior handler generated
23
- * one, but the image-resize dependency is not wired here yet and a missing
24
- * thumb must not block the upload restoring).
25
- */
26
- import type { Hono } from "hono";
27
- export declare function attachTransfersUpload(app: Hono): Promise<void>;
@@ -1,28 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Auth middleware factory. Three modes, same BetterAuth email-OTP mechanism:
7
- *
8
- * service_install.auth values:
9
- * - omitted | "none" → no gate, no handler (scala-public, scala-sign)
10
- * - "better-auth-cookie" → full session auth: mount handler + session gate
11
- * - "email-otp-only" → mount handler (OTP send/verify), no session gate
12
- *
13
- * The sign-in block determines the UX per mode:
14
- * - "full" → email → OTP → session → portal selection (portal, agents, docs, gpt)
15
- * - "email-gate" → email → OTP → verify-only, no session (forms, proposals, quiz)
16
- * - "capture-only" → email field only, no OTP (sign)
17
- *
18
- * There is NO localhost bypass: 127.0.0.1 is gated identically to the tunnel
19
- * (no session → /sign-in). Tools needing an authed view sign in
20
- * (getSuperAdminSessionCookie); the dev OTP is DB-readable.
21
- *
22
- * When SCALA_OTP_URL is set, /api/auth/* forwards to the centralised
23
- * scala-otp service (per otp-centralised-via-otp-service). Otherwise
24
- * the local better-auth handler is used (dev-host fallback).
25
- */
26
- import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
27
- import type { Hono } from "hono";
28
- export declare function attachAuthGate(app: Hono, config: ServiceInstallConfig, serviceSlug: string): Promise<void>;
@@ -1,18 +0,0 @@
1
- /**
2
- * @system ui-service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Calls a createPageHandler() handler with the per-request extras the
7
- * SSR pipeline needs: the resolved userId/orgId (from localhost-bypass
8
- * or better-auth-cookie), and the htmlTemplate. Shared by the regions
9
- * and page_slug shell variants.
10
- */
11
- import type { Context } from "hono";
12
- type PageHandler = (req: Request, extras: Record<string, unknown>) => Promise<{
13
- body: unknown;
14
- status: number;
15
- headers: HeadersInit;
16
- }>;
17
- export declare function bindPageHandler(c: Context, handler: PageHandler, authMode: string, serviceSlug: string, label: string, resourceOrgId?: string): Promise<Response>;
18
- export {};
@@ -1,25 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Input hashing for the client-asset build fingerprint.
7
- *
8
- * Split out of build-client-assets.ts, which had grown to 1221 lines holding
9
- * six distinct concerns (lock + status, manifest, hydrate bundle, source maps,
10
- * stylesheet/tailwind, and this). `single-purpose-file` did not catch it: that
11
- * rule flags a file whose top-level exports share no common name root, which
12
- * measures export-NAME cohesion rather than how many concerns a file holds — so
13
- * a large file with few tidily-named exports stays invisible to it.
14
- *
15
- * This module owns ONE concern: turning the build's real inputs into a stable
16
- * hash. `computeInputFingerprint` (still in the parent, and still the only
17
- * exported entry point) composes the two functions exported here; everything
18
- * else is module-private because nothing outside needs it.
19
- *
20
- * Byte-for-byte move — imports adjusted, nothing else. No renames, no type
21
- * changes (`migrations-copy-not-rewrite`).
22
- */
23
- import { createHash } from "node:crypto";
24
- export declare function hashResolvedPackageStylesheets(cwd: string, h: ReturnType<typeof createHash>): Promise<void>;
25
- export declare function hashTeamgbgJsGraph(cwd: string, h: ReturnType<typeof createHash>): Promise<void>;
@@ -1,27 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Tailwind source-candidate scanning for the client CSS build.
7
- *
8
- * Split out of build-client-assets.ts (1221 lines, six concerns). Owns ONE
9
- * concern: turning the resolved `@source` directories into the candidate token
10
- * set Tailwind's `build()` consumes.
11
- *
12
- * Why it exists at all, preserved from the original: Tailwind v4's low-level
13
- * `compile()` does NOT scan content, and @tailwindcss/oxide's native Scanner is
14
- * not a dependency of the bare `tailwindcss` lib — only of the integration
15
- * packages. So the tokens are extracted here. Over-extraction is safe, because
16
- * `build()` ignores any token that is not a valid utility. Dropping this scan is
17
- * what shipped an unstyled page to the operator's tablet on 2026-06-13; it is
18
- * enforced by `css-build-scans-source-candidates`.
19
- *
20
- * Byte-for-byte move — imports adjusted, nothing else.
21
- */
22
- import { getAppLogger } from "@teamscala/logger/app-loggers";
23
- export declare function scanSourceCandidates(sources: Array<{
24
- base: string;
25
- pattern: string;
26
- negated?: boolean;
27
- }>, log: ReturnType<typeof getAppLogger>): Promise<string[]>;