@teamscala/service-boot 0.23.524 → 0.23.526

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +1 -11
  2. package/src/agreement-gate/authorized-emails.ts +0 -7
  3. package/src/agreement-gate/placeholder-strip.ts +0 -2
  4. package/src/agreement-gate/settings.ts +0 -15
  5. package/src/agreement-gate.ts +0 -34
  6. package/src/attach-proposal-accept.ts +1 -1
  7. package/src/attach-proposal-magic.ts +1 -1
  8. package/src/auth/sign-out.ts +0 -2
  9. package/src/auth-gate/public-paths.ts +0 -86
  10. package/src/auth-gate.ts +0 -19
  11. package/src/bind-page-handler.ts +0 -76
  12. package/src/build-client-assets/hydrate-bundle.ts +0 -126
  13. package/src/build-client-assets/manifest.ts +0 -18
  14. package/src/build-client-assets/source-candidates.ts +0 -34
  15. package/src/build-client-assets/sourcemaps-upload.ts +0 -42
  16. package/src/build-client-assets/styles-build.ts +0 -50
  17. package/src/capability-slug.ts +0 -10
  18. package/src/compose-page-title.ts +0 -9
  19. package/src/content-gate/access.ts +0 -26
  20. package/src/content-gate/catalog.ts +0 -29
  21. package/src/content-gate/config-types.ts +3 -84
  22. package/src/content-gate/gate.ts +0 -20
  23. package/src/content-page-floor.ts +0 -11
  24. package/src/error-page-logo.ts +2 -12
  25. package/src/expand-roster.ts +0 -9
  26. package/src/gpt-gate.ts +1 -1
  27. package/src/hono-error-handler.ts +0 -4
  28. package/src/install-logging.ts +0 -32
  29. package/src/load-page-manifest.ts +0 -6
  30. package/src/load-service-install-row.ts +41 -4
  31. package/src/module-not-found.ts +0 -11
  32. package/src/mount-orpc-router.ts +1 -88
  33. package/src/mount-server-packages.ts +0 -29
  34. package/src/mutable-app-ref.ts +0 -11
  35. package/src/orpc-contract-rows.json +4102 -61
  36. package/src/orpc-openapi-document.ts +0 -6
  37. package/src/orpc-openapi-gate.ts +0 -10
  38. package/src/pg-boss-handler-registry.ts +0 -12
  39. package/src/pg-boss-mount.ts +0 -101
  40. package/src/proposal-gate.ts +1 -41
  41. package/src/quiz-gate.ts +1 -9
  42. package/src/register-page-server-client.ts +0 -53
  43. package/src/register-registry-page-route/ai-chat-prefetch.ts +0 -18
  44. package/src/register-registry-page-route/apply-page-branding.ts +0 -90
  45. package/src/register-registry-page-route/captured-loader-config.ts +0 -1
  46. package/src/register-registry-page-route/live-loader-config.ts +0 -16
  47. package/src/register-registry-page-route/meta-title.ts +0 -24
  48. package/src/register-registry-page-route/page-data-shared.ts +0 -6
  49. package/src/register-registry-page-route/prefetch-org-id.ts +0 -17
  50. package/src/register-registry-page-route/proposal-approval-email-token.ts +0 -11
  51. package/src/register-registry-page-route/proposal-brand-stamping.ts +0 -20
  52. package/src/register-registry-page-route/public-shell-page-data.ts +0 -26
  53. package/src/register-registry-page-route/resolve-request-graph.ts +2 -155
  54. package/src/register-registry-page-route/run-content-gates.ts +2 -54
  55. package/src/register-registry-page-route/warn-uncovered-blocks.ts +0 -10
  56. package/src/register-registry-page-route.ts +0 -85
  57. package/src/register-static-assets.ts +0 -45
  58. package/src/resolve-brand-favicon.ts +0 -9
  59. package/src/resolve-service-port.ts +0 -11
  60. package/src/resolve-theme-mode.ts +0 -8
  61. package/src/resolvers/app-header.ts +0 -1
  62. package/src/resolvers/fetch-org-brand.ts +0 -1
  63. package/src/resolvers/portal-route-paths.ts +0 -17
  64. package/src/resource-not-found.ts +0 -24
  65. package/src/select-service-routes.ts +0 -14
  66. package/src/theme-fonts-link.ts +0 -5
  67. package/src/two-phase-full-app.ts +0 -76
  68. package/src/two-phase-minimal-app.ts +1 -105
  69. package/src/types.ts +0 -14
  70. package/src/ui-service.ts +1 -42
  71. package/src/content-gate/gate-entry-marker.test.ts +0 -111
  72. package/src/content-gate/token.segmentation.test.ts +0 -76
  73. package/src/content-gate/token.test.ts +0 -65
  74. package/src/content-gate/token.ts +0 -197
  75. package/src/is-react-element-type.test.ts +0 -45
  76. package/src/is-react-element-type.ts +0 -10
  77. package/src/page-server-orpc/hub-mcp-client.ts +0 -14
  78. package/src/pg-boss-mount.test.ts +0 -101
  79. package/src/register-registry-page-route/graph-cache.test.ts +0 -81
  80. package/src/register-registry-page-route/graph-cache.ts +0 -71
  81. package/src/resource-chat-inject.page-assistant.test.ts +0 -145
  82. package/src/resource-chat-inject.test.ts +0 -240
  83. package/src/resource-chat-inject.ts +0 -357
@@ -43,7 +43,6 @@ import {
43
43
  } from "./register-registry-page-route/proposal-brand-stamping.ts";
44
44
  import type { CreatePageHandler, RendererComponentEntry } from "./types.ts";
45
45
  import {
46
- fingerprintLoaderConfig,
47
46
  readLiveLoaderConfig,
48
47
  type LoaderConfig,
49
48
  } from "./register-registry-page-route/live-loader-config.ts";
@@ -122,22 +121,11 @@ export async function registerRegistryPageRoute(
122
121
  for (const routeRow of serviceRoutes) {
123
122
  const routeConfig = routeRow.config;
124
123
  const pattern = routeConfig.pattern;
125
- // loader_slug is a page_route runtime value and resource_loader is runtime
126
- // data: a row created after this build is exactly the live-edit case this
127
- // module serves, so the generated slug union (a build-time snapshot) can
128
- // only be asserted, never enforced, here. An unknown slug still fails
129
- // closed below (RegistryConfigMissingError skips the route).
130
124
  const loaderSlug = routeConfig.loader_slug as ResourceLoaderKey;
131
125
  const routeProviders = routeConfig.providers ?? baseProviders;
132
126
 
133
127
  let loaderConfig: LoaderConfig | null = null;
134
128
  try {
135
- // Boot-time warm + validate: a missing row skips the route (warn),
136
- // an invalid row fails closed. resource_loader is NOT on the
137
- // deploy-time bake list (constitution installer-and-runtime) — it
138
- // is re-read per request via readLiveLoaderConfig below — so this
139
- // boot read exists ONLY to catch a misconfigured row early, never
140
- // as the source of truth for live rendering.
141
129
  loaderConfig = await loadRegistryConfig(
142
130
  "resource_loader",
143
131
  loaderSlug,
@@ -156,22 +144,7 @@ export async function registerRegistryPageRoute(
156
144
  throw err;
157
145
  }
158
146
 
159
- // Gate for the static-shell graph cache (see module header).
160
- const isStaticShell =
161
- !routeConfig.resource_type &&
162
- !pattern.includes(":") &&
163
- !pattern.includes("*");
164
-
165
147
  const loadData = async (request: Request) => {
166
- // resource_loader is runtime data, not deploy-time baked. Re-read it
167
- // per request — loadRegistryConfig's SWR cache makes the fresh-window
168
- // hit a memory read, not a DB round trip. Throws into the branded
169
- // error path on a vanished or invalid row: the boot-time snapshot
170
- // must NEVER silently outlive the row it captured. This is the fix
171
- // for the loader-row-edits-look-like-they-don't-work defect (every
172
- // edit used to need a service restart; the sign-page-with-brand
173
- // `agreement` source added at 22:16 still hadn't reached
174
- // __PAGE_DATA__.loaderData at 22:29 — 13 minutes, and never).
175
148
  const liveLoaderConfig = await readLiveLoaderConfig(loaderSlug);
176
149
  const url = new URL(request.url);
177
150
  const params = extractUrlParams(pattern, url.pathname);
@@ -179,17 +152,9 @@ export async function registerRegistryPageRoute(
179
152
  params.path = url.pathname || "/";
180
153
  const resolved = await resolveRequestGraph({
181
154
  pattern,
182
- routeSlug: routeRow.slug,
183
155
  routeConfig,
184
156
  loaderConfig: liveLoaderConfig as never,
185
157
  params,
186
- isStaticShell,
187
- serviceSlug,
188
- // Folds the live config into the per-route graphKey so a row edit
189
- // invalidates the cached graph immediately (the graph is a
190
- // function of the config; without this the SWR graph cache
191
- // would still hold the old shape until its fresh window lapsed).
192
- configFingerprint: fingerprintLoaderConfig(liveLoaderConfig),
193
158
  });
194
159
  const { sessionUserId, sessionOrgId } = await stampSessionContext(
195
160
  resolved,
@@ -203,8 +168,6 @@ export async function registerRegistryPageRoute(
203
168
  resolved,
204
169
  gateCookie,
205
170
  Date.now(),
206
- routeConfig.assistant,
207
- regionSlots,
208
171
  );
209
172
  const meta = buildMetaFromContext(routeConfig.meta_context, resolved);
210
173
  await applySignInBrandOverride(resolved, request);
@@ -216,13 +179,6 @@ export async function registerRegistryPageRoute(
216
179
  ) {
217
180
  meta.title = pageMetaTitle.trim();
218
181
  }
219
- // Org-prefixed page titles (feature flag page-title-org-prefix, read
220
- // LIVE per request — feature flags are never cached). The title prefix
221
- // is the OWNING ORG'S name, never the internal website label
222
- // ("Public Pages: <subject>" on a client-facing document was the
223
- // defect; operator ruling 2026-08-17). Absent or unreadable flag ⇒
224
- // legacy <website.name>: <subject> derivation — the switch fails toward
225
- // the pre-flag behaviour, and flipping it back is one registry edit.
226
182
  let orgTitlePrefix = false;
227
183
  try {
228
184
  orgTitlePrefix = (
@@ -233,14 +189,8 @@ export async function registerRegistryPageRoute(
233
189
  )
234
190
  ).enabled;
235
191
  } catch {
236
- // Flag row absent — legacy derivation.
237
192
  }
238
193
  const themeParam = url.searchParams.get("theme");
239
- // ?theme is the EMBED override — only honor it when this page is
240
- // actually framed (a form/quiz in a GPT tool panel, sec-fetch-dest:
241
- // iframe). A DIRECT visit carrying ?theme= (e.g. copied from a GPT
242
- // embed URL) must NOT override the org's DB theme — what the visitor
243
- // sees is org-driven, never URL-driven.
244
194
  const isFramedEmbed =
245
195
  request.headers.get("sec-fetch-dest") === "iframe";
246
196
  const themeOverride =
@@ -257,12 +207,6 @@ export async function registerRegistryPageRoute(
257
207
  themeOverride,
258
208
  orgTitlePrefix,
259
209
  );
260
- // The matched route params (extractUrlParams + host/path) ride the
261
- // per-request pageData so the SSR resolve ctx can scope PUBLIC
262
- // block data per request — data_source loaders read ctx.pathParams
263
- // (query-data-source); without them a public table would return
264
- // every person's rows. Attached AFTER the graph cache (per-request,
265
- // never cached with the graph).
266
210
  (pageData as Record<string, unknown>).pathParams = params;
267
211
  const panelSlug = new URL(request.url).searchParams.get("panel");
268
212
  await attachPanelPage(pageData as Record<string, unknown>, panelSlug);
@@ -284,39 +228,22 @@ export async function registerRegistryPageRoute(
284
228
  await applyBrandOrgPath(
285
229
  pageData as Record<string, unknown>,
286
230
  resolved,
287
- // brand_org_path is the row schema's own field now — the derived
288
- // type sees it, so the narrowing cast the mirror needed is gone.
289
231
  routeConfig.brand_org_path,
290
232
  params,
291
233
  serviceSlug,
292
234
  );
293
235
  warnUncoveredBlocks(pageData, components, serviceSlug, params.path, log);
294
- // Detached (NOT awaited into the response). prefetchAiChatSession -> createOrRestore is DB-heavy (~1.1s), per-user, and uncached, and was
295
- // awaited on EVERY loadData — the dominant board loadMs cost (confirmed
296
- // aiChatPrefetch=1145ms = the full loadMs). Fire it in the background so
297
- // it still warms the server-side session (users who open chat find it
298
- // warm via their live fetch) without blocking the page response. The SSR
299
- // chat seed (pageData.aiChatSession) is sacrificed — chat opens with a
300
- // live fetch instead of SSR-instant — the right trade: most loads never
301
- // open chat, so eager-awaited prefetch was the waste. orgId is the
302
- // URL-derived org (resolveAiOrgId) — see prefetch-org-id.ts for why
303
- // segment 1 is preferred over segment 0.
304
236
  const aiOrgId = resolveAiOrgId(new URL(request.url).pathname);
305
237
  void prefetchAiChatSession(resolved, sessionUserId, aiOrgId).catch((err) =>
306
238
  log.warn(
307
239
  `[scala-install:${serviceSlug}] background ai-chat prefetch failed: ${err instanceof Error ? err.message : err}`,
308
240
  ),
309
241
  );
310
- // Proposal pages: resolve the {{approvalEmail}} button token to the
311
- // org's registered company email (creator email as fallback).
312
242
  if (resolved.proposal) {
313
243
  await applyApprovalEmailToken(
314
244
  pageData as Record<string, unknown>,
315
245
  resolved.proposal as { created_by?: string } | undefined,
316
246
  );
317
- // Stash the partner logo from the partnerOrg loader source (CRM record
318
- // loaded by partner_id) onto pageData.partnerLogo — header fills
319
- // automatically, no inline body-Hero prop needed.
320
247
  applyPartnerBrand(
321
248
  pageData as Record<string, unknown>,
322
249
  resolved.partnerOrg as
@@ -324,10 +251,6 @@ export async function registerRegistryPageRoute(
324
251
  | undefined
325
252
  | null,
326
253
  );
327
- // Auto-resolve the client's company logo from the clientOrg loader
328
- // source (contact → company → crm_organisations.logo_url). Only
329
- // injects when the Hero's clientLogo prop is empty — a manually set
330
- // logo always wins.
331
254
  applyClientLogo(
332
255
  pageData as Record<string, unknown>,
333
256
  resolved.clientOrg as
@@ -353,10 +276,6 @@ export async function registerRegistryPageRoute(
353
276
  providers: routeProviders,
354
277
  renderMode: routeRenderMode,
355
278
  loadData: loadData as never,
356
- // The portal home redirect follows the DECLARED portal_scoped service
357
- // fact (see RegistryRouteArgs.portalScoped) — a signed-in user at "/" of
358
- // an org-scoped portal belongs at /<websiteId>/<orgId>/dashboard; a
359
- // flat-path service (the fleet dashboard) serves its real page at "/".
360
279
  portalHomeRedirect: portalScoped,
361
280
  serviceSlug,
362
281
  });
@@ -374,10 +293,6 @@ export async function registerRegistryPageRoute(
374
293
  });
375
294
 
376
295
  registeredPatterns.push(pattern);
377
- // Boot-validated snapshot (the LIVE read is per request — see
378
- // readLiveLoaderConfig); name the shape so a misconfigured row is
379
- // visible at boot, and note the live contract so "my loader edit
380
- // didn't take effect" routes to the graph SWR window, not a restart.
381
296
  log.info(
382
297
  `[scala-install:${serviceSlug}] registered registry page route "${pattern}" → resource_loader "${loaderSlug}" (boot-validated: ${describeCapturedLoaderConfig(loaderConfig)}; re-read live per request)`,
383
298
  );
@@ -14,10 +14,6 @@ import { isLoopbackHost } from "@teamscala/os/observability/loopback-host";
14
14
  import { handleAssetProxy } from "./asset-proxy";
15
15
  import { resolveBrandFaviconPath, warmBrandFaviconCache } from "./resolve-brand-favicon";
16
16
 
17
- // Served ONLY when the org has no brand favicon, so the correct colour is the
18
- // platform brand default rather than a literal. This changes the generic
19
- // fallback mark from indigo to the canonical slate; any org with its own
20
- // favicon is unaffected (resolveBrandFaviconPath wins before this is reached).
21
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>`;
22
18
 
23
19
  export async function registerStaticAssets(
@@ -25,9 +21,6 @@ export async function registerStaticAssets(
25
21
  serviceSlug: string,
26
22
  brandOrg?: string,
27
23
  ): Promise<void> {
28
- // Warm the brand-favicon cache at boot — the S3 existence probe is a 6-21s
29
- // cold call that otherwise stalls the first /favicon.ico after every
30
- // restart. Fire-and-forget; never blocks boot or a request.
31
24
  warmBrandFaviconCache(brandOrg);
32
25
 
33
26
  app.get("/health", (c) =>
@@ -38,16 +31,6 @@ export async function registerStaticAssets(
38
31
  }),
39
32
  );
40
33
 
41
- // Localhost-gated heap-attribution probe — the heapSize-vs-RSS split (JS heap
42
- // vs native: OTel/Prisma-engine/buffers/compiled code) plus the top object
43
- // types by count. Read-only, no secrets; gated to a localhost Host so it never
44
- // answers over the public domain. Registered here (before auth + page routes)
45
- // so it is reachable on every service kind, like /health
46
- // (`verify-via-127.0.0.1`, sibling of `runtime-metrics-via-railway-cli`).
47
- //
48
- // The payload shape lives in @teamscala/os so standalone `Bun.serve` daemons
49
- // (scala-guard) answer this probe identically — `scala-tools heap <slug>` must
50
- // not mean different things depending on which process replied.
51
34
  app.get("/_heap", async (c) => {
52
35
  if (!isLoopbackHost(c.req.header("host"))) return c.json({ error: "localhost only" }, 403);
53
36
  try {
@@ -61,16 +44,6 @@ export async function registerStaticAssets(
61
44
  }
62
45
  });
63
46
 
64
- // 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
65
- // 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
66
- // from the build the tab booted with, a new build shipped and the open tab is
67
- // stale → it reloads. no-store HTML + immutable hashed assets already prevent
68
- // staleness on every FRESH load; this is what closes the one remaining hole —
69
- // a tab left OPEN across a deploy, holding old HTML pinned to old asset
70
- // hashes, with nothing telling it to re-fetch (the "my tablet still shows the
71
- // old broken page" class). The id derives from hydratePath exactly as the
72
- // SSR-stamped window.__SCALA_BUILD__ does (render-page-html), so they can
73
- // never structurally disagree and trigger a reload loop.
74
47
  app.get("/api/build-id", async (c) => {
75
48
  const path = join(process.cwd(), "dist", "client", "asset-manifest.json");
76
49
  let build = "dev";
@@ -84,10 +57,6 @@ export async function registerStaticAssets(
84
57
  return c.json({ build }, 200, { "cache-control": "no-store" });
85
58
  });
86
59
 
87
- // A branded host (service_install.config.brand_org) never serves the
88
- // generic platform mark — both favicon paths resolve the owning org's
89
- // icon from its asset store; the generic SVG remains only for hosts
90
- // with no declared owner (multi-tenant / infra).
91
60
  app.get("/favicon.svg", async (_c) => {
92
61
  const brand = await resolveBrandFaviconPath(brandOrg);
93
62
  if (brand)
@@ -105,18 +74,10 @@ export async function registerStaticAssets(
105
74
 
106
75
  app.get("/favicon.ico", async (c) => {
107
76
  const brand = await resolveBrandFaviconPath(brandOrg);
108
- // 302 (not 301): the brand asset is row-driven and must stay
109
- // re-resolvable — a permanent redirect would pin stale targets in
110
- // browser caches, the exact failure this route exists to end.
111
- // cache-control lets the browser cache the redirect (5min) so it
112
- // doesn't re-hit the origin per page load — mirrors /favicon.svg.
113
77
  c.header("cache-control", "public, max-age=300");
114
78
  return c.redirect(brand ?? "/favicon.svg", 302);
115
79
  });
116
80
 
117
- // Same-origin S3 asset proxy: /api/assets/<key> → bucket. Public,
118
- // immutable cache, no auth. Canonical URL form for every logo, favicon,
119
- // avatar, uploaded asset — emitted by toAssetPath() in @teamscala/core-ui/theme.
120
81
  app.get("/api/assets/*", (c) => handleAssetProxy(c.req.raw));
121
82
 
122
83
  app.use("/assets/*", etag());
@@ -137,12 +98,6 @@ export async function registerStaticAssets(
137
98
  return c.notFound();
138
99
  });
139
100
 
140
- // Extension auto-update files (.crx + updates.xml) served from ./ext/.
141
- // Chrome's extension updater fetches updates.xml over plain HTTPS with no
142
- // auth, so this route is registered before any auth gate and on every UI
143
- // service. Files are served with short-lived cache so updates propagate
144
- // promptly. The ./ext/ directory is gitignored and populated by the
145
- // extension build pipeline.
146
101
  app.get("/ext/*", async (c) => {
147
102
  const filePath = join(
148
103
  process.cwd(),
@@ -37,15 +37,6 @@ export async function resolveBrandFaviconPath(
37
37
  });
38
38
  }
39
39
 
40
- /**
41
- * Eagerly resolve + cache the favicon at boot so the FIRST user request never
42
- * pays the S3 existence-probe latency. The probe (`handleAssetProxy` →
43
- * `s3.file(key).stat()`) is a COLD S3 call — the connection setup to the asset
44
- * store takes 6s locally / up to 21s in prod, and the in-process cache resets
45
- * on every restart (deploys, dep-sync). Without warming, the first
46
- * /favicon.ico after each restart stalls for that duration (operator-reported
47
- * 23s). Fire-and-forget — runs in the background during boot, never blocks.
48
- */
49
40
  export function warmBrandFaviconCache(brandOrg: string | undefined): void {
50
41
  if (!brandOrg) return;
51
42
  void resolveBrandFaviconPath(brandOrg);
@@ -4,11 +4,8 @@
4
4
  */
5
5
 
6
6
  export interface PortResolution {
7
- /** The port to bind. */
8
7
  port: number;
9
- /** Which source decided it — for the boot log, so the choice is auditable. */
10
8
  source: "env" | "service_routing";
11
- /** Set when PORT disagreed with the row; the caller logs it. */
12
9
  conflict?: { envPort: number; declaredPort: number };
13
10
  }
14
11
 
@@ -16,14 +13,6 @@ function isBindablePort(value: number | undefined): value is number {
16
13
  return value !== undefined && Number.isFinite(value) && value > 0 && value <= 65535;
17
14
  }
18
15
 
19
- /**
20
- * @param envPort parsed PORT env, or undefined when unset/unparseable
21
- * @param declaredPort `service_routing.local_port`, or undefined when the
22
- * service has no routing row (the Railway-only case)
23
- * @param isRailway true when booting inside Railway (caller derives from
24
- * `RAILWAY_ENVIRONMENT`); there the injected PORT is authoritative
25
- * regardless of the row, because Railway healthchecks it
26
- */
27
16
  export function resolveServicePort(
28
17
  envPort: number | undefined,
29
18
  declaredPort: number | undefined,
@@ -5,7 +5,6 @@
5
5
 
6
6
  export interface ResolvedThemeMode {
7
7
  theme: "light" | "dark";
8
- /** True iff the mode came from the ?theme= embed override. */
9
8
  embedded: boolean;
10
9
  }
11
10
 
@@ -14,15 +13,8 @@ function asMode(value: unknown): "light" | "dark" | undefined {
14
13
  }
15
14
 
16
15
  export function resolveThemeMode(opts: {
17
- /** Request URL (or its href) — read for the ?theme= embed override. */
18
16
  url?: URL | string | null;
19
- /**
20
- * Explicit embed override, when the caller already extracted ?theme= from
21
- * the URL upstream (e.g. a pure data-shaper that has no Request). Same
22
- * effect and priority as the URL form.
23
- */
24
17
  override?: unknown;
25
- /** Theme candidates in priority order: rootTheme, brandTheme, websiteTheme. */
26
18
  candidates?: Array<unknown>;
27
19
  }): ResolvedThemeMode {
28
20
  let override = asMode(opts.override);
@@ -3,7 +3,6 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
- /** AEST calendar date (the operator timezone) as `YYYY-MM-DD`. */
7
6
  function aestCalendarDate(now: Date): string {
8
7
  return new Intl.DateTimeFormat("en-CA", {
9
8
  timeZone: "Australia/Sydney",
@@ -6,7 +6,6 @@ import { fetchOrgBrand } from "@teamscala/resolver-engine/data-resolvers/fetch-o
6
6
  import { getAppLogger } from "@teamscala/logger/app-loggers";
7
7
  import type { ResolverArgs } from "./portal-route-paths.ts";
8
8
 
9
- /** A null brand renders a page with no logo, colours or name — so each failure names which of the three it was. */
10
9
  export async function fetchOrgBrandWithFallback({ input, client }: ResolverArgs): Promise<unknown> {
11
10
  const orgId = input.orgId as string | undefined;
12
11
  const fallbackOrgId = input.fallbackOrgId as string | undefined;
@@ -2,23 +2,6 @@
2
2
  * @system service-boot
3
3
  * @status handwritten
4
4
  */
5
- /**
6
- * Extract the organisation id from a portal-shaped callback/path — the single
7
- * authority for "which segment is the org" so the sign-in brand resolver and the
8
- * route resolver cannot drift on the answer (portal-branding-resolves-page-org-first).
9
- *
10
- * The canonical portal shape is `/<websiteId>/<orgId>/<path>` — the org is
11
- * segment 1, NOT segment 0. Reading segment 0 fed the WEBSITE id into brand
12
- * resolution, which resolved no org and fell back to the super-admin org — the
13
- * exact cross-tenant mis-branding this exists to prevent (bind-page-handler.ts,
14
- * found 2026-07-21).
15
- *
16
- * Single-UUID hosts keep segment 0 unchanged: scala-projects routes `/<orgId>/…`
17
- * and proposal/form/quiz hosts route `/<resourceId>/…` (a non-org first segment
18
- * that resolves to no brand and falls back as before — no regression, only the
19
- * correct-org improvement for the two-UUID portal shape). Returns undefined when
20
- * neither leading segment is a UUID.
21
- */
22
5
 
23
6
  export interface ResolverArgs {
24
7
  input: Record<string, unknown>;
@@ -15,16 +15,7 @@ const RESOURCE_LABELS: Record<string, string> = {
15
15
 
16
16
  export class PrimaryResourceMissingError extends Error {
17
17
  readonly resourceType: string;
18
- // HTTP status for the holding page. 404 for a genuinely missing/invalid
19
- // resource (a dead /:id link); 200 for the bare root of a UUID-driven site
20
- // ("no <resource> selected") — the root is a valid landing, not an error,
21
- // and kind:ui services must answer GET / with 200 (verify-prod-persistent-slot).
22
18
  readonly httpStatus: number;
23
- // A page ROW exists but has zero content blocks — a content gap, not a dead
24
- // link (operator ruling 2026-07-21: "if the page is empty, it should have a
25
- // Puck block that says this page is empty... and then take me home"). The
26
- // floor renders the "This page is empty" surface (200) instead of the dead-
27
- // link "doesn't exist" surface (404). Distinct from a dead link (no row).
28
19
  readonly empty: boolean;
29
20
  constructor(resourceType: string, httpStatus = 404, opts?: { empty?: boolean }) {
30
21
  super(`primary resource "${resourceType}" not found`);
@@ -39,23 +30,10 @@ export function buildResourceNotFoundResponse(
39
30
  resourceType: string,
40
31
  logoUrl?: string | null,
41
32
  status = 404,
42
- // Optional 4th arg keeps the existing 3-arg call sites working while letting
43
- // the page binder add a "Take me home" action (operator-requested) and the
44
- // empty-page variant.
45
33
  opts?: { homeUrl?: string; empty?: boolean },
46
34
  ): Response {
47
35
  const label = RESOURCE_LABELS[resourceType] ?? "page";
48
- // Single-owner hosts show the owner's wordmark; multi-tenant hosts
49
- // (no brand_org — a dead id's org is unknowable) stay platform-neutral.
50
- // The helper emits the inline data-URI src with reserved width+height
51
- // (logos-inline-on-branded-server-pages).
52
36
  const logoImg = brandLogoImgTag(logoUrl, { height: 40, width: 160 });
53
- // Three surfaces share this one floor (resource-not-found-is-the-root-floor):
54
- // 200 root — the BARE ROOT of a UUID-driven host ("no <resource> selected").
55
- // 404 dead — a content path with NO page row (a deleted/moved page, e.g.
56
- // /roadmap) OR a dead /:id: a link that genuinely doesn't resolve.
57
- // 200 empty — a page ROW exists but has zero blocks (a content gap, not a
58
- // dead link): "This page is empty", still take-me-home.
59
37
  const isEmpty = opts?.empty === true;
60
38
  const isRoot = status === 200 && !isEmpty;
61
39
  const title = isEmpty ? "Nothing here yet" : isRoot ? `No ${label} here` : "Not found";
@@ -71,8 +49,6 @@ export function buildResourceNotFoundResponse(
71
49
  sent to view it — there&rsquo;s nothing to see at the bare address.`
72
50
  : `The page you&rsquo;re looking for may have been moved or removed, or the
73
51
  link may be incomplete.`;
74
- // "Take me home" — operator-requested action on every not-found/empty surface.
75
- // Omitted on the bare-root landing (it IS home). Defaults to the host root.
76
52
  const homeUrl = opts?.homeUrl ?? "/";
77
53
  const homeLink = isRoot
78
54
  ? ""
@@ -5,17 +5,10 @@
5
5
 
6
6
  import { toCapabilitySlug } from "./capability-slug.ts";
7
7
 
8
- /** The shape this module needs from a loaded page_route row. */
9
8
  export interface SelectableRoute {
10
9
  config: { pattern: string; service: string };
11
10
  }
12
11
 
13
- /**
14
- * Specificity rank for registration order. Hono matches by registration
15
- * order, so `/*` (the portal catchall) registered before `/sign-in` would
16
- * swallow the sign-in page and render it inside the PortalTemplate instead
17
- * of the standalone PublicTemplate shell.
18
- */
19
12
  function comparePatternSpecificity(a: string, b: string): number {
20
13
  // Wildcard `/*` goes last.
21
14
  if (a === "/*") return 1;
@@ -28,13 +21,6 @@ function comparePatternSpecificity(a: string, b: string): number {
28
21
  return 0;
29
22
  }
30
23
 
31
- /**
32
- * The page_route rows this service serves, in registration order.
33
- *
34
- * `serviceSlug` is the deployable identity of the calling lane; both sides
35
- * of the join normalize through `toCapabilitySlug`, so a row may name either
36
- * the capability or a deployable form and both lanes resolve the same set.
37
- */
38
24
  export function selectServiceRoutes<T extends SelectableRoute>(
39
25
  allRoutes: readonly T[],
40
26
  serviceSlug: string,
@@ -9,15 +9,11 @@ interface ThemeFontsConfig {
9
9
  fonts?: Record<string, string>;
10
10
  }
11
11
 
12
- /** The primary family name of a CSS font stack: "Inter, sans-serif" →
13
- * "Inter"; "Playfair Display" → "Playfair Display". */
14
12
  export function primaryFontFamily(stack: string): string {
15
13
  const first = stack.split(",")[0] ?? "";
16
14
  return first.trim().replace(/^['"]|['"]$/g, "");
17
15
  }
18
16
 
19
- /** The declared theme families, deduplicated by primary family, in row
20
- * order (body, heading, manuscript, mono…). Empty when the row is absent. */
21
17
  export function themeFontFamilies(): string[] {
22
18
  try {
23
19
  const config = getEntryConfig<ThemeFontsConfig>("css_theme", "default");
@@ -37,7 +33,6 @@ export function themeFontFamilies(): string[] {
37
33
  }
38
34
  }
39
35
 
40
- /** The <link> tag loading every declared theme family, or "" when none. */
41
36
  export function themeFontsLink(): string {
42
37
  const families = themeFontFamilies();
43
38
  if (families.length === 0) return "";
@@ -3,52 +3,8 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
- /**
7
- * Build the full app with hydration. This adds the expensive operations that
8
- * were deferred from the minimal app:
9
- * - Client asset building (Vite build)
10
- * - Boot hooks (may have expensive initialization)
11
- * - Scheduled jobs (can start later)
12
- * - pg-boss (can start later)
13
- *
14
- * This runs in the background after the minimal app is already serving.
15
- * NOTE: All routes (including page routes) are already registered in Phase 1,
16
- * so we don't need to modify the app here. The app reference stays the same.
17
- */
18
6
 
19
- // Client asset building (expensive: Vite build).
20
- //
21
- // ISOLATED, because a PRESENTATION failure must not take out BACKGROUND WORK.
22
- // This was awaited unguarded, so a throw here aborted buildFullApp before
23
- // mountPgBoss ran, and the caller's catch logged
24
- // "Failed to build full app, continuing with minimal app" — which reads as a
25
- // UI-only degradation while having silently killed every pg-boss handler on
26
- // the service.
27
- //
28
- // Measured 2026-07-30: a stale @teamscala/portal-ui artifact still importing
29
- // the retired @teamgbg scope threw ENOENT on a missing theme CSS file in
30
- // scala-agents. Zero "scheduledJobs DONE" lines appeared for ~20 minutes
31
- // across repeated boots, the pane-outbox drain never mounted, and the
32
- // operator's WhatsApp assistant went silent — because of a stylesheet. The
33
- // service stayed `online` and healthy throughout, so nothing surfaced it.
34
- //
35
- // The steps after this one do not depend on client assets: pg-boss needs the
36
- // registry and the DB, not a bundle. Ordering it behind a Vite build was
37
- // incidental, so the coupling was never intended.
38
7
 
39
- // pg-boss (mount for UI services - wasn't mounted in minimal app)
40
- //
41
- // Wrapped for the SAME reason buildClientAssets above is, in the other
42
- // direction: a background-work failure must not cost the page. Unwrapped,
43
- // anything mountPgBoss throws propagates out of the full-app build, the
44
- // caller falls back to the MINIMAL app, and the service serves 0 api routes
45
- // with /health 503 — so the tunnel health check fails and the site is
46
- // unreachable from outside while localhost answers in 0.1s.
47
- //
48
- // Measured 2026-08-31 on scala-projects: a deps refresh moved pg-boss to a
49
- // build expecting a newer shared pgboss schema than the database carried, its
50
- // migration threw, and an outage of every page followed from a scheduled-job
51
- // dependency. Jobs are the degradable half; pages are not.
52
8
 
53
9
  import { getAppLogger } from "@teamscala/logger/app-loggers";
54
10
  import type { Hono } from "hono";
@@ -78,9 +34,6 @@ export async function buildFullApp(
78
34
  await buildClientAssets(serviceSlug);
79
35
  log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets DONE`);
80
36
  } catch (error) {
81
- // Loud but non-fatal: the tombstone manifest written by buildClientAssets
82
- // already makes SSR serve the prior bundle, so degrading here costs the
83
- // new bundle, never the background work.
84
37
  log.error(
85
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)`,
86
39
  { error: error instanceof Error ? error.message : String(error) },
@@ -107,23 +60,6 @@ export async function buildFullApp(
107
60
  );
108
61
  }
109
62
 
110
- // The readiness flip is gated on a VERIFIED client build, never on "phase 2
111
- // got this far". buildClientAssets's failure above is deliberately non-fatal
112
- // for background work — but a deployment whose client build never ran (or
113
- // whose tombstone names unbuilt files) must not flip ready and serve pages:
114
- // every served asset URL would 404 while /health and the deploy report ok.
115
- // Measured 2026-09-01: proposals.scala.business served a client-facing
116
- // proposal unstyled while Railway reported SUCCESS, because the phase-1
117
- // catch let boot continue past the build and the flip never checked the
118
- // build's outcome (`a-component-may-not-report-a-state-it-has-not-verified`).
119
- // The throw lands in ui-service's boot.client-build-failed catch, which
120
- // keeps the minimal app: pages and /health stay 503 (cleanly unavailable)
121
- // while the pg-boss jobs mounted above KEEP RUNNING. On Railway the
122
- // healthcheck (healthcheckPath: /health) then fails and the previous good
123
- // deployment keeps traffic — the deploy reports FAILED, never SUCCESS.
124
- // readiness-gated-boot: a service is "fully-ready or cleanly-unavailable";
125
- // a UI service that cannot serve its bundle is not ready
126
- // (`liveness-by-capability-not-process-existence`).
127
63
  const clientBuild = await verifyClientBuildAssets();
128
64
  if (!clientBuild.ok) {
129
65
  throw new Error(
@@ -139,20 +75,12 @@ export async function buildFullApp(
139
75
  `${Object.keys(components).length} component(s)`,
140
76
  );
141
77
 
142
- // Set the real page handler so the dynamic handler in Phase 1 can delegate to it.
143
- // Client-only services (render_mode=client) have no SSR page handler — skip
144
- // (they serve via the client bundle, never SSR). Guarded so undefined is a
145
- // no-op rather than a crash.
146
78
  if (deps.createPageHandler) {
147
79
  setPageHandler(deps.createPageHandler);
148
80
  log.info(
149
81
  `[two-phase-boot:${serviceSlug}] Real page handler registered - page routes now delegate to full app`,
150
82
  );
151
83
  } else {
152
- // The full app IS built — this service simply never renders server-side,
153
- // so readiness is flipped directly rather than as a side effect of
154
- // registering an SSR handler that will never exist. Without this a
155
- // client-only service is permanently un-ready (see markFullAppReady).
156
84
  markFullAppReady();
157
85
  log.info(
158
86
  `[two-phase-boot:${serviceSlug}] Client-only mode (render_mode=client) — no SSR page handler registered`,
@@ -172,10 +100,6 @@ export async function buildFullApp(
172
100
  };
173
101
  }
174
102
 
175
- /**
176
- * Swap the minimal app for the full app. This atomically replaces the
177
- * app reference that Bun.serve is using.
178
- */
179
103
  export function swapToFullApp(fullApp: Hono, serviceSlug: string): void {
180
104
  const oldLabel = swapApp(fullApp, "full");
181
105
  const log = getAppLogger();