@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,38 +1,6 @@
1
1
  /**
2
2
  * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Resolves the set of emails authorized to view a proposal. A proposal is
7
- * gated (proposal-access) — only these emails can unlock it:
8
- *
9
- * - the issuing-org team: every member of the proposal's organisation
10
- * (better_auth_member → user.email). Lets the agency team open any of their
11
- * own proposals AND covers existing proposals whose created_by is
12
- * "system"/null.
13
- * - the recipient: the proposal's CRM contact (client_database.primary_email),
14
- * when contact_id is set.
15
- * - the partner team: every CRM contact belonging to the proposal's partner
16
- * org (client_database.company_id = proposals.partner_id → primary_email),
17
- * when partner_id is set. Lets the co-delivery partner's team open a
18
- * co-branded proposal. The partner is still branding-first; this is the one
19
- * access edge it carries.
20
- *
21
- * The partner clause casts to ::uuid because the two sides disagree in the
22
- * database: client_database.company_id is `uuid` while proposals.partner_id is
23
- * `text`. Postgres resolves operators at PLAN time, so `uuid = text` raises
24
- * "operator does not exist" for EVERY proposal — including ones with no partner
25
- * — the catch below swallows it, and the fail-closed empty Set then denies
26
- * access to everyone. Measured 2026-09-10: no proposal could be unlocked by any
27
- * email. The cast is correct once partner_id becomes `uuid` too (a no-op then);
28
- * the column type is the real fix and is tracked separately.
29
- *
30
- * Uses $queryRaw (raw SQL) deliberately: the per-service Prisma client is scoped
31
- * to that service's own models, so the proposals service's client has no
32
- * `member`/`user` model methods — but $queryRaw runs against the shared DB
33
- * regardless of the client's model scope. Returns a lowercased Set, checked
34
- * server-side only (emails never sent to the client). Fail-closed: any error
35
- * yields an empty set (no access).
36
4
  */
37
5
 
38
6
  import { getAppLogger } from "@teamscala/logger/app-loggers";
@@ -1,33 +1,16 @@
1
1
  /**
2
2
  * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The PROPOSAL content gate, expressed as config for the content-gate primitive
7
- * (content-gate/). No bespoke gate plumbing — just the proposal-specific bits:
8
- * which resource (resolved.proposal.id), who's authorized (issuing-org team +
9
- * recipient), what to seed (initialVisitor for the chat), and how to withhold
10
- * (replace the proposal body with an injected SignIn gate block + neutralize the
11
- * meta tags). The engine handles cookie/token/verify/orchestration.
12
- *
13
- * Universal: every proposal route exposes resolved.proposal, so this gates every
14
- * proposal (existing + new) with no per-page block or migration.
15
4
  */
16
5
 
17
6
  import type { AccessConfig, ContentGateConfig } from "./content-gate/config-types.ts";
18
- import { signAccessToken, verifyAccessToken } from "./content-gate/token.ts";
7
+ import { signAccessToken, verifyAccessToken } from "@teamscala/agreement-generation/content-gate-token";
19
8
  import { resolveProposalAuthorizedEmails } from "./proposal-authorized-emails.ts";
20
9
  import { propTtlMs } from "./gate-ttls.ts";
21
10
  import { getAppLogger } from "@teamscala/logger/app-loggers";
22
11
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
23
12
  import * as v from "valibot";
24
13
 
25
- /**
26
- * Proposal gate display + behaviour, DB-driven (config/content-gate-proposal),
27
- * never hardcoded. requireOtp is the per-gate OTP switch (proposals default
28
- * off — email-only; agreements opt in). A missing/invalid row falls back to the
29
- * proposal-appropriate defaults so the gate UI ALWAYS renders.
30
- */
31
14
  interface ProposalGateSettings {
32
15
  title: string;
33
16
  subtitle: string;
@@ -89,8 +72,6 @@ function buildGateBlock(
89
72
  gateContextField: "proposalId",
90
73
  gateContextValue: proposalId,
91
74
  gateAction: "/api/proposal-access",
92
- // Display + behaviour are DB-driven (config/content-gate-proposal),
93
- // not hardcoded. requireOtp is the per-gate OTP switch.
94
75
  title: settings.title,
95
76
  subtitle: settings.subtitle,
96
77
  gateRequireOtp: settings.requireOtp,
@@ -111,26 +92,14 @@ export const proposalGateConfig: ContentGateConfig = {
111
92
  const email = verifyAccessToken(token, PROP_COOKIE, resourceId, nowMs);
112
93
  return email ? { email } : null;
113
94
  },
114
- // Baked-in unified auth (engine default): a logged-in viewer whose session
115
- // email is in this set (issuing-org team ∪ recipient ∪ partner) is released
116
- // with no email/OTP entry. Just exposing the resolver opts this gate into the
117
- // platform-wide behaviour — no per-gate session code.
118
95
  resolveAuthorizedEmails: resolveProposalAuthorizedEmails,
119
96
  async onAuthorized(resolved, _resourceId, { email }) {
120
- // Seed the verified viewer identity (building-applications initialVisitor).
121
97
  (resolved as { initialVisitor?: { email: string } }).initialVisitor = {
122
98
  email,
123
99
  };
124
- // THE ASSISTANT IS NO LONGER INJECTED HERE. It is declared on the
125
- // page_route row (`assistant`) and applied once, centrally, after every
126
- // gate has run — so the bubble is a setting rather than a line of code
127
- // inside one resource type's gate. This call was the reason a third
128
- // resource type could not have an assistant without editing service-boot.
129
100
  },
130
101
  async onUnauthorized(resolved, resourceId) {
131
102
  const settings = await loadProposalGateSettings();
132
- // Replace the proposal BODY with the gate block — no proposal content
133
- // reaches the HTML. Header/footer chrome stays branded.
134
103
  const page = resolved.page as
135
104
  | (PuckHolder & {
136
105
  meta_title?: unknown;
@@ -144,18 +113,11 @@ export const proposalGateConfig: ContentGateConfig = {
144
113
  buildGateBlock(resourceId, brandPrimaryColor(resolved), settings),
145
114
  ];
146
115
  }
147
- // Neutralize the PAGE record's meta too: page.meta_title/meta_description
148
- // feed pageData.meta → the <title>/og tags AND the __PAGE_DATA__ hydration
149
- // JSON. Without this the proposal topic + partner name leak via meta even
150
- // though the body is withheld (the 2026-06-13 meta-leak: a gated proposal
151
- // still exposed "Webinar Campaign · in partnership with <partner>").
152
116
  if (page) {
153
117
  page.meta_title = "Private proposal";
154
118
  page.meta_description = "";
155
119
  page.name = "Private proposal";
156
120
  }
157
- // Neutralize the proposal record so the meta_context resolver + any block
158
- // reading it can't leak name/partner/cover/recipient.
159
121
  const prop = resolved.proposal as Record<string, unknown> | undefined;
160
122
  if (prop) {
161
123
  prop.name = "Private proposal";
@@ -164,13 +126,6 @@ export const proposalGateConfig: ContentGateConfig = {
164
126
  prop.prepared_for = "";
165
127
  prop.cover_image_url = "";
166
128
  }
167
- // Neutralize the resolved headerData too: it carries the recipient's
168
- // name (row1Value = prepared_for) and title, resolved during the loader
169
- // phase BEFORE this gate ran — without this, __PAGE_DATA__ ships the
170
- // recipient name to every unauthorised visitor while the body is
171
- // withheld. Labels stay (generic words); values go. Measured 2026-09-03:
172
- // the gate floor's hydration payload carried the recipient name to
173
- // anonymous visitors on the exact page this outage was about.
174
129
  const headerData = resolved.headerData as Record<string, unknown> | undefined;
175
130
  if (headerData) {
176
131
  headerData.title = "";
@@ -178,12 +133,6 @@ export const proposalGateConfig: ContentGateConfig = {
178
133
  headerData.row2Value = "";
179
134
  headerData.row3Value = "";
180
135
  }
181
- // Neutralize the CRM contact record too. The v8 adapter ignores select
182
- // (the full record returns), so without this the gate floor's hydration
183
- // payload ships the recipient's whole contact row — first/last/full
184
- // name, emails, phones, CRM ids — to every unauthorised visitor. The
185
- // company fields stay: the header's Organisation row shows the company,
186
- // which is brand chrome, not recipient identity.
187
136
  const contact = resolved.contact as Record<string, unknown> | undefined;
188
137
  if (contact) {
189
138
  const keep = new Set(["id", "company_id", "company_name", "company_website"]);
package/src/quiz-gate.ts CHANGED
@@ -1,46 +1,16 @@
1
1
  /**
2
2
  * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The PUBLIC QUIZ identity gate, expressed as config for the content-gate
7
- * primitive. Like the GPT gate this is lead capture, not access control: every
8
- * email is a valid visitor. Its job is to make the visitor KNOWN AT SSR so the
9
- * page's identity blocks hide themselves and the quiz blocks reveal themselves
10
- * — by block condition on `initialVisitor`, never by a component deciding which
11
- * screen to render.
12
- *
13
- * That is the whole point of this file existing. The quiz identity form used to
14
- * be a composite React component that hand-assembled a logo, a heading, three
15
- * inputs and a button, and then branched in TypeScript on whether a visitor was
16
- * known. Every one of those pieces is already a Puck block, and the branch is
17
- * already expressible as a block condition — what was missing was only the
18
- * server half: something to turn a submitted email into a visitor the next
19
- * render can see. This is that half, and it is 60 lines of config because the
20
- * engine (content-gate/gate.ts + access.ts) already does the work.
21
- *
22
- * Flow: the composed form POSTs {quizId, name, email, phone} to
23
- * /api/quiz-access (openCapture → any email matches); the access endpoint signs
24
- * a `quiz_token` cookie and redirects back. On the next request runContentGate
25
- * verifies it for this assessment and seeds resolved.initialVisitor.
26
4
  */
27
5
 
28
6
  import type { AccessConfig, ContentGateConfig } from "./content-gate/config-types.ts";
29
- import { signAccessToken, verifyAccessTokenIdentity } from "./content-gate/token.ts";
7
+ import { signAccessToken, verifyAccessTokenIdentity } from "@teamscala/agreement-generation/content-gate-token";
30
8
  import { quizTtlMs } from "./gate-ttls.ts";
31
9
 
32
10
  const QUIZ_COOKIE = "quiz_token";
33
- /**
34
- * The ENTRY MARKER: proves this request is the redirect the identity submit
35
- * produced, not a later load. Operator ruling 2026-09-14: a refresh must return
36
- * to the identity gate — the visitor starts at Name/Email on every fresh load,
37
- * and only the submit's own redirect releases the quiz. The marker is set by
38
- * the access endpoint with the token and cleared by the render it authorized.
39
- */
40
11
  const QUIZ_ENTRY_COOKIE = "quiz_token_entered";
41
12
  const QUIZ_ACCESS_PATH = "/api/quiz-access";
42
13
 
43
- /** The assessment this page is for, from the quiz-with-brand loader's graph. */
44
14
  function quizId(resolved: Record<string, unknown>): string {
45
15
  const quiz = resolved.quiz as { id?: unknown } | null | undefined;
46
16
  return typeof quiz?.id === "string" ? quiz.id : "";
@@ -1,54 +1,6 @@
1
1
  /**
2
- * @system ui-service-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Builds the shared React/Puck document envelope and substitutes the current
7
- * content-hashed asset URLs from `dist/client/asset-manifest.json`.
8
- *
9
- * The envelope is runtime-owned. UI services are Puck compositions and must
10
- * not carry or generate a per-service `src/index.html`; requiring that file
11
- * creates a second rendering surface that can disappear independently of the
12
- * Hono + React/Puck runtime.
13
- *
14
- * Build-time produces hashed filenames (e.g. `styles-abc123.css`); the
15
- * static-asset handler serves them with `cache-control: immutable`. New
16
- * deployments emit new hashes → new URLs → browsers refetch without ever
17
- * holding a stale copy. Without the substitution, a fixed
18
- * `/assets/styles.css` paired with `immutable` would pin every visitor to
19
- * whatever version they first downloaded.
20
- *
21
- * Cached per service process, keyed on the manifest's mtime. The cache MUST
22
- * invalidate when `asset-manifest.json` changes: `buildClientAssets` can
23
- * rebuild the bundle AFTER the first request — producing a new content hash,
24
- * rewriting the manifest, AND deleting the old hashed bundle
25
- * (`removeStaleHashedFiles`). A lifetime-pinned HTML cache then keeps serving
26
- * the OLD hash, which now 404s → hydration never runs → the page renders SSR
27
- * HTML but is dead client-side (the doc-editor overlay never mounts; scala-docs
28
- * 2026-06-13, exposed by the ui-foundation publish-storm's repeated rebuilds).
29
- * Re-stat'ing the tiny manifest per request is negligible (OS-cached, no
30
- * network); the substituted HTML is reused while the mtime is unchanged.
31
- *
32
- * NO SERVED HTML WITHOUT A VERIFIED MANIFEST: when the manifest is absent,
33
- * unreadable, or names files that do not exist, this module THROWS rather
34
- * than emitting HTML — the fixed-name fallback (`/assets/styles.css`,
35
- * `/assets/hydrate.js`) it once served names files NOTHING builds, so a
36
- * 200 page with those URLs was a stylesheet 404 wearing a working page's
37
- * clothes. Measured 2026-09-01: proposals.scala.business served a
38
- * client-facing proposal unstyled while Railway reported the deploy
39
- * SUCCESS. The readiness flip now gates on `verifyClientBuildAssets`
40
- * (build-client-assets/manifest.ts), so this throw is defence in depth for
41
- * a manifest that vanishes mid-process; the page-server render catch turns
42
- * it into the branded error page, never a 200 with dead URLs.
43
- *
44
- * NO-SILENT-REVERTS: when the manifest is a TOMBSTONE (the last client build
45
- * failed), the served HTML is stamped with a `<meta name="x-client-build-status"
46
- * content="failed">` tag in <head> so any operator / chrome-devtools / theme
47
- * probe inspecting the page can see the served bundle is STALE (a published fix
48
- * that reached node_modules but failed to bundle is silently served from the
49
- * prior bundle otherwise — scala-agents 2026-07-18, the white-on-white toolbar
50
- * regression that looked "not fixed" while the bundle kept serving pre-fix
51
- * bytes across restarts).
52
4
  */
53
5
 
54
6
  import { createCache } from "@teamscala/cache/create-cache";
@@ -1,14 +1,6 @@
1
1
  /**
2
2
  * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Readiness gate — returns 503 + Retry-After: 1 until the boot pipeline
7
- * flips the ready flag true as its last step. Registered as the FIRST
8
- * middleware in both UI and backend boot paths per readiness-gated-boot.
9
- *
10
- * The gate fails open: if it ever throws, the request passes through to
11
- * the normal handler rather than producing a hard 500.
12
4
  */
13
5
 
14
6
  import { getAppLogger } from "@teamscala/logger/app-loggers";
@@ -1,16 +1,6 @@
1
1
  /**
2
- * @system server-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Walks `service_install.api_routes[]` and binds each entry to its
7
- * registered handler (via mountRegistry.handlers, populated by server_packages
8
- * during their mount phase). Unresolved handler_slug values are fatal — the
9
- * service cannot boot with declared routes pointing at nothing.
10
- *
11
- * Injects x-remote-port header from Bun's getConnInfo so downstream
12
- * handlers (MCP route) can resolve caller identity via process-tree
13
- * walking when explicit caller headers are absent.
14
4
  */
15
5
 
16
6
  import type { ServiceInstallConfig } from "./load-service-install-row";
@@ -1,11 +1,6 @@
1
1
  /**
2
- * @system server-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Registers process-level crash handlers (unhandledRejection, uncaughtException)
7
- * for a given service. Each handler logs the error with the service slug for
8
- * identification. Per constitution crash-isolation.
9
4
  */
10
5
 
11
6
  import { getAppLogger } from "@teamscala/logger/app-loggers";
@@ -1,17 +1,6 @@
1
1
  /**
2
- * @system ui-service-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit directly
5
- *
6
- * Page route registration — reads `page_route` registry rows for the
7
- * current service and delegates to `registerRegistryPageRoute`.
8
- *
9
- * All routes are registry-driven (Phase 1): each `page_route` row pairs
10
- * a URL pattern with a `resource_loader` graph that resolves data in
11
- * wave-parallel via `resolveFromLoaderConfig`. No deploy-time shell
12
- * variant routes — the old `service_install.page_routes[]` Phase 2 path
13
- * was removed when every active service migrated to registry-driven
14
- * `page_route` rows (all 9 services carry empty arrays).
15
4
  */
16
5
 
17
6
  import { BootFatalError } from "@teamscala/os/errors/boot-error";
@@ -1,31 +1,8 @@
1
1
  /**
2
- * @system ui-service-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Registers the system server-client for page-server SSR rendering. Composes
7
- * the platform-owned routers into the service's generated `appRouter` and
8
- * stashes the resulting client via setServerClient. Called once at boot before
9
- * any page route handler fires.
10
- *
11
- * Handwritten because no machine-readable contract generates it: this is the
12
- * EXECUTOR half of a rows+executor pair (`business-logic-is-rows-plus-executors`),
13
- * whose declared half is the `service_install`/`service_schema` rows read at
14
- * boot. There is no supplier spec, CLI or registry row that could emit this
15
- * boot-time wiring, so it stays hand-written; the row half remains the source.
16
4
  */
17
5
 
18
- // REFUSE, DO NOT SKIP. `existsSync(routerPath)` being false used to fall straight through with appRouter=null and still register a client + log
19
- // success. That client carries only the three platform routers below and NO CRUD models, which is indistinguishable from a healthy one until a
20
- // page asks it for a model — at which point resolve-from-loader-config throws MissingLoaderModelCapabilityError, far from the actual fault.
21
- //
22
- // Measured 2026-08-20: scala-quiz and scala-forms served 500s on every data-backed page while /health returned 200 and this line logged "ORPC
23
- // serverClient registered". The build had produced no generated router (its railpack.json ran registry codegen against an unreachable
24
- // *.railway.internal host), and nothing anywhere said so.
25
- //
26
- // A service whose service_schema DECLARES models and has no generated router is not degraded, it is non-functional — so this fails the boot
27
- // rather than reporting a state it has not verified (`a-component-may-not-report-a-state-it-has-not-verified`). A service
28
- // that declares no models (daemon/portal shells owning no DB schema) still passes through cleanly.
29
6
 
30
7
  import { join } from "node:path";
31
8
  import { getAppLogger } from "@teamscala/logger/app-loggers";
@@ -43,16 +20,6 @@ export async function registerPageServerClient(
43
20
  };
44
21
  const { createRouterClient } = await import("@orpc/server");
45
22
  const { getPrisma } = await import("@teamscala/db/prisma-registry");
46
- // Load the composed CRUD router via getAppRouter() — the SAME registered
47
- // instance the /api/rpc mount uses (mount-orpc-router.ts). The previous code
48
- // did a raw second `import(cwd + "prisma/generated/orpc/routers/index.ts")`,
49
- // re-loading the generated routers under a different module specifier than
50
- // the mount already used; in the full boot process that re-execution throws,
51
- // and the throw was swallowed by a SILENT catch — leaving the page-server
52
- // client without the CRUD models, so every data-backed page 500s
53
- // ("client.<model> is undefined", the scala-sign prod outage). existsSync
54
- // gates the legitimate no-router case (daemon/portal services that own no DB
55
- // schema) so it stays a clean skip, never a misleading warn.
56
23
  const { existsSync } = await import("node:fs");
57
24
  const routerPath = join(
58
25
  process.cwd(),
@@ -64,8 +31,6 @@ export async function registerPageServerClient(
64
31
  const { getAppRouter } = await import("@teamscala/orpc/app-router");
65
32
  appRouter = ((await getAppRouter()) as Record<string, unknown>) ?? null;
66
33
  } catch (err) {
67
- // A genuine load failure here means every data-backed page 500s, so it
68
- // must surface, never be swallowed (protective-layers-fail-explicitly).
69
34
  log.warn(
70
35
  `[scala-install:${serviceSlug}] appRouter load failed — data-backed pages will 500: ${err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err)}`,
71
36
  );
@@ -73,28 +38,6 @@ export async function registerPageServerClient(
73
38
  }
74
39
  const declaredModels = await (async (): Promise<string[]> => {
75
40
  const capSlug = toCapabilitySlug(serviceSlug);
76
- // LOOK IT UP BY CAPABILITY, NOT BY DEPLOYABLE NAME. A service_schema
77
- // row describes the APPLICATION's data surface, so there is ONE row
78
- // for both lanes; the dev lane arrives here as
79
- // `scala-projects-bun-dev`. Both sides normalize through
80
- // toCapabilitySlug, so the join is tier-agnostic by construction.
81
- // See ./capability-slug.ts.
82
- //
83
- // READ WITH THE REAL SCHEMA, NOT `undefined`. The previous call passed
84
- // `undefined as never`, so v.parse(undefined, raw) threw a TypeError
85
- // (schema is not a schema) on EVERY service, the bare catch below turned
86
- // that into "declares nothing", and the refusal at the bottom NEVER
87
- // fired — a service declaring 18 models booted "0 declared model(s)"
88
- // and served 500s. (measured scala-sign 2026-09-24/25: row declares 18,
89
- // boot logged 0.) loadOptionalRegistryConfig parses against
90
- // ServiceSchemaConfigSchema and returns null ONLY for a missing row.
91
- //
92
- // A MISSING ROW is legitimately "declares nothing" (daemon/portal
93
- // shells own no DB models) and passes through clean. A READ FAILURE is
94
- // NOT "declares nothing" — it fails the boot naming the error, so the
95
- // database can never quietly become "no models" and let a router-less,
96
- // data-backed service boot and serve 500s
97
- // (`a-component-may-not-report-a-state-it-has-not-verified`).
98
41
  const { loadOptionalRegistryConfig } = await import(
99
42
  "@teamscala/db/registry/load-config"
100
43
  );
@@ -145,8 +88,6 @@ export async function registerPageServerClient(
145
88
  const { PlatformIdentitySchema } = await import(
146
89
  "@teamscala/db-validation/registry-schemas/platform-identity"
147
90
  );
148
- // Platform org id is a registry value (registries-are-source-of-truth),
149
- // not a hardcoded literal — read from config/platform-identity.
150
91
  const platformIdentity = await loadRegistryConfig(
151
92
  "config",
152
93
  "platform-identity",
@@ -166,19 +107,13 @@ export async function registerPageServerClient(
166
107
  } as never,
167
108
  );
168
109
  mod.setServerClient(client);
169
- // Record declared-vs-present capability for /health. Property lookups only.
170
110
  (mod as unknown as { recordServerClientCapability?: (m: readonly string[]) => void })
171
111
  .recordServerClientCapability?.(declaredModels);
172
- // State the model count. "registered" alone was true in the outage and told
173
- // nobody anything; a zero here is now visible in the boot log.
174
112
  log.info(
175
113
  `[scala-install:${serviceSlug}] ORPC serverClient registered (system:super-admin) — ` +
176
114
  `${Object.keys(composed).length} router(s), ${declaredModels.length} declared model(s)`,
177
115
  );
178
116
  } catch (err) {
179
- // RETHROW. Swallowing this into a warn is what let a service boot, pass its
180
- // healthcheck and serve 500s on every page. The boot must fail so the
181
- // supervisor and the deploy healthcheck can see it.
182
117
  log.error(
183
118
  `[scala-install:${serviceSlug}] serverClient registration FAILED — data-backed pages cannot render: ${err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err)}`,
184
119
  );
@@ -1,33 +1,10 @@
1
1
  /**
2
- * @system ui-service-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * SSR-prefetch the signed-in user's AI-chat conversation so a click-opened AI
7
- * panel renders the real conversation on first paint. Extracted from
8
- * register-registry-page-route.ts (single-purpose-file split).
9
- */
10
- /**
11
- * SSR-prefetch the signed-in user's AI-chat conversation so a CLICK-opened AI
12
- * panel renders the real conversation on first paint — never a loading gate
13
- * (ssr-then-live-handoff). The AiChat block lives on the /ai PANEL page, which
14
- * isn't rendered when the panel is closed, so its block serverLoader can't run
15
- * at page-load; this prefetches server-side (regardless of panel open-state) +
16
- * stamps pageData.aiChatSession, which AiChat reads on mount. The agent is
17
- * resolved from the /ai panel page's own AiChat block (single source of truth —
18
- * no duplicate config to drift). Fired in parallel with the post-processors +
19
- * bounded by their window; a failure degrades silently to the client-side
20
- * createOrRestore fallback, never an error page.
21
4
  */
22
5
 
23
6
  import { getServerClient } from "../page-server-orpc/server-client.ts";
24
7
 
25
- /**
26
- * Deep-find the serving `website_id` in the loader graph (resolved). The
27
- * Utility/Portal shell carries the website id (not a slug) under a loader
28
- * source key; the client RegionPageSwap reads it from loaderData.*.website_id
29
- * the same way. Bounded walk (cycle-safe) over the resolved loader results.
30
- */
31
8
  function findWebsiteId(root: unknown): string | undefined {
32
9
  if (!root || typeof root !== "object") return undefined;
33
10
  const seen = new Set<unknown>();
@@ -1,75 +1,10 @@
1
1
  /**
2
- * @system ui-service-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The three brand-override paths of a registered page route: the /sign-in
7
- * callback-org override, the signed-in session-org personalisation
8
- * (platform pages only), and the brand_org_path resource-owner override.
9
- *
10
- * Split out of register-registry-page-route.ts (924 lines) — the loadData
11
- * closure segments moved byte-identical; only the closure variables became
12
- * explicit parameters.
13
4
  */
14
5
 
15
- /**
16
- * /sign-in brand override: a shared org-scoped link 302→/sign-in for the
17
- * unauthed crawler (WhatsApp), which saw the service's default website
18
- * brand (the platform owner). The callbackURL carries the destination org
19
- * — override resolved.brand (before the title/meta/og are built from it)
20
- * so the page reflects the org the visitor is signing in to. Scoped to
21
- * /sign-in; resource pages keep their own brand.
22
- *
23
- * The org is segment 1 of the canonical portal shape /<websiteId>/<orgId>/
24
- * <path>, NOT segment 0 (the website id): reading segment 0 fed a website id
25
- * into fetchOrganisationPageBranding, which resolved no org and left the
26
- * default (super-admin) brand — the cross-tenant mis-branding this override
27
- * exists to prevent (portal-branding-resolves-page-org-first, 2026-07-21).
28
- * Shared helper so this and bind-page-handler cannot drift on the answer.
29
- */
30
6
 
31
- /**
32
- * brand-is-org-owned-full-scheme, reconciled with page-org-first
33
- * (portal-branding-resolves-page-org-first): the session override now
34
- * personalizes ONLY platform pages (page org === website owning-org).
35
- * Tenant pages keep their resolved page-org brand — the page is
36
- * authoritative, the menu switcher changes which tenant's pages you
37
- * view. Anonymous/public (no session) keeps the resolved brand. Same
38
- * fetchOrganisationPageBranding pattern as the /sign-in brand override.
39
- *
40
- * A PUBLIC PAGE IS NEVER PERSONALISED TO THE READER'S ORG. Personalising
41
- * the brand is right for a portal — you are looking at your own
42
- * workspace. It is wrong for a public document, which belongs to the org
43
- * that PUBLISHED it and must look identical to every reader.
44
- *
45
- * Measured 2026-08-17: the Launcha Authority Blueprint, owned by GMG
46
- * APAC, rendered correctly for an anonymous customer and in GBG Indo's
47
- * navy with the scala logo for any signed-in staff member — so the two
48
- * people checking it before it went to clients were the only two who
49
- * could not see what a client sees. Silent, and inverted: the reviewers
50
- * got the wrong page.
51
- */
52
7
 
53
- /**
54
- * BRAND FROM THE ORG THAT OWNS THE RESOURCE, not the website's owner.
55
- * A public resource page (an assessment report, a shared document) is
56
- * hosted on a shared website but BELONGS to a tenant. resolvePageBrand
57
- * reads pageData.orgId, which is the WEBSITE's org, so every tenant's
58
- * report rendered the host org's logo and palette.
59
- *
60
- * Measured 2026-08-17: the Launcha Authority Blueprint is owned by GMG
61
- * APAC (#FF4500, launcha logo) and rendered with GBG Indo's navy and
62
- * scala mark, because both pages live on the shared "Public Pages"
63
- * site. The loader had already resolved the right org — nothing
64
- * consumed it.
65
- *
66
- * `brand_org_path` names where the owning org id lives in the resolved
67
- * loader data (e.g. "quiz.organisation_id"), so which resource owns the
68
- * brand is a ROW, never a per-service branch
69
- * (`business-logic-is-rows-plus-executors`). Absent or unresolvable,
70
- * the website's org stands — this can only ever narrow the brand to a
71
- * more specific owner, never blank it.
72
- */
73
8
 
74
9
  import { fetchOrganisationPageBranding } from "@teamscala/db/fetch-organisation-page-branding";
75
10
  import { getPlatformOrgId } from "@teamscala/db/registry/platform";
@@ -86,9 +21,6 @@ function spreadBranding(
86
21
  return {
87
22
  ...existing,
88
23
  companyName: branding.company_name,
89
- // Friendly name is the customer-facing one; falling back to the
90
- // legal entity fed "GMG APAC" to every consumer that correctly
91
- // read companyFriendlyName (e.g. logoAlt in the Puck host codegen).
92
24
  companyFriendlyName:
93
25
  branding.company_friendly_name ?? branding.company_name,
94
26
  primaryColor: branding.brand_primary_color ?? existing?.primaryColor,
@@ -126,7 +58,6 @@ export async function applySignInBrandOverride(
126
58
  }
127
59
  }
128
60
  } catch {
129
- // Branding fetch failed — keep the default brand (no regression).
130
61
  }
131
62
  }
132
63
 
@@ -140,31 +71,9 @@ export async function applySessionOrgBranding(
140
71
  renderMode !== "public" &&
141
72
  sessionOrgId &&
142
73
  sessionOrgId !== (pageData.orgId as string | undefined) &&
143
- // Page-org-first (portal-branding-resolves-page-org-first): the
144
- // session override personalizes ONLY the shared PLATFORM website
145
- // (super-admin org). A TENANT-owned site (a customer GPT/quiz/portal)
146
- // keeps its page-org brand regardless of the viewer session org. The
147
- // old pageData.orgId===website.org mis-fired for a tenant whose page org equals their own website org.
148
74
  platformOrgId ===
149
75
  ((pageData.website as { organisation_id?: string | null } | undefined)
150
76
  ?.organisation_id ?? undefined) &&
151
- // ...AND the PAGE itself must be platform-owned. A platform-owned
152
- // WEBSITE also hosts tenant DOCUMENTS — an agreement, a proposal, a
153
- // signed contract — and those belong to the org that published them.
154
- // Testing only the website let the viewer's own org repaint a tenant
155
- // document, which is the same defect this rule already fixed once at
156
- // the website level (2026-08-17, the Launcha Authority Blueprint).
157
- //
158
- // It recurred on sign.scala.business, whose website IS owned by the
159
- // platform org (c503629a), so the website test passed and every
160
- // signed-in staff viewer saw a GMG APAC agreement painted in their own
161
- // org's brand — while an anonymous client saw it correctly. That is
162
- // the worst possible shape: the only people who cannot see what the
163
- // client sees are the people reviewing it before it is sent.
164
- //
165
- // A public document belongs to the org that PUBLISHED it and must look
166
- // identical to every reader, so a page whose org is not the platform's
167
- // is never personalised.
168
77
  platformOrgId === (pageData.orgId as string | undefined)
169
78
  ) {
170
79
  try {
@@ -178,7 +87,6 @@ export async function applySessionOrgBranding(
178
87
  pageData.brand = spreadBranding(existing, branding);
179
88
  }
180
89
  } catch {
181
- // Branding fetch failed — keep the default brand (no regression).
182
90
  }
183
91
  }
184
92
  }
@@ -202,12 +110,6 @@ export async function applyBrandOrgPath(
202
110
  }
203
111
  if (typeof cursor === "string" && cursor.length > 0) {
204
112
  pageData.orgId = cursor;
205
- // orgId alone is not enough. resolvePageBrand fills only the
206
- // accent/secondary/muted gaps from the org; primaryColor, logo and
207
- // companyName come from pageData.brand, which was built from the
208
- // website's org. Replace those too, the same way the session
209
- // override above does, or the page keeps the host's logo while
210
- // claiming the tenant's org.
211
113
  try {
212
114
  const owner = await fetchOrganisationPageBranding(
213
115
  getServerClient() as never,
@@ -218,7 +120,6 @@ export async function applyBrandOrgPath(
218
120
  pageData.brand = spreadBranding(existing, owner);
219
121
  }
220
122
  } catch {
221
- // Branding fetch failed — keep the website brand (no regression).
222
123
  }
223
124
  } else {
224
125
  log.warn(
@@ -1,19 +1,8 @@
1
1
  /**
2
- * @system ui-service-boot
2
+ * @system service-boot
3
3
  * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Summarises the resource_loader config a route captured at boot, for the
7
- * route-registration log line. Registration reads the loader row ONCE into
8
- * the route closure, so an edit to the row silently does not reach the
9
- * running service (measured 2026-08-18: a footer region added to a report
10
- * produced no footer and no diagnostic until a cold wake). Naming the
11
- * captured shape in the boot log makes the row-vs-running-service gap a
12
- * one-glance check in the service logs, and the restart contract is stated
13
- * where the operator will be looking when the edit "doesn't work".
14
4
  */
15
5
 
16
- /** Structural slice of the ResourceLoaderConfigSchema output this unit reads. */
17
6
  interface LoaderConfigSlice {
18
7
  sources?: { key: string }[];
19
8
  region_slots?: string[];