@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,28 +1,11 @@
1
1
  /**
2
- * @system ui-service-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Block-coverage guard (protective-layers-fail-explicitly) for registered
7
- * page routes.
8
- *
9
- * Split out of register-registry-page-route.ts (924 lines) — byte-for-byte
10
- * move; only the import paths adjusted.
11
4
  */
12
5
 
13
6
  import { getAppLogger } from "@teamscala/logger/app-loggers";
14
7
  import type { RendererComponentEntry } from "../types.ts";
15
8
 
16
- /**
17
- * A page that renders a block whose component is NOT registered — because
18
- * its package is missing from service_install.config.block_packages —
19
- * silently renders BLANK. That is the exact failure behind the 2026-06-04
20
- * scala-sign regression (sign-in-block dropped from block_packages → the
21
- * gate + signature fields vanished) which left only a benign boot WARN.
22
- * This turns that silent blank into a loud, GlitchTip-visible ERROR naming
23
- * the page + block, throttled to once per (service, block type) so a hot
24
- * path can never spam it.
25
- */
26
9
  const uncoveredBlocksLogged = new Set<string>();
27
10
 
28
11
  function collectBlockTypes(node: unknown, out: Set<string>): void {
@@ -1,23 +1,6 @@
1
1
  /**
2
- * @system ui-service-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Reads `page_route` registry rows for the current service slug and registers
7
- * Hono routes that resolve data via `resource_loader` graph-based sources.
8
- *
9
- * Every page_route uses pattern `/:id` and every resource_loader uses a
10
- * `sources[]` dependency graph. When header/footer sources are present in
11
- * the resolved graph, the output includes a `regions` array for shell rendering.
12
- * Otherwise it produces single-page puckData.
13
- *
14
- * This module is the ROUTE-REGISTRATION unit: row loading, specificity
15
- * ordering, handler binding. The per-request loadData pipeline segments live
16
- * as siblings under ./register-registry-page-route/ (graph-cache,
17
- * resolve-request-graph, stamp-session-context, run-content-gates,
18
- * apply-page-branding, resolve-panel-page, warn-uncovered-blocks,
19
- * resolve-page-data, meta-title, ai-chat-prefetch, captured-loader-config,
20
- * proposal-*) — split when the parent held them all at 924 lines.
21
4
  */
22
5
 
23
6
  import type { InferOutput } from "valibot";
@@ -139,22 +122,11 @@ export async function registerRegistryPageRoute(
139
122
  for (const routeRow of serviceRoutes) {
140
123
  const routeConfig = routeRow.config;
141
124
  const pattern = routeConfig.pattern;
142
- // loader_slug is a page_route runtime value and resource_loader is runtime
143
- // data: a row created after this build is exactly the live-edit case this
144
- // module serves, so the generated slug union (a build-time snapshot) can
145
- // only be asserted, never enforced, here. An unknown slug still fails
146
- // closed below (RegistryConfigMissingError skips the route).
147
125
  const loaderSlug = routeConfig.loader_slug as ResourceLoaderKey;
148
126
  const routeProviders = routeConfig.providers ?? baseProviders;
149
127
 
150
128
  let loaderConfig: LoaderConfig | null = null;
151
129
  try {
152
- // Boot-time warm + validate: a missing row skips the route (warn),
153
- // an invalid row fails closed. resource_loader is NOT on the
154
- // deploy-time bake list (constitution installer-and-runtime) — it
155
- // is re-read per request via readLiveLoaderConfig below — so this
156
- // boot read exists ONLY to catch a misconfigured row early, never
157
- // as the source of truth for live rendering.
158
130
  loaderConfig = await loadRegistryConfig(
159
131
  "resource_loader",
160
132
  loaderSlug,
@@ -173,22 +145,12 @@ export async function registerRegistryPageRoute(
173
145
  throw err;
174
146
  }
175
147
 
176
- // Gate for the static-shell graph cache (see module header).
177
148
  const isStaticShell =
178
149
  !routeConfig.resource_type &&
179
150
  !pattern.includes(":") &&
180
151
  !pattern.includes("*");
181
152
 
182
153
  const loadData = async (request: Request) => {
183
- // resource_loader is runtime data, not deploy-time baked. Re-read it
184
- // per request — loadRegistryConfig's SWR cache makes the fresh-window
185
- // hit a memory read, not a DB round trip. Throws into the branded
186
- // error path on a vanished or invalid row: the boot-time snapshot
187
- // must NEVER silently outlive the row it captured. This is the fix
188
- // for the loader-row-edits-look-like-they-don't-work defect (every
189
- // edit used to need a service restart; the sign-page-with-brand
190
- // `agreement` source added at 22:16 still hadn't reached
191
- // __PAGE_DATA__.loaderData at 22:29 — 13 minutes, and never).
192
154
  const liveLoaderConfig = await readLiveLoaderConfig(loaderSlug);
193
155
  const url = new URL(request.url);
194
156
  const params = extractUrlParams(pattern, url.pathname);
@@ -202,10 +164,6 @@ export async function registerRegistryPageRoute(
202
164
  params,
203
165
  isStaticShell,
204
166
  serviceSlug,
205
- // Folds the live config into the per-route graphKey so a row edit
206
- // invalidates the cached graph immediately (the graph is a
207
- // function of the config; without this the SWR graph cache
208
- // would still hold the old shape until its fresh window lapsed).
209
167
  configFingerprint: fingerprintLoaderConfig(liveLoaderConfig),
210
168
  });
211
169
  const { sessionUserId, sessionOrgId } = await stampSessionContext(
@@ -220,8 +178,6 @@ export async function registerRegistryPageRoute(
220
178
  resolved,
221
179
  gateCookie,
222
180
  Date.now(),
223
- routeConfig.assistant,
224
- regionSlots,
225
181
  );
226
182
  const meta = buildMetaFromContext(routeConfig.meta_context, resolved);
227
183
  await applySignInBrandOverride(resolved, request);
@@ -233,13 +189,6 @@ export async function registerRegistryPageRoute(
233
189
  ) {
234
190
  meta.title = pageMetaTitle.trim();
235
191
  }
236
- // Org-prefixed page titles (feature flag page-title-org-prefix, read
237
- // LIVE per request — feature flags are never cached). The title prefix
238
- // is the OWNING ORG'S name, never the internal website label
239
- // ("Public Pages: <subject>" on a client-facing document was the
240
- // defect; operator ruling 2026-08-17). Absent or unreadable flag ⇒
241
- // legacy <website.name>: <subject> derivation — the switch fails toward
242
- // the pre-flag behaviour, and flipping it back is one registry edit.
243
192
  let orgTitlePrefix = false;
244
193
  try {
245
194
  orgTitlePrefix = (
@@ -250,14 +199,8 @@ export async function registerRegistryPageRoute(
250
199
  )
251
200
  ).enabled;
252
201
  } catch {
253
- // Flag row absent — legacy derivation.
254
202
  }
255
203
  const themeParam = url.searchParams.get("theme");
256
- // ?theme is the EMBED override — only honor it when this page is
257
- // actually framed (a form/quiz in a GPT tool panel, sec-fetch-dest:
258
- // iframe). A DIRECT visit carrying ?theme= (e.g. copied from a GPT
259
- // embed URL) must NOT override the org's DB theme — what the visitor
260
- // sees is org-driven, never URL-driven.
261
204
  const isFramedEmbed =
262
205
  request.headers.get("sec-fetch-dest") === "iframe";
263
206
  const themeOverride =
@@ -274,12 +217,6 @@ export async function registerRegistryPageRoute(
274
217
  themeOverride,
275
218
  orgTitlePrefix,
276
219
  );
277
- // The matched route params (extractUrlParams + host/path) ride the
278
- // per-request pageData so the SSR resolve ctx can scope PUBLIC
279
- // block data per request — data_source loaders read ctx.pathParams
280
- // (query-data-source); without them a public table would return
281
- // every person's rows. Attached AFTER the graph cache (per-request,
282
- // never cached with the graph).
283
220
  (pageData as Record<string, unknown>).pathParams = params;
284
221
  const panelSlug = new URL(request.url).searchParams.get("panel");
285
222
  await attachPanelPage(pageData as Record<string, unknown>, panelSlug);
@@ -301,39 +238,22 @@ export async function registerRegistryPageRoute(
301
238
  await applyBrandOrgPath(
302
239
  pageData as Record<string, unknown>,
303
240
  resolved,
304
- // brand_org_path is the row schema's own field now — the derived
305
- // type sees it, so the narrowing cast the mirror needed is gone.
306
241
  routeConfig.brand_org_path,
307
242
  params,
308
243
  serviceSlug,
309
244
  );
310
245
  warnUncoveredBlocks(pageData, components, serviceSlug, params.path, log);
311
- // Detached (NOT awaited into the response). prefetchAiChatSession -> createOrRestore is DB-heavy (~1.1s), per-user, and uncached, and was
312
- // awaited on EVERY loadData — the dominant board loadMs cost (confirmed
313
- // aiChatPrefetch=1145ms = the full loadMs). Fire it in the background so
314
- // it still warms the server-side session (users who open chat find it
315
- // warm via their live fetch) without blocking the page response. The SSR
316
- // chat seed (pageData.aiChatSession) is sacrificed — chat opens with a
317
- // live fetch instead of SSR-instant — the right trade: most loads never
318
- // open chat, so eager-awaited prefetch was the waste. orgId is the
319
- // URL-derived org (resolveAiOrgId) — see prefetch-org-id.ts for why
320
- // segment 1 is preferred over segment 0.
321
246
  const aiOrgId = resolveAiOrgId(new URL(request.url).pathname);
322
247
  void prefetchAiChatSession(resolved, sessionUserId, aiOrgId).catch((err) =>
323
248
  log.warn(
324
249
  `[scala-install:${serviceSlug}] background ai-chat prefetch failed: ${err instanceof Error ? err.message : err}`,
325
250
  ),
326
251
  );
327
- // Proposal pages: resolve the {{approvalEmail}} button token to the
328
- // org's registered company email (creator email as fallback).
329
252
  if (resolved.proposal) {
330
253
  await applyApprovalEmailToken(
331
254
  pageData as Record<string, unknown>,
332
255
  resolved.proposal as { created_by?: string } | undefined,
333
256
  );
334
- // Stash the partner logo from the partnerOrg loader source (CRM record
335
- // loaded by partner_id) onto pageData.partnerLogo — header fills
336
- // automatically, no inline body-Hero prop needed.
337
257
  applyPartnerBrand(
338
258
  pageData as Record<string, unknown>,
339
259
  resolved.partnerOrg as
@@ -341,10 +261,6 @@ export async function registerRegistryPageRoute(
341
261
  | undefined
342
262
  | null,
343
263
  );
344
- // Auto-resolve the client's company logo from the clientOrg loader
345
- // source (contact → company → crm_organisations.logo_url). Only
346
- // injects when the Hero's clientLogo prop is empty — a manually set
347
- // logo always wins.
348
264
  applyClientLogo(
349
265
  pageData as Record<string, unknown>,
350
266
  resolved.clientOrg as
@@ -370,10 +286,6 @@ export async function registerRegistryPageRoute(
370
286
  providers: routeProviders,
371
287
  renderMode: routeRenderMode,
372
288
  loadData: loadData as never,
373
- // The portal home redirect follows the DECLARED portal_scoped service
374
- // fact (see RegistryRouteArgs.portalScoped) — a signed-in user at "/" of
375
- // an org-scoped portal belongs at /<websiteId>/<orgId>/dashboard; a
376
- // flat-path service (the fleet dashboard) serves its real page at "/".
377
289
  portalHomeRedirect: portalScoped,
378
290
  serviceSlug,
379
291
  });
@@ -391,10 +303,6 @@ export async function registerRegistryPageRoute(
391
303
  });
392
304
 
393
305
  registeredPatterns.push(pattern);
394
- // Boot-validated snapshot (the LIVE read is per request — see
395
- // readLiveLoaderConfig); name the shape so a misconfigured row is
396
- // visible at boot, and note the live contract so "my loader edit
397
- // didn't take effect" routes to the graph SWR window, not a restart.
398
306
  log.info(
399
307
  `[scala-install:${serviceSlug}] registered registry page route "${pattern}" → resource_loader "${loaderSlug}" (boot-validated: ${describeCapturedLoaderConfig(loaderConfig)}; re-read live per request)`,
400
308
  );
@@ -1,13 +1,6 @@
1
1
  /**
2
- * @system server-handlers
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Routes every service exposes regardless of registry config: /health
7
- * (liveness probe), /favicon.svg (default brand mark), /api/assets/*
8
- * (S3 asset proxy), and /assets/* (client bundle + stylesheet produced
9
- * by build-client-assets.ts into dist/client/, served identically in
10
- * dev and prod per scala-os/ui.md `bun-build-only`).
11
4
  */
12
5
 
13
6
  import { join } from "node:path";
@@ -21,10 +14,6 @@ import { isLoopbackHost } from "@teamscala/os/observability/loopback-host";
21
14
  import { handleAssetProxy } from "./asset-proxy";
22
15
  import { resolveBrandFaviconPath, warmBrandFaviconCache } from "./resolve-brand-favicon";
23
16
 
24
- // Served ONLY when the org has no brand favicon, so the correct colour is the
25
- // platform brand default rather than a literal. This changes the generic
26
- // fallback mark from indigo to the canonical slate; any org with its own
27
- // favicon is unaffected (resolveBrandFaviconPath wins before this is reached).
28
17
  const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="${FALLBACK_PRIMARY_COLOR}"/><text x="16" y="23" text-anchor="middle" font-size="20" font-family="system-ui" fill="white">S</text></svg>`;
29
18
 
30
19
  export async function registerStaticAssets(
@@ -32,9 +21,6 @@ export async function registerStaticAssets(
32
21
  serviceSlug: string,
33
22
  brandOrg?: string,
34
23
  ): Promise<void> {
35
- // Warm the brand-favicon cache at boot — the S3 existence probe is a 6-21s
36
- // cold call that otherwise stalls the first /favicon.ico after every
37
- // restart. Fire-and-forget; never blocks boot or a request.
38
24
  warmBrandFaviconCache(brandOrg);
39
25
 
40
26
  app.get("/health", (c) =>
@@ -45,16 +31,6 @@ export async function registerStaticAssets(
45
31
  }),
46
32
  );
47
33
 
48
- // Localhost-gated heap-attribution probe — the heapSize-vs-RSS split (JS heap
49
- // vs native: OTel/Prisma-engine/buffers/compiled code) plus the top object
50
- // types by count. Read-only, no secrets; gated to a localhost Host so it never
51
- // answers over the public domain. Registered here (before auth + page routes)
52
- // so it is reachable on every service kind, like /health
53
- // (`verify-via-127.0.0.1`, sibling of `runtime-metrics-via-railway-cli`).
54
- //
55
- // The payload shape lives in @teamscala/os so standalone `Bun.serve` daemons
56
- // (scala-guard) answer this probe identically — `scala-tools heap <slug>` must
57
- // not mean different things depending on which process replied.
58
34
  app.get("/_heap", async (c) => {
59
35
  if (!isLoopbackHost(c.req.header("host"))) return c.json({ error: "localhost only" }, 403);
60
36
  try {
@@ -68,16 +44,6 @@ export async function registerStaticAssets(
68
44
  }
69
45
  });
70
46
 
71
- // Build-id: the content hash of the CURRENT client bundle, read FRESH from the asset manifest on every call (never a process-pinned copy) so a
72
- // post-restart rebuild reports its new id immediately. The client self-heal (page-client/boot/build-self-heal) polls this on tab refocus; if it differs
73
- // from the build the tab booted with, a new build shipped and the open tab is
74
- // stale → it reloads. no-store HTML + immutable hashed assets already prevent
75
- // staleness on every FRESH load; this is what closes the one remaining hole —
76
- // a tab left OPEN across a deploy, holding old HTML pinned to old asset
77
- // hashes, with nothing telling it to re-fetch (the "my tablet still shows the
78
- // old broken page" class). The id derives from hydratePath exactly as the
79
- // SSR-stamped window.__SCALA_BUILD__ does (render-page-html), so they can
80
- // never structurally disagree and trigger a reload loop.
81
47
  app.get("/api/build-id", async (c) => {
82
48
  const path = join(process.cwd(), "dist", "client", "asset-manifest.json");
83
49
  let build = "dev";
@@ -91,10 +57,6 @@ export async function registerStaticAssets(
91
57
  return c.json({ build }, 200, { "cache-control": "no-store" });
92
58
  });
93
59
 
94
- // A branded host (service_install.config.brand_org) never serves the
95
- // generic platform mark — both favicon paths resolve the owning org's
96
- // icon from its asset store; the generic SVG remains only for hosts
97
- // with no declared owner (multi-tenant / infra).
98
60
  app.get("/favicon.svg", async (_c) => {
99
61
  const brand = await resolveBrandFaviconPath(brandOrg);
100
62
  if (brand)
@@ -112,18 +74,10 @@ export async function registerStaticAssets(
112
74
 
113
75
  app.get("/favicon.ico", async (c) => {
114
76
  const brand = await resolveBrandFaviconPath(brandOrg);
115
- // 302 (not 301): the brand asset is row-driven and must stay
116
- // re-resolvable — a permanent redirect would pin stale targets in
117
- // browser caches, the exact failure this route exists to end.
118
- // cache-control lets the browser cache the redirect (5min) so it
119
- // doesn't re-hit the origin per page load — mirrors /favicon.svg.
120
77
  c.header("cache-control", "public, max-age=300");
121
78
  return c.redirect(brand ?? "/favicon.svg", 302);
122
79
  });
123
80
 
124
- // Same-origin S3 asset proxy: /api/assets/<key> → bucket. Public,
125
- // immutable cache, no auth. Canonical URL form for every logo, favicon,
126
- // avatar, uploaded asset — emitted by toAssetPath() in @teamscala/core-ui/theme.
127
81
  app.get("/api/assets/*", (c) => handleAssetProxy(c.req.raw));
128
82
 
129
83
  app.use("/assets/*", etag());
@@ -144,12 +98,6 @@ export async function registerStaticAssets(
144
98
  return c.notFound();
145
99
  });
146
100
 
147
- // Extension auto-update files (.crx + updates.xml) served from ./ext/.
148
- // Chrome's extension updater fetches updates.xml over plain HTTPS with no
149
- // auth, so this route is registered before any auth gate and on every UI
150
- // service. Files are served with short-lived cache so updates propagate
151
- // promptly. The ./ext/ directory is gitignored and populated by the
152
- // extension build pipeline.
153
101
  app.get("/ext/*", async (c) => {
154
102
  const filePath = join(
155
103
  process.cwd(),
@@ -1,26 +1,6 @@
1
1
  /**
2
- * @system server-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Registers each `service_install.websocket_routes[]` entry as a Hono WS route.
7
- * The ORPC WS (`/api/rpc/ws`) is mounted by the canonical primitive
8
- * `mountOrpcWs` (@teamscala/orpc/ws/server) over the FULLY-COMPOSED app router —
9
- * this runs AFTER the app router composition, so the eager RPCHandler snapshots every
10
- * namespace, and any `output: eventIterator(...)` procedure dispatches (rule
11
- * `orpc-ws-via-mount-orpc-ws`). It is NOT a server_package WsRouteHandler — it
12
- * is exempt from the legacy invariant below.
13
- *
14
- * The legacy `WsRouteHandler` interface (open/message/close on a Bun
15
- * ServerWebSocket) is preserved by an adapter for already-migrated
16
- * server_packages (terminal-ws); new WS handlers should use Hono's WSEvents
17
- * directly.
18
- *
19
- * INVARIANT (legacy paths only): every handler registered via
20
- * mountCtx.registerWsRoute MUST have a matching entry in config.websocket_routes
21
- * (minus /api/rpc/ws), and vice versa. A mismatch is fatal — the service cannot
22
- * boot with handlers declared but never wired (silent 404 on upgrade), or routes
23
- * that point at handlers no server_package registered.
24
4
  */
25
5
 
26
6
  import { getAppRouter } from "@teamscala/orpc/app-router";
@@ -1,19 +1,6 @@
1
1
  /**
2
2
  * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Host-level brand favicon resolution. A single-brand UI service's host has
7
- * a KNOWN owner (`service_install.config.brand_org`) — serving the generic
8
- * platform "S" at the implicit /favicon.ico path there is a defect: browsers
9
- * and crawlers hit that path directly and their favicon caches are sticky
10
- * (operator-observed 2026-06-05 on launcha's proposal pages).
11
- *
12
- * Resolution is by convention from the org's asset store:
13
- * `<brand_org>/logos/favicon.png`. Existence is probed once through the
14
- * in-process asset proxy and cached (positive AND negative) so the favicon
15
- * route never adds S3 latency per request. No brand_org, or no asset at the
16
- * convention key → null, and the caller serves the generic fallback.
17
4
  */
18
5
 
19
6
  import { createCache } from "@teamscala/cache/create-cache";
@@ -50,15 +37,6 @@ export async function resolveBrandFaviconPath(
50
37
  });
51
38
  }
52
39
 
53
- /**
54
- * Eagerly resolve + cache the favicon at boot so the FIRST user request never
55
- * pays the S3 existence-probe latency. The probe (`handleAssetProxy` →
56
- * `s3.file(key).stat()`) is a COLD S3 call — the connection setup to the asset
57
- * store takes 6s locally / up to 21s in prod, and the in-process cache resets
58
- * on every restart (deploys, dep-sync). Without warming, the first
59
- * /favicon.ico after each restart stalls for that duration (operator-reported
60
- * 23s). Fire-and-forget — runs in the background during boot, never blocks.
61
- */
62
40
  export function warmBrandFaviconCache(brandOrg: string | undefined): void {
63
41
  if (!brandOrg) return;
64
42
  void resolveBrandFaviconPath(brandOrg);
@@ -1,63 +1,11 @@
1
1
  /**
2
- * @system service-runtime
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Decides which port a service binds, from the two sources that can claim one:
7
- * the PORT environment variable and the port the registry declares for that
8
- * service (`service_routing.local_port` for a backend, `service_install.port`
9
- * for a UI service).
10
- *
11
- * This is the resolver for BOTH kinds. It lives in service-boot rather than
12
- * service-runtime because the UI branch boots from here and may not import
13
- * upward; keeping one copy is the point, since the second copy is exactly what
14
- * went wrong (below).
15
- *
16
- * The rule, and why it is not "env always wins":
17
- *
18
- * Railway injects PORT and healthchecks that exact port, reporting the service
19
- * unavailable if it listens elsewhere. So on Railway the injected PORT is
20
- * authoritative period — a `service_install`/`service_routing` row carries the
21
- * service's LOCAL dev port (e.g. portal 5200), and on Railway binding that
22
- * instead of PORT means Railway probes a port nothing listens on and the deploy
23
- * fails healthcheck. The registry-wins rule below exists to stop LOCAL port
24
- * collisions (one host, many services); inside a Railway container there are no
25
- * sibling services to collide with, so PORT must win. `isRailway` is the caller's
26
- * assertion that it is booting inside Railway (from `RAILWAY_ENVIRONMENT`).
27
- *
28
- * Off Railway, a `service_routing`/`service_install` row means this service has
29
- * a DECLARED port on this host. There, a PORT that disagrees is a polluted launcher environment, never
30
- * an intention, and obeying it binds someone else's port. That is not a
31
- * hypothetical: mcp-ai-chat-system booted onto 3021 — session-picker's port —
32
- * while its own MCP_SERVER_URL still pointed at 8080. Both processes bound 3021,
33
- * SO_REUSEPORT load-balanced between them, and every symptom appeared somewhere
34
- * else: a chat loopback that resolved to nothing, tool-description curation
35
- * failing for every tool and disabling itself via its circuit breaker, RPC calls
36
- * landing in the wrong service, and 59 restarts. Nothing failed loudly because
37
- * nothing ever compared the two numbers.
38
- *
39
- * So the registry wins wherever it has an opinion, and the disagreement is
40
- * REPORTED rather than silently resolved — a wrong port that boots cleanly is
41
- * the expensive shape, because the damage surfaces far from the cause.
42
- *
43
- * That reasoning was written for the backend branch and wired only there, so
44
- * the UI branch kept taking `process.env.PORT` unconditionally — and on
45
- * 2026-07-31 reproduced the identical incident in the other direction:
46
- * scala-quiz (kind:ui, declared 5203) inherited a leaked PORT=3021 from the
47
- * tmux server environment and bound session-picker's port. Half of every RPC
48
- * to the session daemon was answered by a quiz service with no /rpc routes.
49
- *
50
- * A rule enforced on one of two branches is not enforced. Both branches now
51
- * call this function, and `port-resolution-is-registry-authoritative` fails
52
- * the push if either stops.
53
4
  */
54
5
 
55
6
  export interface PortResolution {
56
- /** The port to bind. */
57
7
  port: number;
58
- /** Which source decided it — for the boot log, so the choice is auditable. */
59
8
  source: "env" | "service_routing";
60
- /** Set when PORT disagreed with the row; the caller logs it. */
61
9
  conflict?: { envPort: number; declaredPort: number };
62
10
  }
63
11
 
@@ -65,14 +13,6 @@ function isBindablePort(value: number | undefined): value is number {
65
13
  return value !== undefined && Number.isFinite(value) && value > 0 && value <= 65535;
66
14
  }
67
15
 
68
- /**
69
- * @param envPort parsed PORT env, or undefined when unset/unparseable
70
- * @param declaredPort `service_routing.local_port`, or undefined when the
71
- * service has no routing row (the Railway-only case)
72
- * @param isRailway true when booting inside Railway (caller derives from
73
- * `RAILWAY_ENVIRONMENT`); there the injected PORT is authoritative
74
- * regardless of the row, because Railway healthchecks it
75
- */
76
16
  export function resolveServicePort(
77
17
  envPort: number | undefined,
78
18
  declaredPort: number | undefined,
@@ -1,32 +1,10 @@
1
1
  /**
2
- * @system ui-service-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The ONE theme-mode decider for every page render, across every UI app
7
- * (portal, proposals, sign, forms, quiz, gpt, public). Every route handler in service-boot
8
- * resolves light/dark through this single function — never its own inline
9
- * `(... === "dark" ? "dark" : "light")` chain. That inline form was duplicated
10
- * across every route branch, so flipping one app's theme required editing
11
- * every copy (the heavy lifting this removes).
12
- *
13
- * Resolution order (first match wins):
14
- * 1. ?theme=light|dark on the URL — the embed override. Set when a page is
15
- * framed inside another shell (a form in a GPT tool panel) so the framed
16
- * page matches the host's scheme. Also marks the render `embedded` so the
17
- * page can drop its own brand page-surface and render the host's clean
18
- * surface.
19
- * 2. candidates[] — page rootProps.theme, brand theme, website default, in
20
- * whatever priority the caller passes them.
21
- * 3. "light".
22
- *
23
- * Pairs with the brand/token decider (resolveBrand → computeThemeVars,
24
- * `unified-brand-theme`): that owns the SURFACE, this owns the MODE.
25
4
  */
26
5
 
27
6
  export interface ResolvedThemeMode {
28
7
  theme: "light" | "dark";
29
- /** True iff the mode came from the ?theme= embed override. */
30
8
  embedded: boolean;
31
9
  }
32
10
 
@@ -35,15 +13,8 @@ function asMode(value: unknown): "light" | "dark" | undefined {
35
13
  }
36
14
 
37
15
  export function resolveThemeMode(opts: {
38
- /** Request URL (or its href) — read for the ?theme= embed override. */
39
16
  url?: URL | string | null;
40
- /**
41
- * Explicit embed override, when the caller already extracted ?theme= from
42
- * the URL upstream (e.g. a pure data-shaper that has no Request). Same
43
- * effect and priority as the URL form.
44
- */
45
17
  override?: unknown;
46
- /** Theme candidates in priority order: rootTheme, brandTheme, websiteTheme. */
47
18
  candidates?: Array<unknown>;
48
19
  }): ResolvedThemeMode {
49
20
  let override = asMode(opts.override);
@@ -1,27 +1,8 @@
1
1
  /**
2
- * @system ui-service-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * app-header.ts — shared application header resolver_fn.
7
- *
8
- * Shapes an app's per-row resolver_input into the canonical header-data object
9
- * the ONE shared header Puck page (`/shared-app-header`) binds through the
10
- * unified page-data surface (`dataSource:"page-data"` + `fieldPath:"headerData.<key>"`).
11
- * The loader row owns the mapping (title field path, label/value pairs — the
12
- * per-app variation); this executor owns the shape and the two coercions the
13
- * rows cannot express:
14
- *
15
- * - `Date` values (Prisma returns Date objects) → ISO strings, so
16
- * __PAGE_DATA__ serialises them the same way the client reads them
17
- * (server-serializes-client-reads-verbatim).
18
- * - the literal `"today"` → a noon-UTC ISO instant of the AEST calendar date.
19
- * Noon keeps the calendar date stable under every timezone offset, so the
20
- * header shows the same day on the server and on any visitor's client —
21
- * the deterministic replacement for the old `format:"today"` clock block.
22
4
  */
23
5
 
24
- /** AEST calendar date (the operator timezone) as `YYYY-MM-DD`. */
25
6
  function aestCalendarDate(now: Date): string {
26
7
  return new Intl.DateTimeFormat("en-CA", {
27
8
  timeZone: "Australia/Sydney",
@@ -1,21 +1,11 @@
1
1
  /**
2
- * @system ui-service-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * fetch-org-brand.ts — org brand resolver with page-org-first fallback.
7
- *
8
- * Wraps @teamscala/resolver-engine's fetchOrgBrand with the page-org-first
9
- * fallback (portal-branding-resolves-page-org-first): if the page (center) org
10
- * carried no brand row, fall back to the website owning-org so pages with no
11
- * tenant org render the website's brand, not a blank. Extracted from the
12
- * hardcoded `fetchOrgBrand` arm in resolve-from-loader-config.ts.
13
4
  */
14
5
  import { fetchOrgBrand } from "@teamscala/resolver-engine/data-resolvers/fetch-org-brand";
15
6
  import { getAppLogger } from "@teamscala/logger/app-loggers";
16
7
  import type { ResolverArgs } from "./portal-route-paths.ts";
17
8
 
18
- /** A null brand renders a page with no logo, colours or name — so each failure names which of the three it was. */
19
9
  export async function fetchOrgBrandWithFallback({ input, client }: ResolverArgs): Promise<unknown> {
20
10
  const orgId = input.orgId as string | undefined;
21
11
  const fallbackOrgId = input.fallbackOrgId as string | undefined;
@@ -1,34 +1,6 @@
1
1
  /**
2
- * @system ui-service-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * portal-route-paths.ts — pure path mapper for portal region routing.
7
- *
8
- * Extracted from resolve-from-loader-config.ts (the hardcoded
9
- * `resolvePortalRoutePaths` arm) so it can be invoked via a resolver_fn
10
- * registry row instead of an if-chain. Faithful extraction — no logic change.
11
- * The request path is the center page's builda path ("/" falls back to
12
- * home_path), and the per-route toolbar page follows the flat naming convention
13
- * `/<last-segment>-toolbar`. Pages without a matching toolbar page simply
14
- * resolve an empty toolbar region (the source is declared optional).
15
- */
16
- /**
17
- * Extract the organisation id from a portal-shaped callback/path — the single
18
- * authority for "which segment is the org" so the sign-in brand resolver and the
19
- * route resolver cannot drift on the answer (portal-branding-resolves-page-org-first).
20
- *
21
- * The canonical portal shape is `/<websiteId>/<orgId>/<path>` — the org is
22
- * segment 1, NOT segment 0. Reading segment 0 fed the WEBSITE id into brand
23
- * resolution, which resolved no org and fell back to the super-admin org — the
24
- * exact cross-tenant mis-branding this exists to prevent (bind-page-handler.ts,
25
- * found 2026-07-21).
26
- *
27
- * Single-UUID hosts keep segment 0 unchanged: scala-projects routes `/<orgId>/…`
28
- * and proposal/form/quiz hosts route `/<resourceId>/…` (a non-org first segment
29
- * that resolves to no brand and falls back as before — no regression, only the
30
- * correct-org improvement for the two-UUID portal shape). Returns undefined when
31
- * neither leading segment is a UUID.
32
4
  */
33
5
 
34
6
  export interface ResolverArgs {
@@ -1,12 +1,6 @@
1
1
  /**
2
- * @system ui-service-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * website-by-host-map.ts — resolves the website + body path for a host.
7
- *
8
- * Extracted from resolve-from-loader-config.ts (the hardcoded
9
- * `resolveWebsiteByHostMap` arm). Faithful extraction — no logic change.
10
4
  */
11
5
  import type { ResolverArgs } from "./portal-route-paths.ts";
12
6