@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,15 +1,6 @@
1
1
  /**
2
- * @system server-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The auth gate's public-path surface: sign-in paths, the static-asset
7
- * extension predicate, and the public infra prefixes/paths (self-authenticating
8
- * service-to-service surfaces, dev-host infra reads, and the access-gate
9
- * rejection target).
10
- *
11
- * Split out of auth-gate.ts (436 lines) — byte-for-byte move; import paths
12
- * adjusted only.
13
4
  */
14
5
 
15
6
  export const SIGN_IN_PATHS = new Set([
@@ -22,16 +13,6 @@ export const SIGN_IN_PATHS = new Set([
22
13
  "/magic-link",
23
14
  ]);
24
15
 
25
- /// A STATIC ASSET IS NEVER A PROTECTED RESOURCE, so it is decided by EXTENSION / rather than by remembering to list it.
26
- /// / The allowlist below enumerates PREFIXES, and every path outside it is gated. / That makes a new public asset directory private BY DEFAULT, and the failure
27
- /// lands exactly where it hurts most: on the SIGNED-OUT screen, which is the one / page guaranteed to need branding. `/assets/` was listed; `/logos/` never was,
28
- /// so `/logos/cropped.png` answered 302 → /sign-in and the browser rendered a / blank logo under a "starting up" splash. Favicons are on the list ONE FILE AT
29
- /// A TIME, which is the same defect already visible. / / This is why the operator reports it "keeps regressing": nothing is broken by
30
- /// a code change, the allowlist simply never covered the path, and any build that / moves images re-creates it. Enumerating what is public is `prevention-over-
31
- /// detection` inverted — the cure is a predicate, not a longer list. /
32
- /// Redirecting an <img> to a login page is never useful behaviour either: the
33
- /// browser cannot act on the redirect, so it silently paints nothing. Failing
34
- /// closed on a page is protection; failing closed on an image is just a blank.
35
16
  const STATIC_ASSET_EXTENSIONS = [
36
17
  ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".ico", ".bmp",
37
18
  ".woff", ".woff2", ".ttf", ".otf", ".eot",
@@ -47,17 +28,7 @@ export const PUBLIC_INFRA_PREFIXES = [
47
28
  "/assets/",
48
29
  "/api/assets/",
49
30
  "/api/auth/",
50
- // fn.signInFlow procedures serve the unauthenticated sign-in flow (config
51
- // load, session check, portal list) — anonymous-allowed by design (return {}
52
- // for no session). They replaced the former /api/data/sign-in-flow-config,
53
- // /check-session, /portals HANDLER_MAP entries (handler-map-retirement).
54
31
  "/api/rpc/fn/signInFlow/",
55
- // scala-enroll device-onboarding: the machine-facing installer + register
56
- // routes are TOKEN-authenticated (a single-use enrollment token issued by a
57
- // logged-in, dev_access-approved operator), fetched by the onboardee via
58
- // `irm`/`curl` with NO session cookie — so they must skip the page login
59
- // gate, exactly like /mcp (Bearer) and /api/rpc/ws self-authenticate. The
60
- // TOKEN is the delegated capability; issuance (/issue) stays login-gated.
61
32
  "/provision/",
62
33
  ];
63
34
 
@@ -67,81 +38,15 @@ export const PUBLIC_INFRA_PATHS = new Set([
67
38
  "/favicon.svg",
68
39
  "/api/session",
69
40
  "/api/rpc/ws",
70
- // The MCP endpoint is a service-to-service surface with its OWN Bearer auth
71
- // (the gateway sends `Authorization: Bearer SCALA_DEV_KEY`, validated by the
72
- // MCP route handler). It must never be session-page-gated: on a kind:ui
73
- // service that also mounts an MCP server (scala-agents — the fleet
74
- // orchestration + cross-pane channel), the auth-gate would 302 /mcp to
75
- // /sign-in, so the gateway sees toolCount=0 and the whole channel goes
76
- // degraded. Backend-only MCP hosts have no auth-gate, so this only bit the
77
- // one UI MCP host once the localhost super-admin bypass was removed
78
- // (the bypass had been masking it). Exempt alongside /api/rpc/ws and
79
- // /api/auth/, which self-authenticate the same way.
80
41
  "/mcp",
81
- // build-id is polled by the client self-heal; it must always return JSON
82
- // (a tiny bundle hash) regardless of session, never a /sign-in redirect —
83
- // otherwise an expired-session tab would compare a redirect body to its
84
- // booted build and never heal.
85
42
  "/api/build-id",
86
- // /_heap is the localhost-gated heap-attribution probe (register-static-assets);
87
- // like /health it is infra, not a page, so it must never 302 to /sign-in on an
88
- // auth-gated service. The probe's own localhost Host check is the real gate.
89
43
  "/_heap",
90
- // /api/fleet/events is the SSE fleet event stream consumed self-contained by
91
- // the generated opencode scala-hooks plugin (and any future client without
92
- // an @orpc/client dep) for native cross-pane channel delivery. The plugin
93
- // runs in the opencode process with no scala-agents session cookie, so the
94
- // route must be public; the stream is keyed by the orchestratorSessionId
95
- // query param (the subscriber's own tmux pane id) and carries no sensitive
96
- // data, and dispatch only emits events targeted at that identity.
97
44
  "/api/fleet/events",
98
- // /api/fleet/claude-sessions is a non-sensitive lane-identity read (active
99
- // Claude lane sessionIDs + cwds) consumed by the session-picker daemon to
100
- // RESTORE transcript tails after its own restart. The picker runs with no
101
- // scala-agents session cookie; the data is less sensitive than the
102
- // /api/fleet/events stream above (no message content), so it is public like
103
- // the other dev-host infra reads.
104
45
  "/api/fleet/claude-sessions",
105
- // /api/fleet/ingest-event receives parsed lane-action events
106
- // (message.part.updated etc.) from the cli-session transcript-tail — a
107
- // dev-host INTERNAL machine caller that POSTs with no session cookie, exactly
108
- // like the lane-event hook. Without this exemption the page auth-gate 302s
109
- // every POST and the lane's tool/text action stream never reaches the
110
- // ingestEvent pipeline. A first-class
111
- // api_route (sibling to lane-event); security is the handler's loopback gate
112
- // on the platform's x-caller-remote-address (set by register-api-routes).
113
46
  "/api/fleet/ingest-event",
114
- // /api/fleet/lane-event receives Claude Code http lifecycle hooks
115
- // (Stop/UserPromptSubmit/Pre*ToolUse) for live-lane visibility. Like /mcp it
116
- // is a service-to-service surface with its OWN Bearer auth (the hook sends
117
- // `Authorization: Bearer SCALA_DEV_KEY`, validated by the handler); the hook
118
- // runs in the Claude process with no scala-agents session cookie, so it must
119
- // not be session-page-gated. The handler filters to fleet lanes by workspace
120
- // cwd + resolves the lane via the session-picker, so an unauth'd caller
121
- // writes no lane state.
122
47
  "/api/fleet/lane-event",
123
- // /api/fleet/sweep-executor receives the loga monitor engine's sweep dispatch
124
- // (POST {slug}) -- a service-to-service surface like /api/fleet/lane-event /
125
- // /api/fleet/ingest-event (loga -> scala-agents, localhost). Security is the
126
- // handler's loopback gate (x-caller-remote-address), identical to its siblings.
127
48
  "/api/fleet/sweep-executor",
128
- // /api/terminal-ws is the in-browser terminal WebSocket bridge (a real PTY
129
- // login shell). It is a dev-tool surface analogous to /api/fleet/events:
130
- // the operator's own shell, no sensitive data beyond what the operator
131
- // already has in their terminal. Must not be session-gated — the test
132
- // harness and the terminal block both connect without a session cookie.
133
49
  "/api/terminal-ws",
134
- // /unauthorised is the access-gate's rejection target (the PageGuard access
135
- // page). It MUST be exempt: an authed-but-not-allowed viewer is redirected
136
- // here, so gating it would re-reject and loop. It renders no sensitive data.
137
50
  "/unauthorised",
138
- // The OpenAPI contract for the ORPC router — a service-to-service surface
139
- // with its OWN Bearer auth (`Authorization: Bearer SCALA_DEV_KEY`,
140
- // validated by the mount-orpc-router handler, fail-closed), exactly like
141
- // /mcp and /api/fleet/lane-event above. The MCP gateway fetches it at boot
142
- // (SCALA_OPENAPI_SPEC_URL) with no session cookie; without this exemption
143
- // the page auth-gate 302s the fetch to /sign-in and the gateway reads an
144
- // HTML sign-in page where the JSON document belongs (observed probing
145
- // scala-portal.internal:5200/api/rpc/openapi.json on 2026-09-30).
146
51
  "/api/rpc/openapi.json",
147
52
  ]);
package/src/auth-gate.ts CHANGED
@@ -1,48 +1,8 @@
1
1
  /**
2
- * @system server-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Auth middleware factory. Three modes, same BetterAuth email-OTP mechanism:
7
- *
8
- * service_install.auth values:
9
- * - omitted | "none" → no gate, no handler (scala-public, scala-sign)
10
- * - "better-auth-cookie" → full session auth: mount handler + session gate
11
- * - "email-otp-only" → mount handler (OTP send/verify), no session gate
12
- *
13
- * The sign-in block determines the UX per mode:
14
- * - "full" → email → OTP → session → portal selection (portal, agents, docs, gpt)
15
- * - "email-gate" → email → OTP → verify-only, no session (forms, proposals, quiz)
16
- * - "capture-only" → email field only, no OTP (sign)
17
- *
18
- * There is NO localhost bypass: 127.0.0.1 is gated identically to the tunnel
19
- * (no session → /sign-in). Tools needing an authed view sign in
20
- * (getSuperAdminSessionCookie); the dev OTP is DB-readable.
21
- *
22
- * When SCALA_OTP_URL is set, /api/auth/* forwards to the centralised
23
- * scala-otp service (per otp-centralised-via-otp-service). Otherwise
24
- * the local better-auth handler is used (dev-host fallback).
25
4
  */
26
5
 
27
- /**
28
- * Point the event-log primitive at the process's Prisma singleton.
29
- *
30
- * Driver-agnostic `write` callback backed by the Prisma singleton
31
- * (`one-db-surface` — no second postgres). Lazy, and it takes precedence over
32
- * any `prisma:` configure, so this writer is authoritative for authed services.
33
- * The previous form passed the ORPC server client (`getServerClient()`) cast
34
- * `as never` as `prisma:` — that client has no `$executeRaw`, so `recordEvent`
35
- * silently failed. Routing it through ORPC would reintroduce exactly that.
36
- * Retention is owned by the event-log-sweep pg-boss job.
37
- *
38
- * EXTRACTED so its name states what it is. This is a `configured-primitives`
39
- * INJECTION — handing a client to a primitive's configure() — not a query, and
40
- * `no-direct-prisma-outside-orpc` cannot tell the two apart by AST. It exempts
41
- * initialization, which is precisely what this is; buried inside
42
- * `attachAuthGate` the call read as an arbitrary getPrisma() and blocked every
43
- * unrelated edit to this file. Naming the boot step honestly is the fix. The
44
- * registry refused a path exemption for it, correctly — `guard-exemptions-to-zero`.
45
- */
46
6
 
47
7
  // Eager-warm the local BetterAuth runtime at boot whenever a request path
48
8
  // will build it: the session-READ path (getSession, better-auth-cookie mode)
@@ -1,25 +1,8 @@
1
1
  /**
2
- * @system ui-service-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Calls a createPageHandler() handler with the per-request extras the
7
- * SSR pipeline needs: the resolved userId/orgId (from localhost-bypass
8
- * or better-auth-cookie), and the htmlTemplate. Shared by the regions
9
- * and page_slug shell variants.
10
4
  */
11
5
 
12
- // Per-page root.props.maxWidth (the canonical mechanism) centers any page at the chosen width via inline styles — already hydration-safe
13
- // and page-scoped. Bind-page-handler MUST NOT inject unscoped CSS selectors: a global `.puck-root-slot { max-width }` rule shrinks
14
- // every portal pane that hosts a Puck root (the 2026-06-17 dashboard sliver bug — `.puck-root-slot` matches every Puck page in every
15
- // region, including the dashboard center pane). The `no-bind-page-handler-css-injection` guard enforces this; this comment is the load-bearing rationale.
16
- // Live dashboards must never be served from a stale cache (intermediary, browser back-forward, mobile Safari aggressive caching). Force every
17
- // SSR HTML response to be revalidated. Static assets (CSS/JS bundles) keep their hash-busted caching — those are content-addressed.
18
- // no-transform: intermediaries must not mutate SSR HTML — Cloudflare's Email Address Obfuscation rewrites mailto: hrefs at the edge, which
19
- // breaks the server-serializes-client-reads-verbatim hydration contract (React recoverable-error on every page with a mailto link). Per CF
20
- // docs, Email Obfuscation does not apply when no-transform is present. NOTE: a shared-edge-caching (CDN max-age) variant shipped briefly on 2026-06-10
21
- // and was REMOVED by operator ruling the same day ("the origin should be so fast we don't care") — the origin-speed work (SWR caches,
22
- // wave-parallel loader, brotli) is the sanctioned path; re-adding shared-cache headers needs a fresh operator ruling.
23
6
 
24
7
  import { getAppLogger } from "@teamscala/logger/app-loggers";
25
8
  import { withGateEntryClears } from "./content-gate/gate-entry-clears.ts";
@@ -48,34 +31,12 @@ export async function bindPageHandler(
48
31
  let orgId: string | undefined;
49
32
  let visitor: { id: string; name: string; email: string } | null = null;
50
33
 
51
- // NO localhost super-admin bypass: identity comes ONLY from the session
52
- // (the auth-gate already forces sign-in for authed services on every
53
- // host, 127.0.0.1 included). The old `if (isLocalhost) userId="super-admin"`
54
- // rendered authed pages as a privileged user on localhost regardless of
55
- // who (if anyone) was signed in — so agent/tool verification on 127.0.0.1
56
- // saw a view that diverged from the real signed-in user's. Tools that need
57
- // an authed render sign in and carry the cookie (getSuperAdminSessionCookie).
58
34
 
59
- // The .scala.business session cookie reaches EVERY app on the domain,
60
- // so a signed-in platform user is identifiable at SSR regardless of
61
- // this service's own auth mode. Resolving it per-request (never via
62
- // the cross-request loader graph) lets identity-gated blocks
63
- // (PublicGptBlock's SignInBlock gate) first-paint the real experience
64
- // instead of a client-side "checking who you are" loading pass
65
- // (ssr-then-live-handoff). authMode still governs AUTHORIZATION;
66
- // this is read-only identity convenience.
67
35
  const hasSessionCookie = /better-auth\.session_token=/.test(
68
36
  c.req.header("cookie") ?? "",
69
37
  );
70
38
 
71
39
  if (authMode === "better-auth-cookie" || hasSessionCookie) {
72
- // Visitor identity is an ENHANCEMENT: on auth:none services
73
- // (scala-public et al.) no auth factory is registered, and a
74
- // signed-in operator's domain-wide cookie reaches every
75
- // *.scala.business site — getSession throwing "Auth factory not
76
- // registered" 500'd every page for signed-in visitors
77
- // (public-dev outage, 2026-06-11). Degrade to anonymous, never
78
- // fail the render (block-and-provider-error-isolation).
79
40
  try {
80
41
  const { getSession } = await import("@teamscala/auth/get-session.ts");
81
42
  const session = await getSession(c.req.raw.headers);
@@ -92,27 +53,11 @@ export async function bindPageHandler(
92
53
  }
93
54
  } catch (err) {
94
55
  if (authMode === "better-auth-cookie") throw err;
95
- // cookie-only enhancement path: anonymous render proceeds
96
56
  }
97
57
  }
98
58
 
99
- // Anonymous dynamic routes (proposal/form/quiz/agent) resolve a
100
- // resource whose owning org is known from the loader. That org owns
101
- // the brand the block-level serverLoaders read — override the
102
- // viewer's org (if any) so the SSR injects the resource owner's
103
- // logo/colours, not the viewer's.
104
59
  if (resourceOrgId) orgId = resourceOrgId;
105
60
 
106
- // Unauthed /sign-in (no session, no resource) would otherwise resolve no org, and the brand resolver falls back to the super-admin org (GBG Indo)
107
- // — so a link shared to org A (e.g. GMG APAC) previews as org B's brand on WhatsApp/crawlers, because the link 302→/sign-in for the unauthed crawler.
108
- // The callbackURL carries the org the visitor is signing in to; derive orgId from it so the sign-in page's brand/title/og reflect the destination org.
109
- // The canonical portal shape is /<websiteId>/<orgId>/<path> — the org is segment 1, NOT segment 0. Reading segment 0 (the website id) fed a website
110
- // id into brand resolution, which resolved no org and fell back to the super-admin org — the exact cross-tenant mis-branding this block exists to
111
- // fix, still live until 2026-07-21; violates portal-branding-resolves-page-org-first.
112
- // scala-projects routes /<orgId>/… (single UUID) and proposal/form/quiz hosts
113
- // route /<resourceId>/… (a non-org first segment) — both keep segment 0 via
114
- // the helper's single-UUID fallback (resolves to no brand, falls back as
115
- // before). The helper is the single authority shared with the route resolver.
116
61
  if (!orgId) {
117
62
  const cb = c.req.query("callbackURL") ?? "";
118
63
  const resolved = extractOrgIdFromCallbackPath(cb);
@@ -132,10 +77,6 @@ export async function bindPageHandler(
132
77
  }),
133
78
  );
134
79
  const headers = new Headers(resp.headers as HeadersInit);
135
- // Gate-entry markers are ONE-VIEW state: the render that consumed one
136
- // clears it here, so the next load (a refresh) is unauthorized and shows
137
- // the gate again. Without this the marker would outlive the view that
138
- // used it and every load would skip the gate.
139
80
  for (const name of gateEntryClears) {
140
81
  headers.append(
141
82
  "Set-Cookie",
@@ -148,26 +89,9 @@ export async function bindPageHandler(
148
89
  );
149
90
  headers.set("Pragma", "no-cache");
150
91
  headers.set("Expires", "0");
151
- // Viewport-aware slot loading varies the response by these headers.
152
- // Even with Cache-Control: no-store, browsers and intermediaries
153
- // honour Vary on back/forward navigation and BFCache; Accept-CH
154
- // asks the browser to send Sec-CH-UA-Mobile on subsequent
155
- // navigations (Chromium-family auto-sends, others opt in).
156
92
  headers.set("Vary", "Sec-CH-UA-Mobile, User-Agent");
157
93
  headers.set("Accept-CH", "Sec-CH-UA-Mobile");
158
- // Origin-side gzip. Cache-Control: no-transform (load-bearing above —
159
- // it disables Cloudflare HTML rewrites that corrupt hydration) ALSO
160
- // makes hono's compress() middleware skip this response, which shipped
161
- // 1.2MB of raw SSR HTML per page load through the dev tunnel (~3s of
162
- // pure transfer; the operator's tablet timed out — 2026-06-06).
163
- // no-transform forbids INTERMEDIARY transforms; origin-applied
164
- // content-coding is part of the representation (RFC 9110 §8.4.1), so
165
- // compressing here honors both. Bun.gzipSync on ~1.2MB costs ~10ms.
166
94
  const acceptEnc = c.req.header("Accept-Encoding") ?? "";
167
- // Brotli preferred when offered (every modern browser): ~15-20%
168
- // smaller than gzip on SSR HTML. Quality 4, NOT the library default
169
- // 11 — q11 costs hundreds of ms on a ~1MB page (defeats the point);
170
- // q4 compresses in single-digit ms and still beats gzip.
171
95
  if (/\bbr\b/.test(acceptEnc) && resp.body) {
172
96
  const { brotliCompressSync, constants } = await import("node:zlib");
173
97
  const raw = new Uint8Array(await new Response(resp.body as never).arrayBuffer());
@@ -187,8 +111,6 @@ export async function bindPageHandler(
187
111
  }
188
112
  return new Response(resp.body as never, { status: resp.status, headers });
189
113
  } catch (err) {
190
- // Dead entity link (wrong id, deleted row) → the branded not-found
191
- // page with a real 404 — never a 500, never default-content leakage.
192
114
  if (err instanceof PrimaryResourceMissingError) {
193
115
  log.warn(
194
116
  `[scala-install:${serviceSlug}] 404 ${err.resourceType} not found (${label}) for ${new URL(c.req.url).pathname}`,
@@ -197,10 +119,6 @@ export async function bindPageHandler(
197
119
  err.resourceType,
198
120
  await resolveErrorPageLogo(serviceSlug),
199
121
  err.httpStatus,
200
- // "Take me home" → the host root (relative "/" resolves to the
201
- // current site's home; the portal redirects / to its dashboard).
202
- // empty → the page-row-exists-but-zero-blocks "This page is empty"
203
- // surface, distinct from a dead link (operator ruling 2026-07-21).
204
122
  { homeUrl: "/", empty: err.empty },
205
123
  );
206
124
  }
@@ -1,16 +1,6 @@
1
1
  /**
2
- * @system server-handlers
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The host-wide client-build lock of the client-asset build: serialises the
7
- * heavy boot-time client build across ALL kind:ui services
8
- * (client-asset-builds-serialize-via-lock), with a DB-driven TTL that
9
- * fail-softs to a hardcoded default.
10
- *
11
- * Split out of build-client-assets.ts (962 lines) alongside its build
12
- * siblings. Byte-for-byte move from the parent — imports adjusted, nothing
13
- * else.
14
4
  */
15
5
 
16
6
  import { join } from "node:path";
@@ -1,16 +1,6 @@
1
1
  /**
2
- * @system server-handlers
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Content-hashed output housekeeping for the client-asset build: the
7
- * short content hash stamped into hashed filenames, and removal of stale
8
- * hashed files from a previous build so dist/client/ never accumulates
9
- * dead outputs.
10
- *
11
- * Split out of build-client-assets.ts (962 lines) alongside its build
12
- * siblings. Byte-for-byte move from the parent except createHash →
13
- * Bun.CryptoHasher (bun-native-crypto guard; identical digest output).
14
4
  */
15
5
 
16
6
  import { readdir, rm } from "node:fs/promises";
@@ -1,60 +1,8 @@
1
1
  /**
2
- * @system server-handlers
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The hydrate JS bundle unit of the client-asset build: Bun.build over
7
- * src/entry-client.ts with the resilient-block-imports plugin (dead block
8
- * imports degrade at runtime instead of crash-looping the service), the
9
- * post-build dead-facade gate, stale-chunk removal, and source-map upload.
10
- *
11
- * Split out of build-client-assets.ts (962 lines) alongside its build
12
- * siblings. Byte-for-byte move from the parent — imports adjusted, nothing
13
- * else.
14
4
  */
15
5
 
16
- // Externalize an unresolvable @teamscala/* block import (a deactivated block_schema whose manifest regen never propagated) or a broken
17
- // third-party internal (@fullcalendar/core/internal.js during a version mismatch) so it degrades ONE block at runtime instead of crash-looping
18
- // the service (2026-07-21 portal outage: @fullcalendar 7.x + plugins 6.x → unresolvable internal.js → 21 crash-loop restarts).
19
- //
20
- // The filter is deliberately NARROW — only the package prefixes where unresolvable imports actually occur — NOT every bare import. The prior
21
- // broad /^(?![./])/ filter intercepted resolvable shared packages too, and onResolve pass-through (return undefined) on a shared
22
- // sideEffects:false package (@tanstack/react-query, imported across many lazy block chunks) tripped Bun's code-splitter: it emitted a dead
23
- // `export{...}`-with-no-`from` facade and tree-shook away the entire query-core implementation → a fatal hydration SyntaxError that froze
24
- // agents.scala.business while every component reported healthy (2026-07-22; see the dead-facade gate + minifyIdentifiers:false
25
- // below). Intercepts of node builtins (stream/web) likewise pulled them into the browser build. The narrow filter leaves @tanstack/* + builtins
26
- // to Bun's native (correct) resolution. A future unresolvable import OUTSIDE these prefixes fails loud via the manifest guard + build
27
- // tombstone (no-dead-block-imports-in-manifest) — never silent. Entry-point imports are excluded — those must fail loudly (silently
28
- // externalizing a core import turns a build error into a mystery runtime crash; containment must not become a swallow).
29
-
30
- // minifyIdentifiers is DISABLED (whitespace + syntax/dead-code minify
31
- // kept): under code-splitting, identifier mangling interacting with a
32
- // shared sideEffects:false package (@tanstack/react-query) trips Bun's
33
- // splitter to tree-shake away the entire implementation and emit a dead
34
- // re-export facade — a fatal hydration SyntaxError that froze
35
- // agents.scala.business while every component reported healthy
36
- // (2026-07-22; reproduces in stable 1.3.14 + canary 1.4.0, so an upstream
37
- // fix is not near). Whitespace + syntax minify retain the bulk of the
38
- // size win; only name-mangling is dropped. The post-build dead-facade
39
- // gate below fails the build LOUD if this or any future regression slips
40
- // a fatal facade through on any UI service.
41
-
42
- // dead-facade prevention gate (agents.scala.business 2026-07-22): a Bun
43
- // splitter+tree-shake bug (stable 1.3.14 + canary 1.4.0) can emit a "dead
44
- // facade" chunk — `export { ... }` with NO `from` clause and NO local binding
45
- // definitions — a fatal SyntaxError at module load that kills React hydration
46
- // SILENTLY (the whole dashboard froze while every component reported healthy).
47
- // The narrow resilientBlockPlugin filter + minifyIdentifiers:false above
48
- // prevent the known trigger (@tanstack/react-query under split); this gate
49
- // makes the CLASS unshippable — any dead facade from ANY package on ANY UI
50
- // service fails the build LOUD here instead of freezing the page. Detection
51
- // is POSITION-INDEPENDENT — a named export{} ANYWHERE in the comment-stripped
52
- // body, with no `from` clause and no local declaration/import backing the
53
- // names, is the facade (Bun could prefix it with a directive or statement, not
54
- // only emit it at the very start — a positional assumption is a known way to
55
- // be silently bypassed; a valid local-export chunk carries a
56
- // class/function/const/var/let/import, a valid re-export carries `from`).
57
-
58
6
  import { mkdir, readdir, rm } from "node:fs/promises";
59
7
  import { join } from "node:path";
60
8
  import { getAppLogger } from "@teamscala/logger/app-loggers";
@@ -89,16 +37,6 @@ export async function buildHydrateBundle(
89
37
 
90
38
  await mkdir(outDir, { recursive: true });
91
39
 
92
- // Resilience (block-and-provider-error-isolation applied to the BUILD): a page-manifest.ts lazy import of a block whose component no longer resolves
93
- // (a block_schema deactivation or a removed package export whose manifest regen never propagated) MUST NOT fail the whole bundle and crash-loop the
94
- // service. Seven UI services went down at once this way on 2026-06-13. Such
95
- // an import is EXTERNALIZED instead: the build succeeds, the service boots,
96
- // and at runtime that one lazy import rejects into the block's
97
- // BlockErrorBoundary — the dead block degrades, the page + service stay up.
98
- // Only UNRESOLVABLE @teamgbg specifiers are touched (resolvable ones fall
99
- // through to Bun's normal resolution unchanged). Surfaced via warn so it is
100
- // never silent; the `no-dead-block-imports-in-manifest` guard still flags the
101
- // stale manifest so it gets regenerated.
102
40
  const externalizedBlocks: string[] = [];
103
41
  const resilientBlockPlugin: Bun.BunPlugin = {
104
42
  name: "resilient-block-imports",
@@ -122,21 +60,6 @@ export async function buildHydrateBundle(
122
60
  entrypoints: [entry],
123
61
  target: "browser",
124
62
  outdir: outDir,
125
- // THE BUNDLER OWNS THE HASH, because it also writes the references.
126
- // Under `splitting: true` + `publicPath`, Bun bakes the entry's
127
- // build-time name into every chunk that imports it. Naming the entry
128
- // `hydrate.js` here and RENAMING it to `hydrate-<hash>.js` afterwards
129
- // moved the file but could not rewrite the specifiers already emitted
130
- // into the chunks — so every chunk imported `/assets/hydrate.js`, which
131
- // no longer existed. 404 on the module, the importing block dies, and
132
- // the page still returns 200 with every guard green.
133
- //
134
- // MEASURED 2026-09-08 on quiz.scala.business: `AssessmentFlowBlock`
135
- // failed with "Failed to fetch dynamically imported module"; crawling
136
- // the served graph (142 level-1 + 76 level-2 assets) found exactly one
137
- // broken URL — `/assets/hydrate.js` — imported by the entry itself and
138
- // by most chunks. Hash applied OUTSIDE the generator is two artefacts
139
- // that must agree with nothing making them (`one-generator-pattern`).
140
63
  naming: {
141
64
  entry: `${HYDRATE_BASE}-[hash].[ext]`,
142
65
  chunk: "chunk-[hash].[ext]",
@@ -145,36 +68,13 @@ export async function buildHydrateBundle(
145
68
  minifyWhitespace: process.env.NODE_ENV === "production",
146
69
  minifySyntax: process.env.NODE_ENV === "production",
147
70
  minifyIdentifiers: false,
148
- // Bun plugin type
149
71
  plugins: [resilientBlockPlugin],
150
- // Code-splitting honours `lazy-blocks-per-page`: page-manifest.ts wraps
151
- // every block in `lazy(() => import())`, so splitting emits the heavy
152
- // Plate editor + each lazy block as a SEPARATE chunk loaded on demand
153
- // instead of inlining all of them into one ~12.5MB entry. The initial
154
- // download drops ~10× (entry ~1.2MB; chunks lazy). splitting:false was
155
- // drift that defeated the lazy manifest. SSR safety is intact — the
156
- // page-server awaits stream.allReady before flushing (render-page-html),
157
- // so every block is server-rendered and hydration is consistent.
158
72
  splitting: true,
159
- // External source maps so minified client stacks resolve to source in
160
- // GlitchTip (uploaded post-build by uploadSourceMaps via the official
161
- // glitchtip-cli debug-ID flow). Maps are deleted after upload — never
162
- // served; the debug IDs injected into the bundles are all the SDK needs.
163
73
  sourcemap: "external",
164
- // Chunks are referenced by absolute /assets/ URL (served by
165
- // register-static-assets' /assets/* handler), never a relative path that
166
- // could mis-resolve under a record route like /?id=<uuid>.
167
74
  publicPath: "/assets/",
168
75
  external: ["*.css"],
169
76
  });
170
77
  } catch (e) {
171
- // Bun.build can THROW (not return {success:false}) on certain failure
172
- // modes — e.g. entrypoint resolves to a path Bun can't read, or an
173
- // internal panic. Without this catch the boot path swallows the
174
- // useful diagnostic and the operator sees only "Bundle failed".
175
- // AggregateError carries the per-file resolution list in .errors —
176
- // without serializing it the operator sees only "Bundle failed"
177
- // (cost a full Railway round-trip per guess on 2026-06-05).
178
78
  const subErrors =
179
79
  e instanceof AggregateError
180
80
  ? e.errors.map((se) => (se instanceof Error ? se.message : String(se)))
@@ -192,12 +92,6 @@ export async function buildHydrateBundle(
192
92
  }
193
93
 
194
94
  if (!result.success) {
195
- // Bun.build's `.logs` is the verbose-build-message list. Each log is an
196
- // object with at minimum `{ level, message, position }`. Some failures
197
- // surface with an empty logs array — happens when Bun bails before
198
- // resolution (e.g. missing entrypoint after symlink-walk). Always emit
199
- // the full JSON form via the logger so service-runtime's structured
200
- // error trail captures every field, not just a `.message`-joined string.
201
95
  log.error(`[build-client-assets] Bun.build failed`, {
202
96
  logs: result.logs.map((l) => ({
203
97
  level: (l as { level?: string }).level,
@@ -250,11 +144,6 @@ export async function buildHydrateBundle(
250
144
  );
251
145
  }
252
146
 
253
- // Bun emitted the hashed entry itself, so the name it wrote into every
254
- // chunk IS the name on disk. Read it back from the build's own output
255
- // list rather than reconstructing it — a reconstructed name is a second
256
- // copy of a fact the bundler already owns, which is the defect this
257
- // change removes (`a-mutating-verb-reads-back-what-it-claims`).
258
147
  const entryOutput = result.outputs.find(
259
148
  (o) => o.kind === "entry-point",
260
149
  );
@@ -265,12 +154,6 @@ export async function buildHydrateBundle(
265
154
  }
266
155
  const hashedName = entryOutput.path.split("/").pop() as string;
267
156
  const hashedPath = join(outDir, hashedName);
268
- // The bundler OWNS this name (naming.entry above). Read the release hash
269
- // out of it; a name that no longer matches the declared
270
- // hydrate-<hash>.js shape means the naming contract changed underneath
271
- // us — refuse rather than re-derive a second hash from file bytes
272
- // (`one-generator-pattern`): a fallback here would produce a release id
273
- // the URL does not carry.
274
157
  const hashMatch = /^hydrate-([A-Za-z0-9]+)\.js$/.exec(hashedName);
275
158
  if (!hashMatch) {
276
159
  throw new Error(
@@ -280,9 +163,6 @@ export async function buildHydrateBundle(
280
163
  const hash = hashMatch[1];
281
164
  removeStaleHashedFiles(outDir, HYDRATE_BASE, "js", hashedName);
282
165
 
283
- // Splitting emits one chunk per lazy import() alongside the entry. Keep the
284
- // chunks THIS build produced; drop stale ones so dist/client/ doesn't
285
- // accumulate hundreds of orphaned chunks per rebuild.
286
166
  const currentChunks = new Set(
287
167
  result.outputs
288
168
  .filter((o) => o.kind === "chunk")
@@ -290,10 +170,6 @@ export async function buildHydrateBundle(
290
170
  );
291
171
  await removeStaleChunks(outDir, currentChunks);
292
172
 
293
- // Bundle telemetry → logger (single-logger-entry-point → GlitchTip): the
294
- // initial-download size + lazy-chunk footprint are visible per build, so a
295
- // regression (splitting silently off, or a heavy dep landing in the entry)
296
- // surfaces as a number instead of a mystery "slow page".
297
173
  const entryBytes = (await Bun.file(hashedPath).stat()).size;
298
174
  let chunkBytes = 0;
299
175
  for (const name of currentChunks) {
@@ -308,19 +184,11 @@ export async function buildHydrateBundle(
308
184
  { entryBytes, chunkCount: currentChunks.size, chunkBytes },
309
185
  );
310
186
  if (externalizedBlocks.length > 0) {
311
- // The service boots; these blocks degrade at runtime (BlockErrorBoundary)
312
- // instead of crash-looping the whole service. The stale manifest still
313
- // needs regenerating — surfaced here AND by no-dead-block-imports-in-manifest.
314
187
  log.warn(
315
188
  `[build-client-assets] ${externalizedBlocks.length} unresolvable block import(s) externalized (degraded, not fatal) — regenerate this service's page-manifest: ${externalizedBlocks.join(", ")}`,
316
189
  { externalizedBlocks },
317
190
  );
318
191
  }
319
- // Upload source maps to GlitchTip (best-effort) + RETAIN the .map files
320
- // outside the served root so a failed upload retries next boot — source
321
- // never ships publicly. The currentOutputs set names the build's own
322
- // bundles (entry + chunks) so maps from an older build don't pollute
323
- // this release's cache and re-upload as stale artifacts.
324
192
  const currentOutputs = new Set(
325
193
  result.outputs
326
194
  .filter((o) => o.kind === "chunk")
@@ -330,10 +198,6 @@ export async function buildHydrateBundle(
330
198
  return { hydratePath: `/assets/${hashedName}`, sourceMaps };
331
199
  }
332
200
 
333
- // Remove hashed code-split chunks no longer referenced by the current entry.
334
- // Keyed off the build's own output list (the only authoritative current set),
335
- // never a name pattern guess — a build emits N `chunk-*.js`; the previous
336
- // build's are orphaned the moment the entry's hash changes.
337
201
  async function removeStaleChunks(
338
202
  outDir: string,
339
203
  keep: Set<string>,
@@ -1,17 +1,6 @@
1
1
  /**
2
- * @system server-handlers
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The asset-manifest unit of the client-asset build: the AssetManifest shape,
7
- * its read/write, the last-failure tombstone status read the runtime surfaces,
8
- * and the first-boot fallback paths.
9
- *
10
- * Split out of build-client-assets.ts (962 lines) alongside its build
11
- * siblings — the file held six concerns (manifest, hashed output, hydrate
12
- * bundle, source-map upload, stylesheet build, fingerprint, lock, orchestration).
13
- *
14
- * Byte-for-byte move from the parent — imports adjusted, nothing else.
15
4
  */
16
5
 
17
6
  import { join } from "node:path";
@@ -46,13 +35,6 @@ export interface AssetManifest {
46
35
  sourceMaps?: SourceMapUploadResult;
47
36
  }
48
37
 
49
- /**
50
- * Read ONLY the tombstone state of the current manifest — the runtime
51
- * (read-html-template, ui-service) calls this to decide whether to surface a
52
- * build-failure signal. Returns null when the manifest is fresh (no
53
- * `buildFailed`). Decoupled from the full AssetManifest read so the runtime
54
- * never depends on the build internals.
55
- */
56
38
  export async function readClientBuildStatus(
57
39
  cwd = process.cwd(),
58
40
  ): Promise<{ failed: true; reason?: string; at?: string } | { failed: false }> {
@@ -104,17 +86,6 @@ export async function writeManifest(
104
86
  );
105
87
  }
106
88
 
107
- /**
108
- * Verify the CURRENT manifest against the files it names — the capability
109
- * probe the readiness flip gates on (`a-component-may-not-report-a-state-it-
110
- * has-not-verified`). A manifest is servable only when BOTH referenced assets
111
- * exist on disk. An absent or unreadable manifest, a first-boot tombstone
112
- * carrying no paths, and any manifest naming files that do not exist must all
113
- * read as NOT built, never as a legitimate bundle. Measured 2026-09-01:
114
- * proposals.scala.business served a client-facing proposal unstyled while
115
- * Railway reported the deploy SUCCESS, because "client build never ran" was
116
- * indistinguishable from "client build fine" at every layer that reports.
117
- */
118
89
  export async function verifyClientBuildAssets(
119
90
  cwd = process.cwd(),
120
91
  ): Promise<{ ok: true } | { ok: false; reason: string }> {