@teamscala/service-boot 0.23.524 → 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 (81) hide show
  1. package/package.json +1 -9
  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 +289 -0
  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/graph-cache.ts +0 -17
  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 +0 -57
  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 -75
  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/resource-chat-inject.page-assistant.test.ts +0 -145
  80. package/src/resource-chat-inject.test.ts +0 -240
  81. package/src/resource-chat-inject.ts +0 -357
@@ -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();
@@ -3,39 +3,6 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
- /**
7
- * Build the minimal tool-only app. This excludes the expensive operations:
8
- * - Client asset building (Vite build)
9
- * - Boot hooks (may have expensive initialization)
10
- * - Scheduled jobs (can start later)
11
- * - pg-boss (can start later)
12
- *
13
- * CRITICAL: All route registration MUST happen here before serving, because
14
- * Hono's RegExpRouter compiles on the first request and becomes immutable.
15
- * Once compiled, no new routes can be added. So we include:
16
- * - Block discovery and page routes (registers all page paths)
17
- * - All other routes (API, WS, data, static assets)
18
- *
19
- * The minimal app includes everything needed for MCP tools and ORPC:
20
- * - Error handling, readiness gate, health check
21
- * - Security headers, CORS, compression, timing
22
- * - Content gate access, auth gates, static assets
23
- * - ORPC router, API routes, WS routes, data routes, page routes
24
- */
25
-
26
- // Fire independent I/O + compilation NOW, overlapping with the gate chain
27
- // below. These three touch NO app route and have no ordering dependency on
28
- // the gate/mount sequence, so they run concurrently with it:
29
- // - preloadServerPackages: warms Bun's module cache (CPU-bound compilation
30
- // that overlaps the gate chain's cross-internet DB I/O — same speculative
31
- // pattern as create-boot-pipeline's preloads). mountServerPackages later
32
- // hits the cache (instant imports), paying only the ~33ms of mount work.
33
- // - initGateTtls: loads config rows into module-level vars. The TTL getters
34
- // are read at REQUEST time (inside route-handler closures), not at boot,
35
- // so this only needs to finish before serving — safe to run in parallel.
36
- // - discoverBlocks: loads the page-manifest (no app mutation, no gate
37
- // dependency). Its result is consumed by registerPageRoutes much later.
38
-
39
6
  import { getAppLogger } from "@teamscala/logger/app-loggers";
40
7
  import { BootFatalError } from "@teamscala/os/errors/boot-error";
41
8
  import { createTimer } from "@teamscala/timing/create-timer";
@@ -55,7 +22,7 @@ import { attachPortalMembers } from "./attach-portal-members.ts";
55
22
  import { attachTransfersUpload } from "./attach-transfers-upload.ts";
56
23
  import { attachAuthGate } from "./auth-gate.ts";
57
24
  import { csrfProtection } from "./csrf-protection.ts";
58
- import { attachRequestLogger } from "./install-logging.ts";
25
+ import { attachRequestLogger } from "@teamscala/logger/install-logging";
59
26
  import type { ServiceInstallConfig } from "./load-service-install-row.ts";
60
27
  import { mountOrpcRouter } from "./mount-orpc-router.ts";
61
28
  import { mountServerPackages, preloadServerPackages } from "./mount-server-packages.ts";
@@ -101,13 +68,6 @@ export async function buildMinimalApp(
101
68
 
102
69
  const app = new Hono();
103
70
 
104
- // Per-step instrumentation inside the boot-ui-service phase. Same
105
- // createTimer + wall-clock convention as create-boot-pipeline's runPhase
106
- // (timing-is-the-only-timing, per-phase-instrumentation) — NOT a second
107
- // telemetry surface. The boot-ui-service phase is 60% of boot; this
108
- // breakdown localises the cost so optimisation targets the real hotspot
109
- // instead of guessing. Spans flow to GlitchTip (timing's trace-emitter is
110
- // wired by the time we reach here); the breakdown line is `pm2 logs`-visible.
111
71
  const stepTimings: Record<string, number> = {};
112
72
  async function runStep<T>(step: string, fn: () => Promise<T>): Promise<T> {
113
73
  const timer = createTimer(`${serviceSlug}:boot.minimal.${step}`);
@@ -119,7 +79,6 @@ export async function buildMinimalApp(
119
79
  }
120
80
  }
121
81
 
122
- // Error handlers
123
82
  app.onError(createRequestErrorHandler("service-boot:minimal"));
124
83
 
125
84
  app.notFound(async (c) => {
@@ -131,7 +90,6 @@ export async function buildMinimalApp(
131
90
  return c.json({ error: "Not Found" }, 404);
132
91
  });
133
92
 
134
- // Core middleware
135
93
  app.use("*", readinessGate);
136
94
  app.get("/health", healthHandler);
137
95
  app.use("*", injectSecurityHeaders);
@@ -151,19 +109,7 @@ export async function buildMinimalApp(
151
109
  );
152
110
  app.use("*", compress());
153
111
  app.use("*", timing());
154
- // CSRF (`security-csrf-protection`): state-changing form-element requests
155
- // must carry a trusted Origin — derived set (managed base domain +
156
- // builda_websites custom domains via isTrustedAuthOrigin), never an
157
- // allowlist beside better-auth's. Registered after security headers, before
158
- // every route + gate.
159
112
  app.use("*", csrfProtection());
160
- // GLOBAL body limit (`security-body-limit`): every request body is bounded
161
- // BEFORE any handler buffers it — a per-route file.size check AFTER
162
- // formData() has buffered is a report, not a limit. 30MB sits above the
163
- // 25MB transfers-upload maximum; routes wanting a smaller bound mount their
164
- // own bodyLimit BEFORE this one (Hono runs matching middleware in
165
- // registration order, and /api/rpc registers its 10MB limit in
166
- // mount-orpc-router).
167
113
  app.use(
168
114
  "*",
169
115
  bodyLimit({
@@ -180,20 +126,8 @@ export async function buildMinimalApp(
180
126
  );
181
127
  const ttlPromise = runStep("init-gate-ttls", () => initGateTtls());
182
128
 
183
- // Public access endpoints — MUST be registered before the auth gate (Hono
184
- // dispatches middleware + handlers in registration order; the content-gate
185
- // POST routes and magic-link endpoints are public and must be reachable
186
- // without a session, so they precede the auth gate's app.use("*") wildcard).
187
- // These are mutually independent — distinct paths, no shared mutable state
188
- // beyond the Hono app's synchronous route array — so they parallelise.
189
129
  await runStep("public-routes", () =>
190
130
  Promise.all([
191
- // EVERY REGISTERED GATE'S ACCESS ENDPOINT (content-gate/catalog.ts).
192
- // The same catalog the SSR gate sequence iterates, so the endpoint that
193
- // MINTS a gate's token and the gate that VERIFIES it cannot disagree
194
- // about which gates exist — this list and that one were two copies of
195
- // one fact, and a gate added to one and missed in the other is a page
196
- // that gates content with no way to unlock it.
197
131
  ...CONTENT_GATES.map((entry) =>
198
132
  attachContentGateAccess(app, serviceSlug, entry.access),
199
133
  ),
@@ -205,27 +139,14 @@ export async function buildMinimalApp(
205
139
  ]),
206
140
  );
207
141
 
208
- // Auth gate — registers /api/auth/* + the session-check wildcard middleware.
209
- // MUST follow the public routes above (load-bearing Hono registration order)
210
- // and MUST precede mountServerPackages: server-package daemon routes (e.g.
211
- // cli-session's /rpc/*) are subject to the session gate, which requires the
212
- // gate's wildcard registered first to apply. Running these concurrently would
213
- // make that ordering non-deterministic — so the gate stays serial here.
214
142
  await runStep("attach-auth-gate", () =>
215
143
  attachAuthGate(app, config, serviceSlug),
216
144
  );
217
145
  registerStaticAssets(app, serviceSlug, config.brand_org);
218
146
 
219
- // Server packages — the cold imports were preloaded above and are now in
220
- // Bun's module cache, so this pays only the mount work (~33ms, not ~2.8s of
221
- // serial compilation). Route registration happens HERE (after the auth gate),
222
- // preserving the gate-vs-daemon-route ordering.
223
147
  const mountRegistry = await runStep("mount-server-packages", () =>
224
148
  mountServerPackages(app, config, serviceSlug, getSharedDb),
225
149
  );
226
- // Ensure the speculative preload finished before proceeding (it populates
227
- // the same cache mountServerPackages just consumed; awaiting now is a no-op
228
- // if the preload already settled, and makes the ordering guarantee explicit).
229
150
  await preloadPromise;
230
151
  const apiRouteCount = registerApiRoutes(app, config, mountRegistry, serviceSlug);
231
152
 
@@ -233,17 +154,10 @@ export async function buildMinimalApp(
233
154
  await runStep("mount-orpc-router", () => mountOrpcRouter(app, serviceSlug));
234
155
  }
235
156
 
236
- // Register the in-process ORPC server client BEFORE discoverBlocks — it
237
- // calls getServerClient() which needs the client set here. Must follow
238
- // mount-orpc-router (which builds the router getAppRouter uses).
239
- // Previously ran inside register-page-routes (too late — discoverBlocks
240
- // fired before it, hitting the empty-router fallback).
241
157
  await runStep("register-page-server-client", () =>
242
158
  registerPageServerClient(serviceSlug),
243
159
  );
244
160
 
245
- // NOW load the generated manifest (getServerClient is set). Awaited below
246
- // before register-page-routes consumes the result.
247
161
  const discoverPromise = runStep("load-page-manifest", () =>
248
162
  loadPageManifest(serviceSlug),
249
163
  );
@@ -252,14 +166,8 @@ export async function buildMinimalApp(
252
166
  registerWsRoutes(app, config, mountRegistry, serviceSlug, upgradeWebSocket),
253
167
  );
254
168
 
255
- // Resolve the deferred TTL load (started at the top — should be long done,
256
- // overlapped with the gate chain + mount work). The getters are read at
257
- // request time, but the cache must be populated before the first request.
258
169
  await ttlPromise;
259
170
 
260
- // CRITICAL: Block discovery and page routes MUST be registered BEFORE serving,
261
- // because Hono's RegExpRouter compiles on first request and becomes immutable.
262
- // We defer only client asset building (expensive Vite build) to Phase 2.
263
171
  let components: Awaited<ReturnType<typeof loadPageManifest>>;
264
172
  try {
265
173
  components = await discoverPromise;
@@ -276,15 +184,10 @@ export async function buildMinimalApp(
276
184
  );
277
185
 
278
186
 
279
- // Run boot hooks BEFORE serving - some hooks may register routes and must
280
- // run before Hono's matcher is compiled by the first request
281
187
  await runStep("run-boot-hooks", () =>
282
188
  runBootHookList(config, mountRegistry, serviceSlug),
283
189
  );
284
190
 
285
- // Register page routes with a dynamic handler that delegates to the real handler once Phase 2 completes.
286
- // All route paths must be registered before serving to avoid Hono
287
- // RegExpRouter compilation issues (matcher becomes immutable).
288
191
  const registeredPageRoutes = await runStep("register-page-routes", () =>
289
192
  registerPageRoutes({
290
193
  app,
@@ -299,10 +202,6 @@ export async function buildMinimalApp(
299
202
  const steps = Object.entries(stepTimings)
300
203
  .map(([step, ms]) => `${step}=${ms}ms`)
301
204
  .join(" ");
302
- // NOTE: several steps run concurrently (speculative preload, init-gate-ttls,
303
- // discover-blocks overlap the gate chain), so the step sum exceeds the
304
- // wall-clock buildMinimalApp duration. Compare to the boot-ui-service phase
305
- // timing in the Boot complete line for the true wall-clock cost.
306
205
  log.info(
307
206
  `[two-phase-boot:${serviceSlug}] buildMinimalApp breakdown (steps overlap) — ${steps}`,
308
207
  );
@@ -322,9 +221,6 @@ export async function buildMinimalApp(
322
221
  databaseUrl,
323
222
  blockPackages,
324
223
  components,
325
- // The generated manifest IS the loaded set — every entry it exports is a
326
- // block this service renders, so there is no "skipped" half any more. A
327
- // row that cannot produce a component fails codegen, not boot.
328
224
  loaded: Object.keys(components),
329
225
  skipped: [] as string[],
330
226
  };
package/src/types.ts CHANGED
@@ -8,9 +8,6 @@ type RendererComponent = (props: Record<string, unknown>) => unknown;
8
8
  export interface RendererComponentEntry {
9
9
  render: RendererComponent;
10
10
  fields?: Record<string, { type: string }>;
11
- /** block_schema.defaults, carried onto the entry by discoverBlocks so
12
- * buildPuckConfig merges them under authored props at SSR
13
- * (blocks-fully-programmable). */
14
11
  defaultProps?: Record<string, unknown>;
15
12
  }
16
13
 
@@ -31,18 +28,7 @@ export interface PageHandlerConfig {
31
28
  providers?: string[];
32
29
  loadData: (request: Request) => Promise<unknown>;
33
30
  renderMode?: "public" | "portals" | "utility";
34
- /**
35
- * Owning service slug — used by the page-server render catch to resolve
36
- * the branded logo on the render-error page. Mirrors service-runtime's
37
- * PageHandlerConfig.
38
- */
39
31
  serviceSlug?: string;
40
- /**
41
- * Redirect a signed-in user's `/` to their org-scoped portal dashboard.
42
- * Set only for services serving a `/*` catchall page_route (the portal);
43
- * single-site portal-mode services serve their real page at `/`.
44
- * Mirrors service-runtime's PageHandlerConfig (1.15.82).
45
- */
46
32
  portalHomeRedirect?: boolean;
47
33
  }
48
34
 
package/src/ui-service.ts CHANGED
@@ -17,32 +17,7 @@
17
17
  * place and is NOT undone.
18
18
  */
19
19
 
20
- // Resolve the listen port through the SHARED resolver (`resolveServicePort`),
21
- // the same one the backend branch uses. The registry wins wherever it has an
22
- // opinion and a contradicting PORT env is reported, never silently obeyed —
23
- // full reasoning in that file.
24
- //
25
- // This branch previously took `process.env.PORT` unconditionally, which is
26
- // how scala-quiz (declared 5203) booted onto session-picker's 3021 from a
27
- // leaked env and silently split that daemon's traffic (2026-07-31). On the
28
- // dev host the registry wins a conflict (below). On Railway the injected PORT
29
- // wins instead — Railway healthchecks exactly that port, and the row's port
30
- // is the service's LOCAL dev port (e.g. portal 5200), so binding it on Railway
31
- // makes the healthcheck probe a dead port and the deploy fails (`isRailway`
32
- // path in the resolver).
33
-
34
- // NO-SILENT-REVERTS — surface the failed client build to OBSERVABILITY
35
- // (event-log → GlitchTip, per event-log-is-the-only-durable-event-log).
36
- // The minimal-app fallback keeps serving SSR from the LAST-successful
37
- // manifest (now a tombstone, see build-client-assets.ts), so the page
38
- // looks "up" while the client bundle is stale. Without this signal the
39
- // failure is buried in boot stderr and a published fix that reached
40
- // node_modules but failed to bundle ships invisibly stale (the
41
- // scala-agents 2026-07-18 incident: ui-foundation@3.4.5 installed but
42
- // served pre-fix bytes across restarts while node_modules was
43
- // mid-install). The boot.client-build-failed event routes into
44
- // event-log's 7-day retention → surfaces in GlitchTip as an incident
45
- // trail the operator actually watches.
20
+
46
21
 
47
22
  import { recordEvent } from "@teamscala/event-log/record-event";
48
23
  import { getAppLogger } from "@teamscala/logger/app-loggers";
@@ -91,7 +66,6 @@ export async function bootUiService(
91
66
  const log = getAppLogger();
92
67
  const startTime = Date.now();
93
68
 
94
- // Phase 1: Build minimal tool-only app
95
69
  const minimalAppResult = await buildMinimalApp(
96
70
  config,
97
71
  serviceSlug,
@@ -102,12 +76,6 @@ export async function bootUiService(
102
76
 
103
77
  const isRailway = Boolean(process.env.RAILWAY_ENVIRONMENT);
104
78
  const envPort = process.env.PORT ? Number(process.env.PORT) : undefined;
105
- // The APP binds its PRIVATE port (app_port) when present — for scale-to-zero
106
- // services `port` is the ACTIVATOR's ingress (what callers resolve), not the
107
- // app's bind. Falling back to `port` keeps RESIDENT + unmigrated services
108
- // unchanged. Without this, a migrated row (port=activator) would make the app
109
- // bind the activator's port → SO_REUSEPORT silent load-balancing (the
110
- // service-ports-from-registry trap: clean boot, two owners in ss -ltnp).
111
79
  const declaredAppPort = config.app_port ?? config.port;
112
80
  const resolution = resolveServicePort(envPort, declaredAppPort, isRailway);
113
81
  if ("error" in resolution) {
@@ -129,21 +97,17 @@ export async function bootUiService(
129
97
  }
130
98
  const port = requireValidPort(resolution.port, serviceSlug);
131
99
 
132
- // service-runtime owns the TCP listener. service-boot only composes the app
133
- // and starts the two-phase build; the runtime binds the returned app.
134
100
  const minimalReadyTime = Date.now();
135
101
  const minimalDuration = minimalReadyTime - startTime;
136
102
  log.info(
137
103
  `[ui-service-boot:${serviceSlug}] Minimal app composed on port ${port} in ${minimalDuration}ms`,
138
104
  );
139
105
 
140
- // FLIP READY SWITCH: minimal app is ready, MCP tools can connect
141
106
  setReady();
142
107
  log.info(
143
108
  `[ui-service-boot:${serviceSlug}] Ready gate flipped - MCP tools can now connect (full app building in background)`,
144
109
  );
145
110
 
146
- // Phase 2: Build full app in background (non-blocking)
147
111
  ;(async () => {
148
112
  try {
149
113
  const PHASE2_TIMEOUT_MS = 120_000;
@@ -159,7 +123,6 @@ export async function bootUiService(
159
123
  const fullBuildTime = Date.now();
160
124
  const fullDuration = fullBuildTime - startTime;
161
125
 
162
- // Swap the app reference atomically
163
126
  swapToFullApp(fullAppResult.app, serviceSlug);
164
127
 
165
128
  log.info(
@@ -178,13 +141,9 @@ export async function bootUiService(
178
141
  at: new Date().toISOString(),
179
142
  },
180
143
  });
181
- // Continue with minimal app - better than crashing
182
144
  }
183
145
  })();
184
146
 
185
- // Return result compatible with the existing UiServiceBootResult interface
186
- // Note: blocks/routes will be empty initially since they're built in the background.
187
- // The service is operational for MCP tools.
188
147
  return {
189
148
  kind: "ui",
190
149
  blocks: [], // Will be populated when full app builds