@teamscala/service-boot 0.23.523 → 0.23.525

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/package.json +1 -9
  2. package/src/agreement-gate/agreement-lookup.ts +0 -7
  3. package/src/agreement-gate/authorized-emails.ts +0 -14
  4. package/src/agreement-gate/placeholder-strip.ts +0 -10
  5. package/src/agreement-gate/resolution-caches.ts +0 -5
  6. package/src/agreement-gate/settings.ts +0 -22
  7. package/src/agreement-gate/signing-session.ts +0 -8
  8. package/src/agreement-gate/signing-shell.ts +0 -9
  9. package/src/agreement-gate.ts +0 -52
  10. package/src/apply-logger-config.ts +1 -9
  11. package/src/asset-cache-key.ts +1 -5
  12. package/src/asset-optimize.ts +1 -7
  13. package/src/asset-proxy.ts +1 -10
  14. package/src/asset-social-card.ts +1 -9
  15. package/src/attach-agreement-magic.ts +0 -14
  16. package/src/attach-portal-members.ts +0 -17
  17. package/src/attach-proposal-accept.ts +1 -21
  18. package/src/attach-proposal-magic.ts +1 -13
  19. package/src/attach-transfers-upload.ts +0 -21
  20. package/src/auth/sign-out.ts +1 -44
  21. package/src/auth-gate/public-paths.ts +1 -96
  22. package/src/auth-gate.ts +1 -41
  23. package/src/bind-page-handler.ts +1 -83
  24. package/src/build-client-assets/build-lock.ts +1 -11
  25. package/src/build-client-assets/hashed-output.ts +1 -11
  26. package/src/build-client-assets/hydrate-bundle.ts +1 -137
  27. package/src/build-client-assets/manifest.ts +1 -30
  28. package/src/build-client-assets/source-candidates.ts +0 -51
  29. package/src/build-client-assets/sourcemaps-upload.ts +1 -52
  30. package/src/build-client-assets/styles-build.ts +1 -71
  31. package/src/build-client-assets.ts +1 -30
  32. package/src/capability-slug.ts +1 -45
  33. package/src/compose-page-title.ts +1 -34
  34. package/src/content-gate/access.ts +0 -34
  35. package/src/content-gate/catalog.ts +0 -52
  36. package/src/content-gate/config-types.ts +3 -90
  37. package/src/content-gate/gate-entry-clears.ts +0 -18
  38. package/src/content-gate/gate.ts +0 -27
  39. package/src/content-gate/parse-cookie.ts +0 -4
  40. package/src/content-page-floor.ts +0 -28
  41. package/src/csrf-protection.ts +1 -28
  42. package/src/error-page-logo.ts +2 -35
  43. package/src/expand-roster.ts +1 -25
  44. package/src/gate-ttls.ts +0 -12
  45. package/src/gpt-gate.ts +1 -19
  46. package/src/hono-error-handler.ts +0 -19
  47. package/src/install-harness.ts +1 -12
  48. package/src/install-logging.ts +1 -47
  49. package/src/load-page-manifest.ts +1 -29
  50. package/src/load-service-install-row.ts +42 -10
  51. package/src/module-not-found.ts +0 -17
  52. package/src/mount-orpc-router.ts +1 -94
  53. package/src/mount-server-packages.ts +1 -37
  54. package/src/mount.ts +1 -4
  55. package/src/mutable-app-ref.ts +0 -21
  56. package/src/orpc-contract-rows.json +289 -0
  57. package/src/orpc-openapi-document.ts +1 -19
  58. package/src/orpc-openapi-gate.ts +1 -23
  59. package/src/page-route-shell-types.ts +1 -8
  60. package/src/page-server-loader-graph.ts +0 -10
  61. package/src/page-server-orpc/server-client.ts +1 -23
  62. package/src/pg-boss-handler-registry.ts +1 -24
  63. package/src/pg-boss-mount.ts +1 -109
  64. package/src/proposal-authorized-emails.ts +0 -32
  65. package/src/proposal-gate.ts +1 -52
  66. package/src/quiz-gate.ts +1 -31
  67. package/src/read-html-template.ts +1 -49
  68. package/src/readiness-gate.ts +0 -8
  69. package/src/register-api-routes.ts +1 -11
  70. package/src/register-crash-handlers.ts +1 -6
  71. package/src/register-page-routes.ts +1 -12
  72. package/src/register-page-server-client.ts +1 -66
  73. package/src/register-registry-page-route/ai-chat-prefetch.ts +1 -24
  74. package/src/register-registry-page-route/apply-page-branding.ts +1 -100
  75. package/src/register-registry-page-route/captured-loader-config.ts +1 -12
  76. package/src/register-registry-page-route/graph-cache.ts +1 -38
  77. package/src/register-registry-page-route/live-loader-config.ts +1 -22
  78. package/src/register-registry-page-route/meta-title.ts +1 -36
  79. package/src/register-registry-page-route/page-data-shared.ts +1 -18
  80. package/src/register-registry-page-route/prefetch-org-id.ts +1 -24
  81. package/src/register-registry-page-route/proposal-approval-email-token.ts +1 -22
  82. package/src/register-registry-page-route/proposal-brand-stamping.ts +1 -31
  83. package/src/register-registry-page-route/public-shell-page-data.ts +1 -34
  84. package/src/register-registry-page-route/region-slots-page-data.ts +1 -8
  85. package/src/register-registry-page-route/resolve-page-data.ts +1 -10
  86. package/src/register-registry-page-route/resolve-panel-page.ts +1 -10
  87. package/src/register-registry-page-route/resolve-request-graph.ts +1 -68
  88. package/src/register-registry-page-route/run-content-gates.ts +3 -64
  89. package/src/register-registry-page-route/single-page-data.ts +1 -9
  90. package/src/register-registry-page-route/stamp-session-context.ts +1 -10
  91. package/src/register-registry-page-route/warn-uncovered-blocks.ts +1 -18
  92. package/src/register-registry-page-route.ts +1 -93
  93. package/src/register-static-assets.ts +1 -53
  94. package/src/register-ws-routes.ts +1 -21
  95. package/src/resolve-brand-favicon.ts +0 -22
  96. package/src/resolve-service-port.ts +1 -61
  97. package/src/resolve-theme-mode.ts +1 -30
  98. package/src/resolvers/app-header.ts +1 -20
  99. package/src/resolvers/fetch-org-brand.ts +1 -11
  100. package/src/resolvers/portal-route-paths.ts +1 -29
  101. package/src/resolvers/website-by-host-map.ts +1 -7
  102. package/src/resource-not-found.ts +0 -48
  103. package/src/run-boot-hook-list.ts +1 -13
  104. package/src/run-boot-hooks.ts +1 -19
  105. package/src/scala-tools-currency-watchdog.ts +1 -7
  106. package/src/security-headers.ts +1 -7
  107. package/src/select-service-routes.ts +1 -31
  108. package/src/server-error-page.ts +0 -18
  109. package/src/theme-fonts-link.ts +1 -21
  110. package/src/transfers/presigned-upload.ts +1 -27
  111. package/src/two-phase-full-app.ts +0 -82
  112. package/src/two-phase-minimal-app.ts +1 -111
  113. package/src/two-phase-readiness.ts +0 -6
  114. package/src/types.ts +0 -17
  115. package/src/ui-service.ts +2 -48
  116. package/src/ws-boot-self-test.ts +1 -14
  117. package/src/content-gate/gate-entry-marker.test.ts +0 -111
  118. package/src/content-gate/token.segmentation.test.ts +0 -76
  119. package/src/content-gate/token.test.ts +0 -65
  120. package/src/content-gate/token.ts +0 -219
  121. package/src/is-react-element-type.test.ts +0 -45
  122. package/src/is-react-element-type.ts +0 -20
  123. package/src/page-server-orpc/hub-mcp-client.ts +0 -20
  124. package/src/pg-boss-mount.test.ts +0 -101
  125. package/src/resource-chat-inject.page-assistant.test.ts +0 -145
  126. package/src/resource-chat-inject.test.ts +0 -240
  127. package/src/resource-chat-inject.ts +0 -374
@@ -1,30 +1,6 @@
1
1
  /**
2
2
  * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * One cohesive unit — the branded resource-not-found / holding page FLOOR:
7
- * the PrimaryResourceMissingError trigger and the HTML builder (the builder
8
- * consumes a logoUrl argument; the org-logo resolvers live in
9
- * error-page-logo.ts).
10
- *
11
- * Branded 404 for registry page routes whose PRIMARY resource is missing.
12
- *
13
- * A route that declares `resource_type` (proposal, form, quiz, agreement…)
14
- * is meaningless without that entity. Before this existed, a dead link
15
- * (wrong id, deleted row) fell through the loader with nulls and rendered
16
- * whatever default content the shell could scrape together — leaking
17
- * internal pages (the "Vision Toolbar" incident, operator-observed
18
- * 2026-06-05) on the org's own branded host, with a 200 status that
19
- * search engines and link unfurlers would happily cache.
20
- *
21
- * The replacement: loadData throws PrimaryResourceMissingError; the page
22
- * binder maps it to a minimal branded 404 — correct status, noindex, the
23
- * host's own favicon (which resolves the owning org per brand_org), and a
24
- * plain human sentence. Deliberately NOT a puck page: the error surface
25
- * must render even when data loading is the thing that failed. A
26
- * website-editable /404 puck page can layer on top later; this is the
27
- * floor that must always exist.
28
4
  */
29
5
 
30
6
  import { brandLogoImgTag } from "@teamscala/os/brand/brand-logo-img-tag";
@@ -39,16 +15,7 @@ const RESOURCE_LABELS: Record<string, string> = {
39
15
 
40
16
  export class PrimaryResourceMissingError extends Error {
41
17
  readonly resourceType: string;
42
- // HTTP status for the holding page. 404 for a genuinely missing/invalid
43
- // resource (a dead /:id link); 200 for the bare root of a UUID-driven site
44
- // ("no <resource> selected") — the root is a valid landing, not an error,
45
- // and kind:ui services must answer GET / with 200 (verify-prod-persistent-slot).
46
18
  readonly httpStatus: number;
47
- // A page ROW exists but has zero content blocks — a content gap, not a dead
48
- // link (operator ruling 2026-07-21: "if the page is empty, it should have a
49
- // Puck block that says this page is empty... and then take me home"). The
50
- // floor renders the "This page is empty" surface (200) instead of the dead-
51
- // link "doesn't exist" surface (404). Distinct from a dead link (no row).
52
19
  readonly empty: boolean;
53
20
  constructor(resourceType: string, httpStatus = 404, opts?: { empty?: boolean }) {
54
21
  super(`primary resource "${resourceType}" not found`);
@@ -63,23 +30,10 @@ export function buildResourceNotFoundResponse(
63
30
  resourceType: string,
64
31
  logoUrl?: string | null,
65
32
  status = 404,
66
- // Optional 4th arg keeps the existing 3-arg call sites working while letting
67
- // the page binder add a "Take me home" action (operator-requested) and the
68
- // empty-page variant.
69
33
  opts?: { homeUrl?: string; empty?: boolean },
70
34
  ): Response {
71
35
  const label = RESOURCE_LABELS[resourceType] ?? "page";
72
- // Single-owner hosts show the owner's wordmark; multi-tenant hosts
73
- // (no brand_org — a dead id's org is unknowable) stay platform-neutral.
74
- // The helper emits the inline data-URI src with reserved width+height
75
- // (logos-inline-on-branded-server-pages).
76
36
  const logoImg = brandLogoImgTag(logoUrl, { height: 40, width: 160 });
77
- // Three surfaces share this one floor (resource-not-found-is-the-root-floor):
78
- // 200 root — the BARE ROOT of a UUID-driven host ("no <resource> selected").
79
- // 404 dead — a content path with NO page row (a deleted/moved page, e.g.
80
- // /roadmap) OR a dead /:id: a link that genuinely doesn't resolve.
81
- // 200 empty — a page ROW exists but has zero blocks (a content gap, not a
82
- // dead link): "This page is empty", still take-me-home.
83
37
  const isEmpty = opts?.empty === true;
84
38
  const isRoot = status === 200 && !isEmpty;
85
39
  const title = isEmpty ? "Nothing here yet" : isRoot ? `No ${label} here` : "Not found";
@@ -95,8 +49,6 @@ export function buildResourceNotFoundResponse(
95
49
  sent to view it — there&rsquo;s nothing to see at the bare address.`
96
50
  : `The page you&rsquo;re looking for may have been moved or removed, or the
97
51
  link may be incomplete.`;
98
- // "Take me home" — operator-requested action on every not-found/empty surface.
99
- // Omitted on the bare-root landing (it IS home). Defaults to the host root.
100
52
  const homeUrl = opts?.homeUrl ?? "/";
101
53
  const homeLink = isRoot
102
54
  ? ""
@@ -1,18 +1,6 @@
1
1
  /**
2
- * @system server-boot
2
+ * @system service-boot
3
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
4
  */
17
5
 
18
6
  import type { ServiceInstallConfig } from "./load-service-install-row";
@@ -1,24 +1,6 @@
1
1
  /**
2
- * @system server-boot
2
+ * @system service-boot
3
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
4
  */
23
5
 
24
6
  import type { ServiceInstallConfig } from "./load-service-install-row";
@@ -1,12 +1,6 @@
1
1
  /**
2
- * @system server-boot
2
+ * @system service-boot
3
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
- * The fetch routes through `getGithubPackagesLatestVersion` so the URL
9
- * encoding and GitHub-Packages auth header live in the typed primitive.
10
4
  */
11
5
 
12
6
  import { recordEvent } from "@teamscala/event-log/record-event";
@@ -1,12 +1,6 @@
1
1
  /**
2
- * @system server-boot
2
+ * @system service-boot
3
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
4
  */
11
5
 
12
6
  import { getConfigObject } from "@teamscala/db/registry/config";
@@ -1,37 +1,14 @@
1
1
  /**
2
- * @system ui-service-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Selecting and ordering the `page_route` rows one service serves.
7
- *
8
- * Two decisions live here, both about WHICH rows and IN WHAT ORDER — never
9
- * about what a route does once matched:
10
- *
11
- * 1. SELECTION joins on the CAPABILITY, never on the deployable name. A
12
- * service arrives under its LANE identity (`scala-projects-bun-dev` in
13
- * dev, `scala-projects` in prod), while a page_route row names the
14
- * APPLICATION and there is ONE row for both lanes. Raw string equality
15
- * therefore matched at most one lane, silently — see `./capability-slug.ts`
16
- * for the measured incident.
17
- *
18
- * 2. ORDERING puts specific patterns before parameterised and wildcard ones,
19
- * because Hono matches in REGISTRATION order.
20
4
  */
21
5
 
22
6
  import { toCapabilitySlug } from "./capability-slug.ts";
23
7
 
24
- /** The shape this module needs from a loaded page_route row. */
25
8
  export interface SelectableRoute {
26
9
  config: { pattern: string; service: string };
27
10
  }
28
11
 
29
- /**
30
- * Specificity rank for registration order. Hono matches by registration
31
- * order, so `/*` (the portal catchall) registered before `/sign-in` would
32
- * swallow the sign-in page and render it inside the PortalTemplate instead
33
- * of the standalone PublicTemplate shell.
34
- */
35
12
  function comparePatternSpecificity(a: string, b: string): number {
36
13
  // Wildcard `/*` goes last.
37
14
  if (a === "/*") return 1;
@@ -44,13 +21,6 @@ function comparePatternSpecificity(a: string, b: string): number {
44
21
  return 0;
45
22
  }
46
23
 
47
- /**
48
- * The page_route rows this service serves, in registration order.
49
- *
50
- * `serviceSlug` is the deployable identity of the calling lane; both sides
51
- * of the join normalize through `toCapabilitySlug`, so a row may name either
52
- * the capability or a deployable form and both lanes resolve the same set.
53
- */
54
24
  export function selectServiceRoutes<T extends SelectableRoute>(
55
25
  allRoutes: readonly T[],
56
26
  serviceSlug: string,
@@ -1,24 +1,6 @@
1
1
  /**
2
2
  * @system service-boot
3
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
4
  */
23
5
 
24
6
  import { escapeHtml } from "@teamscala/formatters/escapeHtml";
@@ -1,21 +1,6 @@
1
1
  /**
2
- * @system ui-service-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Derives the document head's Google Fonts link from the `css_theme` row's
7
- * `fonts` map — the ONE source for the families a page's variants reference
8
- * (`the-theme-decides-colour-not-the-component` extends to type). The old
9
- * envelope hard-coded Inter, so a proposal heading naming a manuscript serif
10
- * ("Playfair Display") never had its family loaded and fell back to Times
11
- * (2026-09-02). Now every family the row declares is loaded; a family a
12
- * variant names that the row does not declare is a row defect, caught at the
13
- * row's schema boundary.
14
- *
15
- * Read from the boot registry cache (captured at service boot, the same
16
- * semantics as the resource_loader capture) and guarded: a missing or
17
- * unreadable row degrades to NO fonts link — system fonts render, the page
18
- * still works. This module must never throw into the document builder.
19
4
  */
20
5
 
21
6
  import { getEntryConfig } from "@teamscala/db/registry/accessors";
@@ -24,15 +9,11 @@ interface ThemeFontsConfig {
24
9
  fonts?: Record<string, string>;
25
10
  }
26
11
 
27
- /** The primary family name of a CSS font stack: "Inter, sans-serif" →
28
- * "Inter"; "Playfair Display" → "Playfair Display". */
29
12
  export function primaryFontFamily(stack: string): string {
30
13
  const first = stack.split(",")[0] ?? "";
31
14
  return first.trim().replace(/^['"]|['"]$/g, "");
32
15
  }
33
16
 
34
- /** The declared theme families, deduplicated by primary family, in row
35
- * order (body, heading, manuscript, mono…). Empty when the row is absent. */
36
17
  export function themeFontFamilies(): string[] {
37
18
  try {
38
19
  const config = getEntryConfig<ThemeFontsConfig>("css_theme", "default");
@@ -52,7 +33,6 @@ export function themeFontFamilies(): string[] {
52
33
  }
53
34
  }
54
35
 
55
- /** The <link> tag loading every declared theme family, or "" when none. */
56
36
  export function themeFontsLink(): string {
57
37
  const families = themeFontFamilies();
58
38
  if (families.length === 0) return "";
@@ -1,32 +1,6 @@
1
1
  /**
2
2
  * @system service-boot
3
- * @status handwritten — none derivable: searched the api_route row (fn-transfers-presigned-upload) which declares path/handler_config/input but not a body, the codegen outputs (host-bus-handler-contracts emits wiring + suites FROM rows, never procedure bodies), reference/package-catalog.md (object-storage signs a key; it carries no upload-policy primitive: no size/type/dir-scheme package to install), and the retired mount attach-transfers-upload.ts (the policy's origin, not a derivation target) — nothing generates a policy-checking procedure body.
4
- * @edit edit directly
5
- *
6
- * The fn.transfers.presignedUpload procedure body (api_route
7
- * `fn-transfers-presigned-upload`, authed: input + context). It replaces the
8
- * FormData POST /api/transfers/upload mount for every UI caller: the browser
9
- * asks for a SIGNED PUT URL for one key, uploads the bytes straight to the
10
- * store, and persists the SERVABLE key — so the file never crosses the
11
- * service and the call is one JSON request instead of a multipart body.
12
- *
13
- * The policy the mount carried lives here, and is the SAME policy: the
14
- * MAX_FILE_SIZE / ALLOWED_TYPES refusals (with their 413/415 statuses intact
15
- * through the ORPC codes below), the USER-scoped key scheme
16
- * (`users/<userId>/<dir>/<dir-singular>-<ts>.<ext>`), the unknown-dir
17
- * fallback to `uploads`, and the servable value returned as `url` with
18
- * `thumbUrl` mirroring it (the stored value is the servable value —
19
- * single-logo-pathway, so display code reads the column with no rewriting).
20
- * The key is derived from `context.user.id`, never from input: a caller
21
- * cannot mint a URL under another user's prefix.
22
- *
23
- * HONEST RESIDUAL: the size refusal reads the DECLARED fileSize, because the
24
- * bytes no longer pass through here — the store enforces the signature's type
25
- * and expiry, not a byte count. The mount's Content-Length check was equally
26
- * a declared-value check (an attacker sent the bytes either way); what
27
- * changes is that a lying client is now bounded by the URL's TTL instead of a
28
- * boot-level body limit. A real byte bound belongs to the bucket, not to this
29
- * handler.
3
+ * @status handwritten
30
4
  */
31
5
  import { getAppLogger } from "@teamscala/logger/app-loggers";
32
6
  import { getPresignedUrl } from "@teamscala/object-storage/create/presigned-url";
@@ -1,60 +1,10 @@
1
1
  /**
2
2
  * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Phase 2 of the two-phase boot: the full app. Adds the expensive operations
7
- * deferred from phase 1 (two-phase-minimal-app.ts) — client asset building
8
- * (Vite build), pg-boss, the readiness/page-handler flip — and is swapped
9
- * in atomically once complete.
10
4
  */
11
5
 
12
- /**
13
- * Build the full app with hydration. This adds the expensive operations that
14
- * were deferred from the minimal app:
15
- * - Client asset building (Vite build)
16
- * - Boot hooks (may have expensive initialization)
17
- * - Scheduled jobs (can start later)
18
- * - pg-boss (can start later)
19
- *
20
- * This runs in the background after the minimal app is already serving.
21
- * NOTE: All routes (including page routes) are already registered in Phase 1,
22
- * so we don't need to modify the app here. The app reference stays the same.
23
- */
24
6
 
25
- // Client asset building (expensive: Vite build).
26
- //
27
- // ISOLATED, because a PRESENTATION failure must not take out BACKGROUND WORK.
28
- // This was awaited unguarded, so a throw here aborted buildFullApp before
29
- // mountPgBoss ran, and the caller's catch logged
30
- // "Failed to build full app, continuing with minimal app" — which reads as a
31
- // UI-only degradation while having silently killed every pg-boss handler on
32
- // the service.
33
- //
34
- // Measured 2026-07-30: a stale @teamscala/portal-ui artifact still importing
35
- // the retired @teamgbg scope threw ENOENT on a missing theme CSS file in
36
- // scala-agents. Zero "scheduledJobs DONE" lines appeared for ~20 minutes
37
- // across repeated boots, the pane-outbox drain never mounted, and the
38
- // operator's WhatsApp assistant went silent — because of a stylesheet. The
39
- // service stayed `online` and healthy throughout, so nothing surfaced it.
40
- //
41
- // The steps after this one do not depend on client assets: pg-boss needs the
42
- // registry and the DB, not a bundle. Ordering it behind a Vite build was
43
- // incidental, so the coupling was never intended.
44
7
 
45
- // pg-boss (mount for UI services - wasn't mounted in minimal app)
46
- //
47
- // Wrapped for the SAME reason buildClientAssets above is, in the other
48
- // direction: a background-work failure must not cost the page. Unwrapped,
49
- // anything mountPgBoss throws propagates out of the full-app build, the
50
- // caller falls back to the MINIMAL app, and the service serves 0 api routes
51
- // with /health 503 — so the tunnel health check fails and the site is
52
- // unreachable from outside while localhost answers in 0.1s.
53
- //
54
- // Measured 2026-08-31 on scala-projects: a deps refresh moved pg-boss to a
55
- // build expecting a newer shared pgboss schema than the database carried, its
56
- // migration threw, and an outage of every page followed from a scheduled-job
57
- // dependency. Jobs are the degradable half; pages are not.
58
8
 
59
9
  import { getAppLogger } from "@teamscala/logger/app-loggers";
60
10
  import type { Hono } from "hono";
@@ -84,9 +34,6 @@ export async function buildFullApp(
84
34
  await buildClientAssets(serviceSlug);
85
35
  log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets DONE`);
86
36
  } catch (error) {
87
- // Loud but non-fatal: the tombstone manifest written by buildClientAssets
88
- // already makes SSR serve the prior bundle, so degrading here costs the
89
- // new bundle, never the background work.
90
37
  log.error(
91
38
  `[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets FAILED — continuing to scheduled jobs and pg-boss (a client-asset failure must not stop background work)`,
92
39
  { error: error instanceof Error ? error.message : String(error) },
@@ -113,23 +60,6 @@ export async function buildFullApp(
113
60
  );
114
61
  }
115
62
 
116
- // The readiness flip is gated on a VERIFIED client build, never on "phase 2
117
- // got this far". buildClientAssets's failure above is deliberately non-fatal
118
- // for background work — but a deployment whose client build never ran (or
119
- // whose tombstone names unbuilt files) must not flip ready and serve pages:
120
- // every served asset URL would 404 while /health and the deploy report ok.
121
- // Measured 2026-09-01: proposals.scala.business served a client-facing
122
- // proposal unstyled while Railway reported SUCCESS, because the phase-1
123
- // catch let boot continue past the build and the flip never checked the
124
- // build's outcome (`a-component-may-not-report-a-state-it-has-not-verified`).
125
- // The throw lands in ui-service's boot.client-build-failed catch, which
126
- // keeps the minimal app: pages and /health stay 503 (cleanly unavailable)
127
- // while the pg-boss jobs mounted above KEEP RUNNING. On Railway the
128
- // healthcheck (healthcheckPath: /health) then fails and the previous good
129
- // deployment keeps traffic — the deploy reports FAILED, never SUCCESS.
130
- // readiness-gated-boot: a service is "fully-ready or cleanly-unavailable";
131
- // a UI service that cannot serve its bundle is not ready
132
- // (`liveness-by-capability-not-process-existence`).
133
63
  const clientBuild = await verifyClientBuildAssets();
134
64
  if (!clientBuild.ok) {
135
65
  throw new Error(
@@ -145,20 +75,12 @@ export async function buildFullApp(
145
75
  `${Object.keys(components).length} component(s)`,
146
76
  );
147
77
 
148
- // Set the real page handler so the dynamic handler in Phase 1 can delegate to it.
149
- // Client-only services (render_mode=client) have no SSR page handler — skip
150
- // (they serve via the client bundle, never SSR). Guarded so undefined is a
151
- // no-op rather than a crash.
152
78
  if (deps.createPageHandler) {
153
79
  setPageHandler(deps.createPageHandler);
154
80
  log.info(
155
81
  `[two-phase-boot:${serviceSlug}] Real page handler registered - page routes now delegate to full app`,
156
82
  );
157
83
  } else {
158
- // The full app IS built — this service simply never renders server-side,
159
- // so readiness is flipped directly rather than as a side effect of
160
- // registering an SSR handler that will never exist. Without this a
161
- // client-only service is permanently un-ready (see markFullAppReady).
162
84
  markFullAppReady();
163
85
  log.info(
164
86
  `[two-phase-boot:${serviceSlug}] Client-only mode (render_mode=client) — no SSR page handler registered`,
@@ -178,10 +100,6 @@ export async function buildFullApp(
178
100
  };
179
101
  }
180
102
 
181
- /**
182
- * Swap the minimal app for the full app. This atomically replaces the
183
- * app reference that Bun.serve is using.
184
- */
185
103
  export function swapToFullApp(fullApp: Hono, serviceSlug: string): void {
186
104
  const oldLabel = swapApp(fullApp, "full");
187
105
  const log = getAppLogger();
@@ -1,47 +1,8 @@
1
1
  /**
2
2
  * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Phase 1 of the two-phase boot: the minimal tool-only app (ORPC, data
7
- * routes, auth gates) that serves immediately (<10s); phase 2
8
- * (two-phase-full-app.ts) builds the full app in the background and swaps
9
- * it in atomically — eliminating the ~25s boot outage window.
10
4
  */
11
5
 
12
- /**
13
- * Build the minimal tool-only app. This excludes the expensive operations:
14
- * - Client asset building (Vite build)
15
- * - Boot hooks (may have expensive initialization)
16
- * - Scheduled jobs (can start later)
17
- * - pg-boss (can start later)
18
- *
19
- * CRITICAL: All route registration MUST happen here before serving, because
20
- * Hono's RegExpRouter compiles on the first request and becomes immutable.
21
- * Once compiled, no new routes can be added. So we include:
22
- * - Block discovery and page routes (registers all page paths)
23
- * - All other routes (API, WS, data, static assets)
24
- *
25
- * The minimal app includes everything needed for MCP tools and ORPC:
26
- * - Error handling, readiness gate, health check
27
- * - Security headers, CORS, compression, timing
28
- * - Content gate access, auth gates, static assets
29
- * - ORPC router, API routes, WS routes, data routes, page routes
30
- */
31
-
32
- // Fire independent I/O + compilation NOW, overlapping with the gate chain
33
- // below. These three touch NO app route and have no ordering dependency on
34
- // the gate/mount sequence, so they run concurrently with it:
35
- // - preloadServerPackages: warms Bun's module cache (CPU-bound compilation
36
- // that overlaps the gate chain's cross-internet DB I/O — same speculative
37
- // pattern as create-boot-pipeline's preloads). mountServerPackages later
38
- // hits the cache (instant imports), paying only the ~33ms of mount work.
39
- // - initGateTtls: loads config rows into module-level vars. The TTL getters
40
- // are read at REQUEST time (inside route-handler closures), not at boot,
41
- // so this only needs to finish before serving — safe to run in parallel.
42
- // - discoverBlocks: loads the page-manifest (no app mutation, no gate
43
- // dependency). Its result is consumed by registerPageRoutes much later.
44
-
45
6
  import { getAppLogger } from "@teamscala/logger/app-loggers";
46
7
  import { BootFatalError } from "@teamscala/os/errors/boot-error";
47
8
  import { createTimer } from "@teamscala/timing/create-timer";
@@ -61,7 +22,7 @@ import { attachPortalMembers } from "./attach-portal-members.ts";
61
22
  import { attachTransfersUpload } from "./attach-transfers-upload.ts";
62
23
  import { attachAuthGate } from "./auth-gate.ts";
63
24
  import { csrfProtection } from "./csrf-protection.ts";
64
- import { attachRequestLogger } from "./install-logging.ts";
25
+ import { attachRequestLogger } from "@teamscala/logger/install-logging";
65
26
  import type { ServiceInstallConfig } from "./load-service-install-row.ts";
66
27
  import { mountOrpcRouter } from "./mount-orpc-router.ts";
67
28
  import { mountServerPackages, preloadServerPackages } from "./mount-server-packages.ts";
@@ -107,13 +68,6 @@ export async function buildMinimalApp(
107
68
 
108
69
  const app = new Hono();
109
70
 
110
- // Per-step instrumentation inside the boot-ui-service phase. Same
111
- // createTimer + wall-clock convention as create-boot-pipeline's runPhase
112
- // (timing-is-the-only-timing, per-phase-instrumentation) — NOT a second
113
- // telemetry surface. The boot-ui-service phase is 60% of boot; this
114
- // breakdown localises the cost so optimisation targets the real hotspot
115
- // instead of guessing. Spans flow to GlitchTip (timing's trace-emitter is
116
- // wired by the time we reach here); the breakdown line is `pm2 logs`-visible.
117
71
  const stepTimings: Record<string, number> = {};
118
72
  async function runStep<T>(step: string, fn: () => Promise<T>): Promise<T> {
119
73
  const timer = createTimer(`${serviceSlug}:boot.minimal.${step}`);
@@ -125,7 +79,6 @@ export async function buildMinimalApp(
125
79
  }
126
80
  }
127
81
 
128
- // Error handlers
129
82
  app.onError(createRequestErrorHandler("service-boot:minimal"));
130
83
 
131
84
  app.notFound(async (c) => {
@@ -137,7 +90,6 @@ export async function buildMinimalApp(
137
90
  return c.json({ error: "Not Found" }, 404);
138
91
  });
139
92
 
140
- // Core middleware
141
93
  app.use("*", readinessGate);
142
94
  app.get("/health", healthHandler);
143
95
  app.use("*", injectSecurityHeaders);
@@ -157,19 +109,7 @@ export async function buildMinimalApp(
157
109
  );
158
110
  app.use("*", compress());
159
111
  app.use("*", timing());
160
- // CSRF (`security-csrf-protection`): state-changing form-element requests
161
- // must carry a trusted Origin — derived set (managed base domain +
162
- // builda_websites custom domains via isTrustedAuthOrigin), never an
163
- // allowlist beside better-auth's. Registered after security headers, before
164
- // every route + gate.
165
112
  app.use("*", csrfProtection());
166
- // GLOBAL body limit (`security-body-limit`): every request body is bounded
167
- // BEFORE any handler buffers it — a per-route file.size check AFTER
168
- // formData() has buffered is a report, not a limit. 30MB sits above the
169
- // 25MB transfers-upload maximum; routes wanting a smaller bound mount their
170
- // own bodyLimit BEFORE this one (Hono runs matching middleware in
171
- // registration order, and /api/rpc registers its 10MB limit in
172
- // mount-orpc-router).
173
113
  app.use(
174
114
  "*",
175
115
  bodyLimit({
@@ -186,20 +126,8 @@ export async function buildMinimalApp(
186
126
  );
187
127
  const ttlPromise = runStep("init-gate-ttls", () => initGateTtls());
188
128
 
189
- // Public access endpoints — MUST be registered before the auth gate (Hono
190
- // dispatches middleware + handlers in registration order; the content-gate
191
- // POST routes and magic-link endpoints are public and must be reachable
192
- // without a session, so they precede the auth gate's app.use("*") wildcard).
193
- // These are mutually independent — distinct paths, no shared mutable state
194
- // beyond the Hono app's synchronous route array — so they parallelise.
195
129
  await runStep("public-routes", () =>
196
130
  Promise.all([
197
- // EVERY REGISTERED GATE'S ACCESS ENDPOINT (content-gate/catalog.ts).
198
- // The same catalog the SSR gate sequence iterates, so the endpoint that
199
- // MINTS a gate's token and the gate that VERIFIES it cannot disagree
200
- // about which gates exist — this list and that one were two copies of
201
- // one fact, and a gate added to one and missed in the other is a page
202
- // that gates content with no way to unlock it.
203
131
  ...CONTENT_GATES.map((entry) =>
204
132
  attachContentGateAccess(app, serviceSlug, entry.access),
205
133
  ),
@@ -211,27 +139,14 @@ export async function buildMinimalApp(
211
139
  ]),
212
140
  );
213
141
 
214
- // Auth gate — registers /api/auth/* + the session-check wildcard middleware.
215
- // MUST follow the public routes above (load-bearing Hono registration order)
216
- // and MUST precede mountServerPackages: server-package daemon routes (e.g.
217
- // cli-session's /rpc/*) are subject to the session gate, which requires the
218
- // gate's wildcard registered first to apply. Running these concurrently would
219
- // make that ordering non-deterministic — so the gate stays serial here.
220
142
  await runStep("attach-auth-gate", () =>
221
143
  attachAuthGate(app, config, serviceSlug),
222
144
  );
223
145
  registerStaticAssets(app, serviceSlug, config.brand_org);
224
146
 
225
- // Server packages — the cold imports were preloaded above and are now in
226
- // Bun's module cache, so this pays only the mount work (~33ms, not ~2.8s of
227
- // serial compilation). Route registration happens HERE (after the auth gate),
228
- // preserving the gate-vs-daemon-route ordering.
229
147
  const mountRegistry = await runStep("mount-server-packages", () =>
230
148
  mountServerPackages(app, config, serviceSlug, getSharedDb),
231
149
  );
232
- // Ensure the speculative preload finished before proceeding (it populates
233
- // the same cache mountServerPackages just consumed; awaiting now is a no-op
234
- // if the preload already settled, and makes the ordering guarantee explicit).
235
150
  await preloadPromise;
236
151
  const apiRouteCount = registerApiRoutes(app, config, mountRegistry, serviceSlug);
237
152
 
@@ -239,17 +154,10 @@ export async function buildMinimalApp(
239
154
  await runStep("mount-orpc-router", () => mountOrpcRouter(app, serviceSlug));
240
155
  }
241
156
 
242
- // Register the in-process ORPC server client BEFORE discoverBlocks — it
243
- // calls getServerClient() which needs the client set here. Must follow
244
- // mount-orpc-router (which builds the router getAppRouter uses).
245
- // Previously ran inside register-page-routes (too late — discoverBlocks
246
- // fired before it, hitting the empty-router fallback).
247
157
  await runStep("register-page-server-client", () =>
248
158
  registerPageServerClient(serviceSlug),
249
159
  );
250
160
 
251
- // NOW load the generated manifest (getServerClient is set). Awaited below
252
- // before register-page-routes consumes the result.
253
161
  const discoverPromise = runStep("load-page-manifest", () =>
254
162
  loadPageManifest(serviceSlug),
255
163
  );
@@ -258,14 +166,8 @@ export async function buildMinimalApp(
258
166
  registerWsRoutes(app, config, mountRegistry, serviceSlug, upgradeWebSocket),
259
167
  );
260
168
 
261
- // Resolve the deferred TTL load (started at the top — should be long done,
262
- // overlapped with the gate chain + mount work). The getters are read at
263
- // request time, but the cache must be populated before the first request.
264
169
  await ttlPromise;
265
170
 
266
- // CRITICAL: Block discovery and page routes MUST be registered BEFORE serving,
267
- // because Hono's RegExpRouter compiles on first request and becomes immutable.
268
- // We defer only client asset building (expensive Vite build) to Phase 2.
269
171
  let components: Awaited<ReturnType<typeof loadPageManifest>>;
270
172
  try {
271
173
  components = await discoverPromise;
@@ -282,15 +184,10 @@ export async function buildMinimalApp(
282
184
  );
283
185
 
284
186
 
285
- // Run boot hooks BEFORE serving - some hooks may register routes and must
286
- // run before Hono's matcher is compiled by the first request
287
187
  await runStep("run-boot-hooks", () =>
288
188
  runBootHookList(config, mountRegistry, serviceSlug),
289
189
  );
290
190
 
291
- // Register page routes with a dynamic handler that delegates to the real handler once Phase 2 completes.
292
- // All route paths must be registered before serving to avoid Hono
293
- // RegExpRouter compilation issues (matcher becomes immutable).
294
191
  const registeredPageRoutes = await runStep("register-page-routes", () =>
295
192
  registerPageRoutes({
296
193
  app,
@@ -305,10 +202,6 @@ export async function buildMinimalApp(
305
202
  const steps = Object.entries(stepTimings)
306
203
  .map(([step, ms]) => `${step}=${ms}ms`)
307
204
  .join(" ");
308
- // NOTE: several steps run concurrently (speculative preload, init-gate-ttls,
309
- // discover-blocks overlap the gate chain), so the step sum exceeds the
310
- // wall-clock buildMinimalApp duration. Compare to the boot-ui-service phase
311
- // timing in the Boot complete line for the true wall-clock cost.
312
205
  log.info(
313
206
  `[two-phase-boot:${serviceSlug}] buildMinimalApp breakdown (steps overlap) — ${steps}`,
314
207
  );
@@ -328,9 +221,6 @@ export async function buildMinimalApp(
328
221
  databaseUrl,
329
222
  blockPackages,
330
223
  components,
331
- // The generated manifest IS the loaded set — every entry it exports is a
332
- // block this service renders, so there is no "skipped" half any more. A
333
- // row that cannot produce a component fails codegen, not boot.
334
224
  loaded: Object.keys(components),
335
225
  skipped: [] as string[],
336
226
  };