@teamscala/service-boot 0.23.329 → 0.23.331

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 (157) hide show
  1. package/package.json +538 -522
  2. package/src/agreement-gate.d.ts +19 -0
  3. package/src/asset-cache-key.d.ts +11 -0
  4. package/src/asset-optimize.d.ts +17 -0
  5. package/src/asset-proxy.d.ts +11 -0
  6. package/src/asset-social-card.d.ts +17 -0
  7. package/src/attach-agreement-magic.d.ts +20 -0
  8. package/src/attach-portal-members.d.ts +23 -0
  9. package/src/attach-proposal-accept.d.ts +26 -0
  10. package/src/attach-proposal-magic.d.ts +18 -0
  11. package/src/attach-transfers-upload.d.ts +27 -0
  12. package/src/auth-gate.d.ts +28 -0
  13. package/src/auth-gate.ts +6 -68
  14. package/src/bind-page-handler.d.ts +18 -0
  15. package/src/build-client-assets/input-hashing.d.ts +25 -0
  16. package/src/build-client-assets/source-candidates.d.ts +27 -0
  17. package/src/build-client-assets.d.ts +50 -0
  18. package/src/build-client-assets.test.ts +1 -1
  19. package/src/build-client-assets.ts +1 -1
  20. package/src/compose-page-title.d.ts +41 -0
  21. package/src/content-gate/access.d.ts +15 -0
  22. package/src/content-gate/config-types.d.ts +99 -0
  23. package/src/content-gate/gate.d.ts +13 -0
  24. package/src/content-gate/parse-cookie.d.ts +9 -0
  25. package/src/content-gate/token.d.ts +48 -0
  26. package/src/content-page-floor.d.ts +39 -0
  27. package/src/discover-blocks.d.ts +28 -0
  28. package/src/discover-blocks.ts +1 -1
  29. package/src/expand-roster.d.ts +34 -0
  30. package/src/fn-routers/agreement-completion-email.d.ts +14 -0
  31. package/src/fn-routers/agreement-send-router.d.ts +82 -0
  32. package/src/fn-routers/ai-health-router.d.ts +35 -0
  33. package/src/fn-routers/auth-router.d.ts +47 -0
  34. package/src/fn-routers/cache-admin-router.d.ts +57 -0
  35. package/src/fn-routers/chat-session-router.d.ts +219 -0
  36. package/src/fn-routers/email-defaults.d.ts +1 -0
  37. package/src/fn-routers/email-link-ttl.d.ts +1 -0
  38. package/src/fn-routers/index.d.ts +18 -0
  39. package/src/fn-routers/index.ts +0 -2
  40. package/src/fn-routers/panel-data-router.d.ts +19 -0
  41. package/src/fn-routers/persist-chat-messages.d.ts +36 -0
  42. package/src/fn-routers/plans-router.d.ts +25 -0
  43. package/src/fn-routers/proposal-send-router.d.ts +58 -0
  44. package/src/fn-routers/public-agreement-router.d.ts +289 -0
  45. package/src/fn-routers/public-assessment-router.d.ts +68 -0
  46. package/src/fn-routers/public-gpt-session-router.d.ts +143 -0
  47. package/src/fn-routers/sign-in-flow-router.d.ts +107 -0
  48. package/src/fn-routers/task-sync-assign.d.ts +305 -0
  49. package/src/fn-routers/task-sync-assign.test.ts +1 -2
  50. package/src/fn-routers/task-sync-assign.ts +4 -4
  51. package/src/fn-routers/task-sync-bind.test.ts +18 -18
  52. package/src/fn-routers/task-sync-brief.d.ts +96 -0
  53. package/src/fn-routers/task-sync-brief.test.ts +17 -20
  54. package/src/fn-routers/task-sync-brief.ts +14 -15
  55. package/src/fn-routers/task-sync-close.d.ts +41 -0
  56. package/src/fn-routers/task-sync-dispatch.d.ts +61 -0
  57. package/src/fn-routers/task-sync-dispatch.test.ts +33 -51
  58. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  59. package/src/fn-routers/task-sync-lane.d.ts +49 -0
  60. package/src/fn-routers/task-sync-lane.ts +125 -0
  61. package/src/fn-routers/task-sync-projection.d.ts +121 -0
  62. package/src/fn-routers/task-sync-projection.test.ts +0 -26
  63. package/src/fn-routers/task-sync-projection.ts +0 -51
  64. package/src/fn-routers/task-sync-rename.d.ts +91 -0
  65. package/src/fn-routers/task-sync-router.d.ts +444 -0
  66. package/src/fn-routers/task-sync-router.test.ts +2 -14
  67. package/src/fn-routers/task-sync-router.ts +1111 -0
  68. package/src/fn-routers/tasks-router.d.ts +25 -0
  69. package/src/fn-routers/user-preferences-router.d.ts +77 -0
  70. package/src/fn-routers/work-items-router.d.ts +102 -0
  71. package/src/gate-ttls.d.ts +6 -0
  72. package/src/gpt-gate.d.ts +25 -0
  73. package/src/handlers/bun-stable-watch.d.ts +1 -0
  74. package/src/handlers/pane-outbox-drain.d.ts +1 -0
  75. package/src/handlers/projects-digest.d.ts +26 -0
  76. package/src/handlers/prune-stale-data.d.ts +30 -0
  77. package/src/handlers/registry-stale-inactive-sweep.d.ts +1 -0
  78. package/src/handlers/scala-tools-currency-watch.d.ts +1 -0
  79. package/src/handlers/singleton-tools-currency-watch.d.ts +1 -0
  80. package/src/handlers/sweep-event-log.d.ts +1 -0
  81. package/src/handlers/sweep-host-cache.d.ts +1 -0
  82. package/src/handlers/sweep-host-cache.ts +26 -143
  83. package/src/handlers/upstream-issue-sync.d.ts +1 -0
  84. package/src/handlers/whatsapp-liveness-logic.d.ts +34 -0
  85. package/src/handlers/whatsapp-liveness.d.ts +4 -0
  86. package/src/hono-error-handler.d.ts +25 -0
  87. package/src/install-harness.d.ts +45 -0
  88. package/src/install-logging.d.ts +34 -0
  89. package/src/load-service-install-row.d.ts +24 -0
  90. package/src/load-service-prisma-client.d.ts +18 -0
  91. package/src/mount-orpc-router.d.ts +15 -0
  92. package/src/mount-orpc-router.ts +1 -1
  93. package/src/mount-server-packages.d.ts +29 -0
  94. package/src/mount.d.ts +9 -0
  95. package/src/mutable-app-ref.d.ts +44 -0
  96. package/src/page-route-shell-types.d.ts +12 -0
  97. package/src/page-server-orpc/audit-revert-router.d.ts +37 -0
  98. package/src/page-server-orpc/hub-mcp-client.d.ts +12 -0
  99. package/src/page-server-orpc/portal-diagnostics-router.d.ts +49 -0
  100. package/src/page-server-orpc/puck-pages-router.d.ts +58 -0
  101. package/src/page-server-orpc/server-client.d.ts +16 -0
  102. package/src/page-server-orpc/templates-router.d.ts +34 -0
  103. package/src/page-server-orpc/user-preferences-router.d.ts +79 -0
  104. package/src/pg-boss-handler-registry.d.ts +27 -0
  105. package/src/pg-boss-mount.d.ts +42 -0
  106. package/src/proposal-authorized-emails.d.ts +28 -0
  107. package/src/proposal-chat-inject.d.ts +17 -0
  108. package/src/proposal-gate.d.ts +18 -0
  109. package/src/read-html-template.d.ts +38 -0
  110. package/src/read-viewport-from-request.d.ts +18 -0
  111. package/src/readiness-gate.d.ts +16 -0
  112. package/src/register-api-routes.d.ts +18 -0
  113. package/src/register-crash-handlers.d.ts +15 -0
  114. package/src/register-page-routes.d.ts +27 -0
  115. package/src/register-page-routes.ts +3 -0
  116. package/src/register-page-server-client.d.ts +11 -0
  117. package/src/register-registry-page-route/ai-chat-prefetch.d.ts +13 -0
  118. package/src/register-registry-page-route/graph-cache.d.ts +30 -0
  119. package/src/register-registry-page-route/meta-title.d.ts +2 -0
  120. package/src/register-registry-page-route/proposal-approval-email-token.d.ts +14 -0
  121. package/src/register-registry-page-route/proposal-brand-stamping.d.ts +28 -0
  122. package/src/register-registry-page-route/resolve-page-data.d.ts +1 -0
  123. package/src/register-registry-page-route.d.ts +27 -0
  124. package/src/register-static-assets.d.ts +13 -0
  125. package/src/register-ws-routes.d.ts +28 -0
  126. package/src/resolve-app-base-url.d.ts +13 -0
  127. package/src/resolve-brand-favicon.d.ts +28 -0
  128. package/src/resolve-brand-favicon.ts +1 -1
  129. package/src/resolve-from-loader-config.d.ts +32 -0
  130. package/src/resolve-service-context.d.ts +17 -0
  131. package/src/resolve-service-port.d.ts +75 -0
  132. package/src/resolve-theme-mode.d.ts +42 -0
  133. package/src/resolvers/fetch-org-brand.d.ts +2 -0
  134. package/src/resolvers/portal-route-paths.d.ts +39 -0
  135. package/src/resolvers/website-by-host-map.d.ts +12 -0
  136. package/src/resource-chat-inject.d.ts +46 -0
  137. package/src/resource-not-found.d.ts +60 -0
  138. package/src/run-boot-hook-list.d.ts +19 -0
  139. package/src/run-boot-hooks.d.ts +31 -0
  140. package/src/scala-tools-currency-watchdog.d.ts +10 -0
  141. package/src/security-headers.d.ts +12 -0
  142. package/src/serve/serve.d.ts +46 -0
  143. package/src/serve/stamp-transport-identity.d.ts +32 -0
  144. package/src/serve/stamp-transport-identity.ts +2 -14
  145. package/src/server-error-page.d.ts +29 -0
  146. package/src/start-scheduled-jobs.d.ts +18 -0
  147. package/src/two-phase-boot.d.ts +136 -0
  148. package/src/two-phase-boot.ts +17 -85
  149. package/src/two-phase-health-gate.test.ts +2 -13
  150. package/src/types.d.ts +46 -0
  151. package/src/ui-service.d.ts +46 -0
  152. package/src/ws-boot-self-test.d.ts +19 -0
  153. package/src/auth-gate.test.ts +0 -70
  154. package/src/fn-routers/task-sync-lane.test.ts +0 -36
  155. package/src/handlers/sweep-host-cache.test.ts +0 -133
  156. package/src/serve/stamp-transport-identity.test.ts +0 -24
  157. package/src/two-phase-health-gate-client-only.test.ts +0 -77
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Injects the generic ResourceChatLauncher block into an AUTHORISED resource
7
+ * page (proposal, agreement, or any gated document) — called from a gate's
8
+ * onAuthorized, so the floating GPT bubble lives strictly behind the email gate
9
+ * and every resource of that type gets it with no per-page Puck edit. It builds
10
+ * the resource's page content into a runtime-context string and passes it to the
11
+ * launcher, which forwards it to the chat as <runtime_context> — so ONE shared
12
+ * assistant agent answers about whichever resource is being viewed.
13
+ *
14
+ * RESOURCE-AGNOSTIC by design (one-ai-chat-system / chat-ui-is-one-canonical-block):
15
+ * the launcher, the chat engine, and this injector are generic; only the
16
+ * per-resource CONFIG differs (which agent, the copy, which resolved key holds
17
+ * the record). The agent id is config, not code (registries-are-source-of-truth):
18
+ * each resource type names a `config / <slug>-assistant-agent` row carrying
19
+ * { agentId }. No row (or no agent) → no injection, page renders unchanged
20
+ * (fail-soft).
21
+ */
22
+ /** Per-resource-type injection config — the only thing that varies. */
23
+ export interface ResourceChatOptions {
24
+ /** Key in `resolved` holding the record (e.g. "proposal", "agreement"). */
25
+ resourceKey: string;
26
+ /** `config` row slug carrying { agentId } (e.g. "agreement-assistant-agent"). */
27
+ agentConfigSlug: string;
28
+ /** Org id to scope the chat. If omitted, read from resolved[resourceKey].organisation_id
29
+ * (proposals); agreements pass it explicitly (org isn't on the resolved record). */
30
+ organisationId?: string;
31
+ /** Overlay title-bar label (e.g. "Agreement Assistant"). */
32
+ title: string;
33
+ /** Floating button label / speech bubble (e.g. "Ask about this agreement"). */
34
+ launchLabel: string;
35
+ /** First assistant message. */
36
+ welcomeMessage?: string;
37
+ /** Input placeholder. */
38
+ placeholder?: string;
39
+ /** Suggested starter questions. */
40
+ conversationStarters?: string[];
41
+ }
42
+ /**
43
+ * Inject the generic ResourceChatLauncher behind the gate. `opts` is the only
44
+ * per-resource-type variation; everything else is shared.
45
+ */
46
+ export declare function injectResourceChatLauncher(resolved: Record<string, unknown>, viewerEmail: string, opts: ResourceChatOptions): Promise<void>;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ * @indivisible-unit
6
+ *
7
+ * One cohesive unit — the branded resource-not-found / holding page: the
8
+ * PrimaryResourceMissingError trigger, the org-logo resolver the page
9
+ * renders, and the HTML builder are tightly coupled (the builder consumes
10
+ * the logo; the error drives the page) and cannot be split without churn.
11
+ *
12
+ * Branded 404 for registry page routes whose PRIMARY resource is missing.
13
+ *
14
+ * A route that declares `resource_type` (proposal, form, quiz, agreement…)
15
+ * is meaningless without that entity. Before this existed, a dead link
16
+ * (wrong id, deleted row) fell through the loader with nulls and rendered
17
+ * whatever default content the shell could scrape together — leaking
18
+ * internal pages (the "Vision Toolbar" incident, operator-observed
19
+ * 2026-06-05) on the org's own branded host, with a 200 status that
20
+ * search engines and link unfurlers would happily cache.
21
+ *
22
+ * The replacement: loadData throws PrimaryResourceMissingError; the page
23
+ * binder maps it to a minimal branded 404 — correct status, noindex, the
24
+ * host's own favicon (which resolves the owning org per brand_org), and a
25
+ * plain human sentence. Deliberately NOT a puck page: the error surface
26
+ * must render even when data loading is the thing that failed. A
27
+ * website-editable /404 puck page can layer on top later; this is the
28
+ * floor that must always exist.
29
+ */
30
+ /**
31
+ * The host org's LOGO (wordmark) for the dark error page — the favicon is a
32
+ * tab glyph, the wrong asset at page scale (operator-observed: a lone "h"
33
+ * where the launcha wordmark belonged). Resolution: the service's own
34
+ * install row names its owner (brand_org); the org's dark-theme logo (a
35
+ * light-coloured mark, correct on the dark page) is preferred, light-theme
36
+ * second. Every step skew-tolerant: any miss → null → the builder renders a
37
+ * logo-less dark page.
38
+ *
39
+ * Returns the logo as an INLINE data URI (not a `/api/assets/` proxy URL) so
40
+ * the branded holding/error page ships the logo IN the initial HTML bytes —
41
+ * no second fetch, no post-paint pop-in, no dependency on the asset proxy the
42
+ * degraded service is failing to serve (incident 2026-07-27: the logo on a
43
+ * DEGRADED render-layer holding page appeared after the page painted). The
44
+ * logo sibling of icons-render-ssr-inline. Size-capped: a pathological
45
+ * (>MAX_INLINE_LOGO_BYTES) logo falls back to null rather than bloating the
46
+ * HTML; the page renders logo-less, never broken.
47
+ */
48
+ export declare function resolveErrorPageLogo(serviceSlug: string): Promise<string | null>;
49
+ export declare class PrimaryResourceMissingError extends Error {
50
+ readonly resourceType: string;
51
+ readonly httpStatus: number;
52
+ readonly empty: boolean;
53
+ constructor(resourceType: string, httpStatus?: number, opts?: {
54
+ empty?: boolean;
55
+ });
56
+ }
57
+ export declare function buildResourceNotFoundResponse(resourceType: string, logoUrl?: string | null, status?: number, opts?: {
58
+ homeUrl?: string;
59
+ empty?: boolean;
60
+ }): Response;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Runs the service's boot hooks. A server_package registers a hook via
7
+ * ctx.registerBootHook during the mount phase; a hook registered by an
8
+ * INSTALLED server_package is intended to run (the package is installed
9
+ * because the service wants it). This therefore invokes EVERY registered
10
+ * hook — those declared in `service_install.boot_hooks[]` first (preserving
11
+ * their declared order), then any registered-but-undeclared hooks. Requiring a
12
+ * hook to be BOTH registered by its package AND re-declared in boot_hooks is a
13
+ * double-declaration footgun — it left the realtime-utils pg-notify bridge
14
+ * registered-but-never-invoked (boot_hooks was null), so the live-refresh
15
+ * relay never started (2026-06-29).
16
+ */
17
+ import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
18
+ import type { MountRegistry } from "@teamscala/os/runtime-contracts/mount-context";
19
+ export declare function runBootHookList(config: ServiceInstallConfig, mountRegistry: MountRegistry, serviceSlug: string): Promise<void>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Runs service_install.boot_hooks in declaration order. A boot hook is a
7
+ * registry-declared, one-shot startup function that needs to execute after
8
+ * prisma + registry are ready but before the HTTP server starts serving.
9
+ * Used for custom startup logic (stale-run reconciliation, cache hydration,
10
+ * ALS seeding, long-running watcher start) that doesn't fit the pg-boss
11
+ * recurring model and is too specific to be a generic installer step.
12
+ *
13
+ * Each hook is resolved by dynamic-importing its handler_slug (or slug when
14
+ * handler_slug is absent) and awaiting its default export. The export must
15
+ * be an async function accepting a BootHookContext. Hooks are invoked in
16
+ * ascending order of their `order` field (default 100).
17
+ *
18
+ * Failures are fatal. The installer has no partial-boot concept: if a
19
+ * declared hook cannot run, the service is broken, and booting anyway would
20
+ * mask the regression. BootFatalError is thrown to propagate to the
21
+ * top-level boot-error handler.
22
+ */
23
+ import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
24
+ export interface BootHookContext {
25
+ serviceSlug: string;
26
+ config: ServiceInstallConfig;
27
+ }
28
+ export type BootHookHandler = (ctx: BootHookContext) => Promise<void> | void;
29
+ export declare function runBootHooks(serviceSlug: string, config: ServiceInstallConfig): Promise<{
30
+ ran: string[];
31
+ }>;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Polls the installed scala-tools package against GitHub Packages and repairs
7
+ * stale daemon tooling by reinstalling and letting PM2 restart the process.
8
+ */
9
+ export declare function startScalaToolsCurrencyWatchdog(): void;
10
+ export declare function stopScalaToolsCurrencyWatchdog(): void;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Middleware that injects security headers into every response.
7
+ * Uses Hono's secureHeaders as the base for standard headers (X-Content-Type-Options,
8
+ * X-Frame-Options, Referrer-Policy, etc.), then layers registry-driven CSP with
9
+ * nonce injection on top.
10
+ */
11
+ import type { Context, Next } from "hono";
12
+ export declare function injectSecurityHeaders(c: Context, next: Next): Promise<void>;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @system runtime-serve
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Listener primitives. Provides the Bun.serve production listener (serveStart)
7
+ * and the WebSocket upgrade helper (upgradeWebSocket) that work identically
8
+ * dev and prod.
9
+ */
10
+ import type { Hono } from "hono";
11
+ /**
12
+ * Hono `upgradeWebSocket` helper bound to the same `createBunWebSocket()`
13
+ * pair that drives Bun.serve.websocket. Use exclusively from this package so
14
+ * the upgrade path and the message dispatch share state.
15
+ */
16
+ export declare const upgradeWebSocket: import("hono/ws").UpgradeWebSocket<unknown, any, import("hono/ws").WSEvents<unknown>>;
17
+ export interface ServeOptions {
18
+ /** TCP port to bind. */
19
+ port: number;
20
+ /** For logging only. */
21
+ serviceSlug: string;
22
+ /**
23
+ * Optional logger; defaults to console.
24
+ */
25
+ logger?: {
26
+ info(msg: string): void;
27
+ warn(msg: string): void;
28
+ };
29
+ }
30
+ export interface ServeHandle {
31
+ server: ReturnType<typeof Bun.serve>;
32
+ stop(): void;
33
+ }
34
+ /**
35
+ * Start Bun.serve listening on the configured port. Used by:
36
+ * - bootUiService when running the production bundle (`bun dist/server/index.js`)
37
+ * - bootBackendService for non-UI services
38
+ *
39
+ * UI services in dev mode also call this — they run `bun src/index.ts`
40
+ * directly under PM2 with the same Bun.serve listener.
41
+ *
42
+ * Two-phase boot variant: when useMutableRef is true, `app` is used as the
43
+ * initial app for the mutable reference (minimal tool-only surface), and the
44
+ * caller can swap to the full app later via swapApp().
45
+ */
46
+ export declare function serveStart(app: Hono, options: ServeOptions, useMutableRef?: boolean): ServeHandle;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Stamp transport-derived identity onto a request — the single shared stamp
7
+ * point used by BOTH serve paths (direct `createFetchHandler` + mutable-ref
8
+ * `fetchWithMutableApp`), so every request on every UI service carries the
9
+ * same identity regardless of boot mode.
10
+ *
11
+ * Two identity layers, both transport-derived (no-client-controlled-trust-
12
+ * signals — never from a client-controllable header):
13
+ * 1. `x-caller-remote-address` / `-port` — the unforgeable socket peer, from
14
+ * `Bun.serve.requestIP`. ALWAYS sanitised (any client-supplied spoof
15
+ * deleted) then set to the verified peer. The /mcp + fleet loopback gates
16
+ * read this.
17
+ * 2. `x-caller-local-comm` / `-cmd` — for a LOOPBACK peer with no forwarding
18
+ * header (a local `fetch` carries no CF-Connecting-IP), the owning local
19
+ * PROCESS resolved via /proc (@teamscala/proc-walker). Names the daemon
20
+ * behind every local request on the access log — including an unidentified
21
+ * sweeper. Attribution only (trust stays transport-derived).
22
+ *
23
+ * Best-effort + never throws: a resolution miss returns the original request
24
+ * unchanged (the request is always served; identity degrades to address-only).
25
+ * WebSocket upgrades are returned untouched (Bun tracks upgrades by Request
26
+ * identity — a clone can never upgrade).
27
+ */
28
+ /**
29
+ * Return a Request with transport-identity headers stamped, or the original
30
+ * `req` when no stamping is needed/possible. Never throws.
31
+ */
32
+ export declare function stampTransportIdentity(req: Request, server: ReturnType<typeof Bun.serve>): Request;
@@ -31,18 +31,6 @@ import { resolveSocketPeer } from "@teamscala/proc-walker/socket-peer";
31
31
 
32
32
  const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
33
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
-
46
34
  /**
47
35
  * Return a Request with transport-identity headers stamped, or the original
48
36
  * `req` when no stamping is needed/possible. Never throws.
@@ -89,8 +77,8 @@ export function stampTransportIdentity(
89
77
  const owner = resolveSocketPeer(port);
90
78
  if (owner) {
91
79
  headers.set(CALLER_HEADERS.LOCAL_COMM, owner.comm);
92
- const cmd = summariseLocalCommand(owner.cmdline);
93
- if (cmd) headers.set(CALLER_HEADERS.LOCAL_CMD, cmd);
80
+ const cmd = owner.cmdline.join(" ").trim();
81
+ if (cmd) headers.set(CALLER_HEADERS.LOCAL_CMD, cmd.slice(0, 160));
94
82
  }
95
83
  }
96
84
  return new Request(req.url, {
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The branded HTML error page for a FAILED PAGE RENDER — the page-server
7
+ * render catch's response surface (constitution `error-responses-via-error-
8
+ * report`). When SSR resolve+render fails (render budget exhausted, DEGRADED
9
+ * protective layer, a thrown loader), the browser is mid-navigation: serving
10
+ * a JSON body there is the violation this closes (2026-07-24: the operator
11
+ * saw a raw `{"error":"Request failed"}` blob where the dashboard should be).
12
+ *
13
+ * Classification stays in `buildErrorReport` (`@teamscala/os`) — the ONE
14
+ * classifier; this file is ONLY the HTML shaper for the page-navigation
15
+ * surface (API/ORPC `onError` keeps the JSON body, consumed by code). It
16
+ * renders the report's already-decided status + Retry-After + message, so the
17
+ * screen, the log line, and the GlitchTip event all share one `errorId`.
18
+ *
19
+ * Deliberately NOT a Puck page: it must render even when data loading is what
20
+ * failed. Sibling of `resource-not-found.ts` (the missing-resource floor) —
21
+ * same dark branded template + org-logo resolver, distinct trigger.
22
+ */
23
+ import type { ErrorReport } from "@teamscala/os/errors/error-report";
24
+ /**
25
+ * Render an `ErrorReport` as a branded HTML page. Pure shaper — the caller
26
+ * classifies via `buildErrorReport` and resolves the logo first. Skew-tolerant
27
+ * by construction: a missing logo degrades to a plain branded dark page.
28
+ */
29
+ export declare function buildServerErrorPageResponse(report: ErrorReport, logoUrl?: string | null): Response;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Starts `service_install.scheduled_jobs[]`. Two kinds:
7
+ *
8
+ * - `long_running`: handler invoked once at boot, runs for the lifetime of
9
+ * the process (e.g. SSE subscribers, WAL watchers). Failures are logged.
10
+ * - `recurring`: declared here but cron scheduling lands via pg-boss in a
11
+ * follow-up — currently a no-op log.
12
+ *
13
+ * Implementations are registered by server_packages via
14
+ * ctx.registerScheduledJob during the mount phase.
15
+ */
16
+ import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
17
+ import type { MountRegistry } from "@teamscala/os/runtime-contracts/mount-context";
18
+ export declare function startScheduledJobs(config: ServiceInstallConfig, mountRegistry: MountRegistry, serviceSlug: string): void;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Two-phase boot for UI services. Splits the boot process into:
7
+ * 1. Minimal app (tool-only surface): ORPC, data routes, auth gates
8
+ * 2. Full app (with pages and hydration): adds block discovery, page routes,
9
+ * client asset building, boot hooks, scheduled jobs
10
+ *
11
+ * The minimal app starts serving immediately (<10s), then the full app is
12
+ * built in the background and atomically swapped in. This eliminates the
13
+ * ~25s channel outage window during scala-agents restart.
14
+ */
15
+ import { Hono, type Context } from "hono";
16
+ import type { ServiceInstallConfig } from "./load-service-install-row.ts";
17
+ import { mountServerPackages } from "./mount-server-packages.ts";
18
+ import type { PageServerDeps } from "./ui-service.ts";
19
+ import type { CreatePageHandler } from "./types.ts";
20
+ /**
21
+ * Set the real page handler (called when Phase 2 completes).
22
+ */
23
+ export declare function setPageHandler(handler: CreatePageHandler): void;
24
+ /**
25
+ * Get the current page handler state.
26
+ */
27
+ export declare function getPageHandlerState(): {
28
+ ready: boolean;
29
+ hasHandler: boolean;
30
+ };
31
+ /**
32
+ * `/health` for a UI service — 200 ONLY once the full app can actually render
33
+ * a page, 503 before that.
34
+ *
35
+ * This endpoint is not decoration: it is the wake gate for every scale-to-zero
36
+ * app. `scala-activator` (`@teamscala/scale-to-zero`) holds the client's TCP
37
+ * connection through a cold wake and pipes it the instant `isHealthy(port)` —
38
+ * a plain `GET /health` — returns ok, which is what `scale-to-zero-apps-sleep-
39
+ * on-demand` means by "`/health`-gates the wake (the client never sees a boot
40
+ * 503)".
41
+ *
42
+ * It previously answered a flat `{status:"ok"}` as soon as the MINIMAL app was
43
+ * serving, because the readiness gate flips ready after phase 1 so the ORPC/MCP
44
+ * surface can accept traffic early. But phase 1 CANNOT render: the phase-1 page
45
+ * handler below returns 503 until `setPageHandler` runs at the phase-2 swap. So
46
+ * `/health` reported ready while every page 503'd, the activator believed the
47
+ * wake had landed, and it piped the customer's first request straight into that
48
+ * window — the customer got the 503.
49
+ *
50
+ * Measured 2026-07-31 on quiz-dev/sign-dev: the activator logged `awake` for
51
+ * scala-quiz at 05:58:25.446 and the app logged `GET / 503 3ms` at 05:58:25.452
52
+ * — 6ms later, and 1004ms BEFORE "Swapped from minimal app to full app". Nine
53
+ * such 503s on scala-quiz and two on scala-sign, every one 0-23ms (the
54
+ * placeholder, which does no render work). scala-public logged ZERO, which is
55
+ * the whole reason it looked healthy while the other two did not: the gap is a
56
+ * RACE whose width is the phase-1→phase-2 build time (1420ms cold, 137ms warm),
57
+ * not a config difference between the apps.
58
+ *
59
+ * Gating this on `fullAppReady` makes the signal honest for every consumer at
60
+ * once (the activator's wake gate, `poller-post-restart-health-gate`, any
61
+ * health-gated router) rather than teaching each one its own probe, and it is
62
+ * what `readiness-gated-boot` already requires: "/health returns 503 until
63
+ * ready, 200 after — so it is the real readiness signal", a service being
64
+ * "binary: fully-ready or cleanly-unavailable". A UI service that cannot render
65
+ * is not ready, per `liveness-by-capability-not-process-existence` — liveness is
66
+ * a probe of the capability being REQUESTED, never of process existence or of
67
+ * some other surface that happens to answer.
68
+ *
69
+ * The early readiness-gate flip is deliberately left alone: it gates the ORPC
70
+ * tool surface, which genuinely IS usable at phase 1. Only this endpoint, whose
71
+ * one consumer contract is "can you serve me a page", is moved to phase 2.
72
+ */
73
+ export declare function healthHandler(c: Context): Response;
74
+ export interface MinimalAppResult {
75
+ app: Hono;
76
+ mountRegistry: Awaited<ReturnType<typeof mountServerPackages>>;
77
+ apiRouteCount: number;
78
+ wsRouteCount: number;
79
+ pageRouteCount: number;
80
+ config: ServiceInstallConfig;
81
+ serviceSlug: string;
82
+ databaseUrl: string;
83
+ blockPackages: string[];
84
+ components: Record<string, unknown>;
85
+ loaded: string[];
86
+ skipped: string[];
87
+ }
88
+ export interface FullAppResult {
89
+ app: Hono;
90
+ blocks: {
91
+ slug: string;
92
+ packagePath: string;
93
+ }[];
94
+ routes: string[];
95
+ pgBossJobs: Array<{
96
+ handlerSlug: string;
97
+ }>;
98
+ }
99
+ /**
100
+ * Build the minimal tool-only app. This excludes the expensive operations:
101
+ * - Client asset building (Vite build)
102
+ * - Boot hooks (may have expensive initialization)
103
+ * - Scheduled jobs (can start later)
104
+ * - pg-boss (can start later)
105
+ *
106
+ * CRITICAL: All route registration MUST happen here before serving, because
107
+ * Hono's RegExpRouter compiles on the first request and becomes immutable.
108
+ * Once compiled, no new routes can be added. So we include:
109
+ * - Block discovery and page routes (registers all page paths)
110
+ * - All other routes (API, WS, data, static assets)
111
+ *
112
+ * The minimal app includes everything needed for MCP tools and ORPC:
113
+ * - Error handling, readiness gate, health check
114
+ * - Security headers, CORS, compression, timing
115
+ * - Content gate access, auth gates, static assets
116
+ * - ORPC router, API routes, WS routes, data routes, page routes
117
+ */
118
+ export declare function buildMinimalApp(config: ServiceInstallConfig, serviceSlug: string, databaseUrl: string): Promise<MinimalAppResult>;
119
+ /**
120
+ * Build the full app with hydration. This adds the expensive operations that
121
+ * were deferred from the minimal app:
122
+ * - Client asset building (Vite build)
123
+ * - Boot hooks (may have expensive initialization)
124
+ * - Scheduled jobs (can start later)
125
+ * - pg-boss (can start later)
126
+ *
127
+ * This runs in the background after the minimal app is already serving.
128
+ * NOTE: All routes (including page routes) are already registered in Phase 1,
129
+ * so we don't need to modify the app here. The app reference stays the same.
130
+ */
131
+ export declare function buildFullApp(minimalResult: MinimalAppResult, deps: PageServerDeps): Promise<FullAppResult>;
132
+ /**
133
+ * Swap the minimal app for the full app. This atomically replaces the
134
+ * app reference that Bun.serve is using.
135
+ */
136
+ export declare function swapToFullApp(fullApp: Hono, serviceSlug: string): void;
@@ -54,7 +54,6 @@ 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";
58
57
  import { swapApp } from "./mutable-app-ref.ts";
59
58
  import type { CreatePageHandler } from "./types.ts";
60
59
 
@@ -64,60 +63,13 @@ let realPageHandler: CreatePageHandler | null = null;
64
63
  let fullAppReady = false;
65
64
 
66
65
  /**
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.
66
+ * Set the real page handler (called when Phase 2 completes).
72
67
  */
73
68
  export function setPageHandler(handler: CreatePageHandler): void {
74
69
  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 {
99
70
  fullAppReady = true;
100
71
  }
101
72
 
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
-
121
73
  /**
122
74
  * Get the current page handler state.
123
75
  */
@@ -295,6 +247,16 @@ export async function buildMinimalApp(
295
247
  preloadServerPackages(serverPackages),
296
248
  );
297
249
  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(() => {});
298
260
 
299
261
  // Public access endpoints — MUST be registered before the auth gate (Hono
300
262
  // dispatches middleware + handlers in registration order; the content-gate
@@ -343,21 +305,6 @@ export async function buildMinimalApp(
343
305
  await runStep("mount-orpc-router", () => mountOrpcRouter(app, serviceSlug));
344
306
  }
345
307
 
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
-
361
308
  const wsRouteCount = await runStep("register-ws-routes", () =>
362
309
  registerWsRoutes(app, config, mountRegistry, serviceSlug),
363
310
  );
@@ -429,8 +376,7 @@ export async function buildMinimalApp(
429
376
  // (the same pulsating-logo holding page scale-to-zero uses) —
430
377
  // never bare plain text (loading-via-logo-loading).
431
378
  if (!fullAppReady || !realPageHandler) {
432
- const logo = await resolveErrorPageLogo(serviceSlug);
433
- return new Response(buildWakingPageHtml({ logoUrl: logo }), {
379
+ return new Response(buildWakingPageHtml({}), {
434
380
  status: 503,
435
381
  headers: {
436
382
  "content-type": "text/html; charset=utf-8",
@@ -552,25 +498,11 @@ export async function buildFullApp(
552
498
  `${Object.keys(components).length} component(s)`,
553
499
  );
554
500
 
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
- }
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
+ );
574
506
 
575
507
  const resolvedBlocks = loaded.map((slug) => ({
576
508
  slug,
@@ -27,14 +27,9 @@
27
27
  * 137ms warm), which is why one app looked healthy and the others did not.
28
28
  */
29
29
 
30
- import { beforeAll, describe, expect, test } from "bun:test";
30
+ import { describe, expect, test } from "bun:test";
31
31
  import { Hono } from "hono";
32
- import {
33
- __resetPhase2StateForTest,
34
- getPageHandlerState,
35
- healthHandler,
36
- setPageHandler,
37
- } from "./two-phase-boot.ts";
32
+ import { healthHandler, setPageHandler, getPageHandlerState } from "./two-phase-boot.ts";
38
33
 
39
34
  /** Mount the real handler exactly as buildMinimalApp does. */
40
35
  const app = new Hono().get("/health", healthHandler);
@@ -46,12 +41,6 @@ const app = new Hono().get("/health", healthHandler);
46
41
  * hook and adding one would exist only for tests.
47
42
  */
48
43
  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
-
55
44
  test("before the phase-2 swap it reports 503, not ready", async () => {
56
45
  expect(getPageHandlerState().ready).toBe(false);
57
46