@teamscala/service-boot 0.23.516 → 0.23.518

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 (86) hide show
  1. package/package.json +232 -1127
  2. package/src/agreement-gate/settings.ts +8 -8
  3. package/src/apply-logger-config.test.ts +46 -0
  4. package/src/apply-logger-config.ts +31 -0
  5. package/src/asset-proxy.test.ts +9 -5
  6. package/src/auth/sign-out.ts +122 -0
  7. package/src/build-client-assets/hydrate-bundle.ts +14 -4
  8. package/src/build-client-assets/manifest.ts +5 -18
  9. package/src/build-client-assets/styles-build.test.ts +11 -8
  10. package/src/build-client-assets/styles-build.ts +14 -63
  11. package/src/build-client-assets.test.ts +0 -161
  12. package/src/build-client-assets.ts +20 -67
  13. package/src/content-gate/token.ts +9 -2
  14. package/src/install-logging.ts +10 -26
  15. package/src/mount-orpc-router.ts +8 -2
  16. package/src/mutable-app-ref.ts +0 -7
  17. package/src/page-server-loader-graph.test.ts +31 -0
  18. package/src/page-server-loader-graph.ts +21 -0
  19. package/src/pg-boss-mount.ts +3 -16
  20. package/src/readiness-gate.ts +0 -4
  21. package/src/register-api-routes.ts +8 -17
  22. package/src/register-page-server-client.ts +6 -0
  23. package/src/register-registry-page-route/meta-title.test.ts +50 -0
  24. package/src/register-registry-page-route/meta-title.ts +1 -1
  25. package/src/register-registry-page-route/page-data-shared.ts +7 -11
  26. package/src/register-registry-page-route/resolve-request-graph.test.ts +1 -1
  27. package/src/register-registry-page-route/resolve-request-graph.ts +1 -2
  28. package/src/register-registry-page-route.ts +14 -16
  29. package/src/resolve-brand-favicon.test.ts +63 -0
  30. package/src/resolve-theme-mode.test.ts +51 -0
  31. package/src/resolvers/fetch-org-brand.test.ts +48 -0
  32. package/src/resolvers/website-by-host-map.test.ts +63 -0
  33. package/src/resolvers/website-by-host-map.ts +15 -3
  34. package/src/run-boot-hook-list.test.ts +47 -0
  35. package/src/run-boot-hooks.test.ts +56 -0
  36. package/src/scala-tools-currency-watchdog.test.ts +43 -0
  37. package/src/scala-tools-currency-watchdog.ts +0 -5
  38. package/src/transfers/presigned-upload.ts +165 -0
  39. package/src/two-phase-minimal-app.test.ts +24 -0
  40. package/src/two-phase-readiness.test.ts +102 -0
  41. package/src/types.test.ts +20 -0
  42. package/src/ui-service.test.ts +24 -0
  43. package/src/ws-boot-self-test.test.ts +43 -0
  44. package/src/build-client-assets/input-fingerprint.ts +0 -92
  45. package/src/build-client-assets/input-hashing.ts +0 -206
  46. package/src/handlers/bun-stable-watch.ts +0 -86
  47. package/src/handlers/messaging-send-reconcile-logic.test.ts +0 -79
  48. package/src/handlers/messaging-send-reconcile-logic.ts +0 -83
  49. package/src/handlers/messaging-send-recording-logic.test.ts +0 -86
  50. package/src/handlers/messaging-send-recording-logic.ts +0 -68
  51. package/src/handlers/messaging-send-recording-watch.test.ts +0 -91
  52. package/src/handlers/messaging-send-recording-watch.ts +0 -244
  53. package/src/handlers/messaging-send-sweep-selection.test.ts +0 -47
  54. package/src/handlers/messaging-send-sweep-selection.ts +0 -35
  55. package/src/handlers/platform-fault-escalation/config.ts +0 -95
  56. package/src/handlers/platform-fault-escalation/delivery.ts +0 -327
  57. package/src/handlers/platform-fault-escalation/event-log-queries.ts +0 -182
  58. package/src/handlers/platform-fault-escalation/orpc-client.ts +0 -114
  59. package/src/handlers/platform-fault-escalation/probe.test.ts +0 -63
  60. package/src/handlers/platform-fault-escalation/probe.ts +0 -19
  61. package/src/handlers/platform-fault-escalation-logic.ts +0 -382
  62. package/src/handlers/platform-fault-escalation-refresh.test.ts +0 -72
  63. package/src/handlers/platform-fault-escalation.test.ts +0 -497
  64. package/src/handlers/platform-fault-escalation.ts +0 -246
  65. package/src/handlers/projects-digest.ts +0 -198
  66. package/src/handlers/quiz-research.ts +0 -178
  67. package/src/handlers/registry-stale-inactive-sweep.ts +0 -82
  68. package/src/handlers/scala-tools-currency-watch.test.ts +0 -65
  69. package/src/handlers/scala-tools-currency-watch.ts +0 -162
  70. package/src/handlers/singleton-tools-currency-watch.test.ts +0 -51
  71. package/src/handlers/singleton-tools-currency-watch.ts +0 -380
  72. package/src/handlers/sweep-event-log.ts +0 -98
  73. package/src/handlers/upstream-issue-sync.ts +0 -220
  74. package/src/handlers/whatsapp-liveness-handler.test.ts +0 -56
  75. package/src/handlers/whatsapp-liveness-logic.ts +0 -53
  76. package/src/handlers/whatsapp-liveness.test.ts +0 -78
  77. package/src/handlers/whatsapp-liveness.ts +0 -138
  78. package/src/interpolate-meta.ts +0 -123
  79. package/src/load-service-prisma-client.ts +0 -53
  80. package/src/loader-errors.ts +0 -41
  81. package/src/read-viewport-from-request.ts +0 -36
  82. package/src/register-page-server-client.test.ts +0 -185
  83. package/src/resolve-app-base-url.ts +0 -35
  84. package/src/resolve-from-loader-config.test.ts +0 -429
  85. package/src/resolve-from-loader-config.ts +0 -316
  86. package/src/resolve-service-context.ts +0 -88
@@ -17,25 +17,18 @@
17
17
  * Without this, `cache-control: immutable` on a fixed filename means
18
18
  * returning visitors keep their stale copy forever.
19
19
  *
20
- * Content-fingerprint cache: the CLIENT-BUNDLE DEPENDENCY CLOSURE (source
21
- * entrypoints + every @teamscala/* module/stylesheet the bundle reaches + the
22
- * resolved versions of the third-party deps the closure imports) is hashed by
23
- * CONTENT and stored in the manifest; the rebuild is skipped only when the
24
- * current closure hashes to the same fingerprint (cache-key-hashes-source-
25
- * inputs-only — never mtime; see input-fingerprint.ts for why mtime silently
26
- * served stale bundles on Railway). bun.lock is deliberately NOT hashed — it
27
- * encodes every @teamscala/* version including backend packages outside this
28
- * service's client bundle, so folding it in would rebuild on every publish
29
- * (the publish→rebuild storm; `no-uncontrolled-repetition-or-cascade`). A
30
- * restart after a real closure change pays the full build cost; unchanged
31
- * restarts skip. Stale hashed outputs from a prior build are removed in the
32
- * same pass so dist/client/ doesn't accumulate dead files.
33
- *
34
- * This module is the ORCHESTRATION unit: fingerprint check → host-wide lock
35
- * → parallel hydrate/CSS builds → manifest write (or failure tombstone). The
36
- * individual concerns live as siblings under ./build-client-assets/
37
- * (input-hashing, source-candidates, input-fingerprint, manifest,
38
- * hashed-output, hydrate-bundle, sourcemaps-upload, styles-build, build-lock)
20
+ * This module is the ORCHESTRATION unit: host-wide lock → parallel
21
+ * hydrate/CSS builds → manifest write (or failure tombstone). The
22
+ * build runs on EVERY boot: the content-fingerprint skip cache and its
23
+ * closure-walking hashers (input-fingerprint.ts, input-hashing.ts) are
24
+ * deleted. A skip is the only path that can serve a stale bundle, and
25
+ * it has done so twice (the mtime skip that baked stale bundles until
26
+ * the fingerprint replaced it, 2026-06-14) — no skip is the one shape
27
+ * that cannot skip wrongly, at the cost of a bounded per-boot build
28
+ * serialised by the host-wide lock below. The individual concerns live
29
+ * as siblings under ./build-client-assets/
30
+ * (source-candidates, manifest, hashed-output, hydrate-bundle,
31
+ * sourcemaps-upload, styles-build, build-lock)
39
32
  * — split when the parent held them all at 962 lines.
40
33
  */
41
34
 
@@ -44,9 +37,10 @@
44
37
  // manifest entry that points at a file we don't actually serve. If a build fails, write a TOMBSTONE manifest (no-silent-reverts) and throw.
45
38
  //
46
39
  // The tombstone preserves the LAST-SUCCESSFUL paths so SSR can keep serving the old bundle (better than a 404 hydrate), but marks itself
47
- // `buildFailed: true` + the error + `failedAt`, AND sets `inputFingerprint` to a FAILURE sentinel so the next boot's "manifest up-to-date (skip)"
48
- // check CANNOT match — the next boot is forced to retry the build explicitly (no silent skip on the stale manifest). The tombstone is the
49
- // on-disk failure signal; ui-service-boot adds the runtime signal (recordEvent → event-log → GlitchTip) and read-html-template stamps the
40
+ // `buildFailed: true` + the error + `failedAt`, AND — because every boot
41
+ // now rebuilds unconditionally — the failure is retried by construction;
42
+ // the tombstone is the on-disk failure signal; ui-service-boot adds the
43
+ // runtime signal (recordEvent → event-log → GlitchTip) and read-html-template stamps the
50
44
  // served HTML with the failure status. Without the tombstone, a build failure left the OLD manifest untouched and the dashboard served stale
51
45
  // code with NO signal that the published fix never reached the bundle (scala-agents 2026-07-18: ui-foundation@3.4.5 installed but the bundle
52
46
  // kept serving the old bytes across restarts while node_modules was mid-install — invisible until the operator hit the white-on-white
@@ -58,15 +52,10 @@ import { withFileLock } from "@teamscala/file-lock/with-lock";
58
52
  import { getAppLogger } from "@teamscala/logger/app-loggers";
59
53
  import { CLIENT_BUILD_LOCK, getClientBuildLockTimeoutMs } from "./build-client-assets/build-lock.ts";
60
54
  import { buildHydrateBundle } from "./build-client-assets/hydrate-bundle.ts";
61
- import { computeInputFingerprint } from "./build-client-assets/input-fingerprint.ts";
62
55
  import {
63
56
  readManifest,
64
57
  writeManifest,
65
58
  } from "./build-client-assets/manifest.ts";
66
- import {
67
- releaseFromHydratePath,
68
- uploadCachedSourceMaps,
69
- } from "./build-client-assets/sourcemaps-upload.ts";
70
59
  import { buildTailwindCss } from "./build-client-assets/styles-build.ts";
71
60
 
72
61
  export async function buildClientAssets(serviceSlug: string): Promise<void> {
@@ -74,41 +63,10 @@ export async function buildClientAssets(serviceSlug: string): Promise<void> {
74
63
  const cwd = process.cwd();
75
64
  const outDir = join(cwd, "dist", "client");
76
65
 
77
- const existingManifest = await readManifest(outDir);
78
- const fingerprint = await computeInputFingerprint(cwd);
79
- if (existingManifest && existingManifest.inputFingerprint === fingerprint) {
80
- // The bundle is fresh, but a fresh bundle with an UNVERIFIED source-map
81
- // upload must not stay that way forever: retry the upload from the
82
- // retained map cache on every boot until it verifies. Without this, one
83
- // failed boot-time upload (GlitchTip down, token missing at that boot)
84
- // permanently lost the release's maps — client stacks stayed minified
85
- // until the next real input change forced a rebuild.
86
- const recorded = existingManifest.sourceMaps?.status;
87
- const release = existingManifest.hydratePath
88
- ? releaseFromHydratePath(existingManifest.hydratePath)
89
- : null;
90
- if (recorded !== "uploaded" && release) {
91
- log.info(
92
- `[build-client-assets:${serviceSlug}] manifest up-to-date (skip); source maps previously ${recorded ?? "unrecorded"} — retrying upload for release ${release}`,
93
- );
94
- const sourceMaps = await uploadCachedSourceMaps(cwd, release, log);
95
- await writeManifest(outDir, { ...existingManifest, sourceMaps });
96
- } else {
97
- log.info(`[build-client-assets:${serviceSlug}] manifest up-to-date (skip)`);
98
- }
99
- return;
100
- }
101
- if (existingManifest && existingManifest.inputFingerprint !== fingerprint) {
102
- log.info(
103
- `[build-client-assets:${serviceSlug}] inputs changed (fingerprint ${existingManifest.inputFingerprint ?? "<none>"} → ${fingerprint}) — rebuilding client bundle`,
104
- );
105
- }
106
-
107
- // A build IS needed. Serialise it across every UI service on this host
66
+ // Serialise the build across every UI service on this host
108
67
  // (client-asset-builds-serialize-via-lock) so a publish-driven restart storm
109
68
  // becomes a short queue instead of N concurrent host-saturating builds. The
110
- // lock is acquired ONLY here — never on the up-to-date skip path above — and
111
- // FAILS OPEN: a lock-acquisition timeout builds unlocked rather than blocking
69
+ // lock FAILS OPEN: a lock-acquisition timeout builds unlocked rather than blocking
112
70
  // boot. A real build failure inside performBuild throws and propagates (the
113
71
  // service refuses to start); only the file-lock timeout falls through.
114
72
  const lockTimeoutMs = await getClientBuildLockTimeoutMs();
@@ -116,7 +74,7 @@ export async function buildClientAssets(serviceSlug: string): Promise<void> {
116
74
  await withFileLock(
117
75
  CLIENT_BUILD_LOCK,
118
76
  { timeoutMs: lockTimeoutMs },
119
- () => performBuild(serviceSlug, cwd, outDir, log, fingerprint),
77
+ () => performBuild(serviceSlug, cwd, outDir, log),
120
78
  );
121
79
  } catch (err) {
122
80
  const isLockTimeout =
@@ -126,7 +84,7 @@ export async function buildClientAssets(serviceSlug: string): Promise<void> {
126
84
  log.warn(
127
85
  `[build-client-assets:${serviceSlug}] build lock timed out after ${lockTimeoutMs}ms — building unlocked (fail-open)`,
128
86
  );
129
- await performBuild(serviceSlug, cwd, outDir, log, fingerprint);
87
+ await performBuild(serviceSlug, cwd, outDir, log);
130
88
  }
131
89
  }
132
90
 
@@ -135,7 +93,6 @@ async function performBuild(
135
93
  cwd: string,
136
94
  outDir: string,
137
95
  log: ReturnType<typeof getAppLogger>,
138
- inputFingerprint: string,
139
96
  ): Promise<void> {
140
97
  const t0 = Date.now();
141
98
 
@@ -157,9 +114,6 @@ async function performBuild(
157
114
  ...(existingManifest?.stylesPath && existingManifest?.hydratePath
158
115
  ? { stylesPath: existingManifest.stylesPath, hydratePath: existingManifest.hydratePath }
159
116
  : {}),
160
- // Failure sentinel: the next boot's fingerprint check reads
161
- // this and cannot match any real fingerprint → forces a retry.
162
- inputFingerprint: `failed:${new Date().toISOString()}`,
163
117
  buildFailed: true,
164
118
  buildFailedReason: reason.slice(0, 2000),
165
119
  buildFailedAt: new Date().toISOString(),
@@ -221,7 +175,6 @@ async function performBuild(
221
175
  await writeManifest(outDir, {
222
176
  stylesPath,
223
177
  hydratePath: hydrate.hydratePath,
224
- inputFingerprint,
225
178
  sourceMaps: hydrate.sourceMaps,
226
179
  });
227
180
 
@@ -16,7 +16,8 @@
16
16
  * (e.g. ag_token) can never satisfy another gate's check (e.g. prop_token).
17
17
  *
18
18
  * Secret resolver, in preference order: AUTH_SECRET → ENCRYPTION_KEY →
19
- * DATABASE_URL (first one ≥16 chars). A gate signs AND verifies inside one
19
+ * the tenant DB address via @teamscala/db's named-need resolver (first one
20
+ * ≥16 chars). A gate signs AND verifies inside one
20
21
  * service, so it only needs a stable per-deployment secret; the fallback keeps a
21
22
  * gate working on a service that was never given AUTH_SECRET (e.g. the former
22
23
  * public-only proposals viewer) instead of failing closed and locking out every
@@ -25,12 +26,18 @@
25
26
  */
26
27
 
27
28
  import type { GateSegmentation } from "./config-types.ts";
29
+ import { resolveDbUrlOrNull } from "@teamscala/db/db-url";
28
30
 
29
31
  function secret(): string | null {
32
+ // The last resort is the tenant DB address, read through the named-need
33
+ // resolver (wrong-database guard, 2026-09-27) — never `process.env` here;
34
+ // the resolver is the only env-aware surface and it names the plane. It is
35
+ // used only as a stable per-deployment string (≥16 chars), never as a
36
+ // connection target.
30
37
  for (const s of [
31
38
  process.env.AUTH_SECRET,
32
39
  process.env.ENCRYPTION_KEY,
33
- process.env.DATABASE_URL,
40
+ resolveDbUrlOrNull("tenant"),
34
41
  ]) {
35
42
  if (s && s.length >= 16) return s;
36
43
  }
@@ -10,17 +10,18 @@
10
10
  * loga (dev) with full context. Application code never imports the logger
11
11
  * or wraps anything in try/catch for telemetry purposes.
12
12
  *
13
- * Marked: installLogging (early bootstrap, hardcoded
14
- * defaults) and applyLoggerConfig (late-stage, DB-driven) are the two
15
- * phases of the same one-call install — the service boot pipeline calls
16
- * them in sequence with the same serviceSlug.
13
+ * Two phases, two files (single-purpose-file: the guard counts them as two
14
+ * export groups in one file): installLogging (early bootstrap, hardcoded
15
+ * defaults) lives here; applyLoggerConfig (late-stage, DB-driven) lives in
16
+ * apply-logger-config.ts. The service boot pipeline calls them in sequence
17
+ * with the same serviceSlug.
17
18
  */
18
19
 
19
20
  import { ManagedCache } from "@teamscala/cache/cache";
20
21
  import { createCache } from "@teamscala/cache/create-cache";
21
22
  import { createDiskCache } from "@teamscala/cache/disk-cache";
22
23
  import { join } from "node:path";
23
- import { userHome } from "@teamscala/os/host-paths";
24
+ import { stateRoot } from "@teamscala/os/host-paths";
24
25
  import { slugify } from "@teamscala/formatters/slugify";
25
26
  import { createTimer } from "@teamscala/timing/create-timer";
26
27
  import { configure as dbConfigure } from "@teamscala/db/configure";
@@ -29,8 +30,6 @@ import { getEntriesByType as dbGetEntriesByType } from "@teamscala/db/registry/g
29
30
  import { loadRegistryConfig as dbLoadRegistryConfig } from "@teamscala/db/registry/load-config";
30
31
  import { configure as httpConfigure } from "@teamscala/http/configure";
31
32
  import { getAppLogger } from "@teamscala/logger/app-loggers";
32
- import { setMinLevel } from "@teamscala/logger/creator";
33
- import { configureFileSink } from "@teamscala/logger/file-sink";
34
33
  import { initSentry } from "@teamscala/logger/init-sentry";
35
34
  import { attachRequestLogger } from "@teamscala/logger/install-logging";
36
35
  import { getRequestContext } from "@teamscala/logger/request-context-async";
@@ -75,8 +74,11 @@ export function installLogging(opts: InstallLoggingOptions): void {
75
74
  // (runtime tier) so host-path resolution stays out of the foundation-tier
76
75
  // db package, which cannot import @teamscala/cache or resolve userHome itself.
77
76
  // load-config serves this on a transient DB failure instead of crash-looping.
77
+ // The root is stateRoot(), not userHome(): a container that declares a
78
+ // read-only HOME (scala-sign prod: HOME=/var/empty) made this cache EACCES on
79
+ // every request — the tier died in exactly the outage class it exists for.
78
80
  const registryConfigDiskCache = createDiskCache("db:registry-config", {
79
- dir: join(userHome(), ".scala-db-cache", "registry-config"),
81
+ dir: join(stateRoot(), ".scala-db-cache", "registry-config"),
80
82
  ttlMs: 24 * 60 * 60 * 1000,
81
83
  });
82
84
  dbConfigure({
@@ -110,24 +112,6 @@ export function installLogging(opts: InstallLoggingOptions): void {
110
112
  // in service_install.server_packages and pick up the configures there.
111
113
  }
112
114
 
113
- /** Late-stage configuration — runs AFTER Registry is loaded.
114
- * Injects DB-driven settings into the logger primitive. */
115
- export function applyLoggerConfig(config: {
116
- minLevel?: "debug" | "info" | "warn" | "error";
117
- logDir?: string;
118
- maxFileSize?: number;
119
- maxFiles?: number;
120
- }): void {
121
- if (config.minLevel) {
122
- setMinLevel(config.minLevel);
123
- }
124
- configureFileSink({
125
- logDir: config.logDir,
126
- maxFileSize: config.maxFileSize,
127
- maxFiles: config.maxFiles,
128
- });
129
- }
130
-
131
115
  function installConsoleShim(serviceSlug: string): void {
132
116
  const log = getAppLogger();
133
117
  const methodMap: Record<
@@ -12,6 +12,7 @@
12
12
  import { join } from "node:path";
13
13
  import { getAppLogger } from "@teamscala/logger/app-loggers";
14
14
  import type { Db8Client } from "@teamscala/db/db8-registry";
15
+ import { resolveDbUrlOrNull } from "@teamscala/db/db-url";
15
16
  import type { Hono } from "hono";
16
17
  import { bodyLimit } from "hono/body-limit";
17
18
 
@@ -131,12 +132,17 @@ export async function mountOrpcRouter(
131
132
  // closed over by the request handler (connects lazily on first query).
132
133
  let db8: Db8Client | undefined;
133
134
  try {
134
- if (process.env.DATABASE_URL) {
135
+ // Named-need address (wrong-database guard, 2026-09-27): the tenant
136
+ // plane by name through @teamscala/db's resolver, never an env key
137
+ // read at this call site. Null = the deploy wrote nothing for this
138
+ // plane, and db8 stays undefined on the existing absence path.
139
+ const databaseUrl = resolveDbUrlOrNull("tenant");
140
+ if (databaseUrl) {
135
141
  const { createDbClientV8 } = await import(
136
142
  "@teamscala/db/create-db-client-v8"
137
143
  );
138
144
  db8 = (await createDbClientV8({
139
- connectionString: process.env.DATABASE_URL,
145
+ connectionString: databaseUrl,
140
146
  serviceName: serviceSlug,
141
147
  })) as never;
142
148
  // Register for db-core internals (registry loaders, raw fallbacks)
@@ -36,13 +36,6 @@ export function getApp(): Hono {
36
36
  return currentApp;
37
37
  }
38
38
 
39
- /**
40
- * Get the current app label for logging.
41
- */
42
- export function getAppLabel(): string {
43
- return appLabel;
44
- }
45
-
46
39
  /**
47
40
  * Atomically swap the app to a new instance. This is called after the
48
41
  * full app is built in the background. Returns the previous label for logging.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @status generated — ts-api-smoke template (scala-test-suites
3
+ * templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
4
+ * ./page-server-loader-graph declares 1 runtime export(s) and 1 declared behaviour vector(s);
5
+ * this suite asserts the import resolves, the runtime surface matches
6
+ * exactly, and every export carries its declared kind. Regenerate from
7
+ * inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
8
+ * overwritten.
9
+ */
10
+
11
+ import { describe, expect, it } from "bun:test";
12
+
13
+ const mod = await import("./page-server-loader-graph");
14
+
15
+ const { resolveFromLoaderConfig } = mod;
16
+
17
+ describe("page-server-loader-graph exports-map smoke", () => {
18
+ it("the runtime export set is exactly the declared surface", () => {
19
+ expect(Object.keys(mod).sort()).toEqual([
20
+ "resolveFromLoaderConfig",
21
+ ]);
22
+ });
23
+ it("resolveFromLoaderConfig is a function", () => {
24
+ expect(typeof mod["resolveFromLoaderConfig"]).toBe("function");
25
+ });
26
+ it("the SSR binding is a resolver PRODUCT of the engine factory applied to the page-server client — never the factory itself", async () => {
27
+ const { createLoaderConfigResolver } = await import("@teamscala/resolver-engine/loader-config/resolve-from-loader-config");
28
+ expect(typeof createLoaderConfigResolver).toBe("function");
29
+ expect(resolveFromLoaderConfig).not.toBe(createLoaderConfigResolver);
30
+ });
31
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * page-server-loader-graph.ts — binds the ONE loader-graph resolver
7
+ * (@teamscala/resolver-engine's createLoaderConfigResolver) to the page-server
8
+ * SSR client. The resolver used to be duplicated here with its own algorithm;
9
+ * the copies drifted (a non-UUID URL ref resolved absent on this surface and
10
+ * still threw into the render breaker on the fn-router surface), so the
11
+ * algorithm became one injected function and each surface binds its own
12
+ * client source. This module IS that binding for the SSR surface — the single
13
+ * place the page-server client and the loader-graph resolver meet.
14
+ */
15
+
16
+ import { createLoaderConfigResolver } from "@teamscala/resolver-engine/loader-config/resolve-from-loader-config";
17
+ import { getServerClient } from "./page-server-orpc/server-client.ts";
18
+
19
+ export const resolveFromLoaderConfig = createLoaderConfigResolver(
20
+ getServerClient,
21
+ );
@@ -21,22 +21,9 @@ import type { ScheduleOptions } from "pg-boss";
21
21
  import { PgBoss } from "pg-boss";
22
22
  import { pgBossHandlerRegistry } from "./pg-boss-handler-registry.ts";
23
23
 
24
- // Bundled pg-boss handlers self-register on import (package-subscriber shape).
25
- // External handler packages register via their own boot hook / subscriber import.
26
- import "./handlers/projects-digest.ts";
27
- import "./handlers/sweep-event-log.ts";
28
- // `sweep-host-cache` is Rust-native: loga (the job's service_slug) registers it
29
- // via scala_os::host_cache_retention with the config/cleanup-allowed-roots
30
- // bound. No TypeScript copy may exist beside it.
31
- import "./handlers/bun-stable-watch.ts";
32
- import "./handlers/whatsapp-liveness.ts";
33
- import "./handlers/messaging-send-recording-watch.ts";
34
- import "./handlers/upstream-issue-sync.ts";
35
- import "./handlers/scala-tools-currency-watch.ts";
36
- import "./handlers/singleton-tools-currency-watch.ts";
37
- import "./handlers/registry-stale-inactive-sweep.ts";
38
- import "./handlers/platform-fault-escalation.ts";
39
- import "./handlers/quiz-research.ts";
24
+ // Handlers register via their package's own boot hook / subscriber import
25
+ // (package-subscriber shape) — no bundled copies. The mount resolves each
26
+ // row's handler_slug against this registry at dispatch time.
40
27
 
41
28
 
42
29
  /**
@@ -16,10 +16,6 @@ import type { Context, Next } from "hono";
16
16
 
17
17
  let _ready = false;
18
18
 
19
- export function isReady(): boolean {
20
- return _ready;
21
- }
22
-
23
19
  export function setReady(): void {
24
20
  _ready = true;
25
21
  }
@@ -111,23 +111,14 @@ export function registerApiRoutes(
111
111
  count++;
112
112
  }
113
113
 
114
- // CORS preflight: any service serving cross-origin browser clients (the central
115
- // chat engine on mcp-ai-chat-system) needs an OPTIONS response. The shared
116
- // session cookie (Domain=.scala.business) authenticates — only credentialed
117
- // CORS is needed (no token-passing). Harmless for same-origin services.
118
- app.options("*", (c) => {
119
- const origin = c.req.header("origin");
120
- if (!origin) return new Response(null, { status: 204 });
121
- return new Response(null, {
122
- status: 204,
123
- headers: {
124
- "Access-Control-Allow-Origin": origin,
125
- "Access-Control-Allow-Credentials": "true",
126
- "Access-Control-Allow-Methods": "POST, OPTIONS",
127
- "Access-Control-Allow-Headers": "Content-Type",
128
- },
129
- });
130
- });
114
+ // NO hand-rolled OPTIONS/CORS here: two-phase-minimal-app registers the
115
+ // official hono/cors middleware GLOBALLY before this runs, and its OPTIONS
116
+ // branch answers preflights without calling next() — a hand preflight
117
+ // handler registered here is shadowed dead code (measured: zero
118
+ // invocations across trusted/no/foreign-origin preflights, bun probe
119
+ // 2026-09-27). The middleware also decorates ACTUAL cross-origin
120
+ // responses and reflects origins only inside the cookie domain — the
121
+ // dead copy reflected ANY origin with credentials.
131
122
 
132
123
  return count;
133
124
  }
@@ -7,6 +7,12 @@
7
7
  * the platform-owned routers into the service's generated `appRouter` and
8
8
  * stashes the resulting client via setServerClient. Called once at boot before
9
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.
10
16
  */
11
17
 
12
18
  // REFUSE, DO NOT SKIP. `existsSync(routerPath)` being false used to fall straight through with appRouter=null and still register a client + log
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @status generated — ts-api-smoke template (scala-test-suites
3
+ * templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
4
+ * ./meta-title declares 3 runtime export(s) and 4 declared behaviour vector(s);
5
+ * this suite asserts the import resolves, the runtime surface matches
6
+ * exactly, and every export carries its declared kind. Regenerate from
7
+ * inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
8
+ * overwritten.
9
+ */
10
+
11
+ import { describe, expect, it } from "bun:test";
12
+
13
+ const mod = await import("./meta-title");
14
+
15
+ const { buildMetaFromContext, resolvePageMetaTitle, resolveEntityTitle } = mod;
16
+
17
+ describe("meta-title exports-map smoke", () => {
18
+ it("the runtime export set is exactly the declared surface", () => {
19
+ expect(Object.keys(mod).sort()).toEqual([
20
+ "buildMetaFromContext",
21
+ "resolveEntityTitle",
22
+ "resolvePageMetaTitle",
23
+ ]);
24
+ });
25
+ it("buildMetaFromContext is a function", () => {
26
+ expect(typeof mod["buildMetaFromContext"]).toBe("function");
27
+ });
28
+ it("resolvePageMetaTitle is a function", () => {
29
+ expect(typeof mod["resolvePageMetaTitle"]).toBe("function");
30
+ });
31
+ it("resolveEntityTitle is a function", () => {
32
+ expect(typeof mod["resolveEntityTitle"]).toBe("function");
33
+ });
34
+ it("an absent meta context resolves no meta at all", async () => {
35
+ const got = buildMetaFromContext(undefined, { page: { name: "Quiz" } });
36
+ expect(got).toEqual({});
37
+ });
38
+ it("the page's own meta_title wins, from loaderData first", async () => {
39
+ const got = resolvePageMetaTitle({ loaderData: { page: { meta_title: "Sign in" } }, page: { meta_title: "Landing" } });
40
+ expect(got).toBe("Sign in");
41
+ });
42
+ it("with no loaderData page, the resolved page's meta_title answers", async () => {
43
+ const got = resolvePageMetaTitle({ loaderData: {}, page: { meta_title: "Landing" } });
44
+ expect(got).toBe("Landing");
45
+ });
46
+ it("neither page carries a title, so no title resolves", async () => {
47
+ const got = resolvePageMetaTitle({ loaderData: {} });
48
+ expect(got).toBeUndefined();
49
+ });
50
+ });
@@ -33,7 +33,7 @@
33
33
  * edit here rather than a fourth branch in three files.
34
34
  */
35
35
 
36
- import { interpolateMeta } from "../interpolate-meta.ts";
36
+ import { interpolateMeta } from "@teamscala/resolver-engine/loader-config/resolve-from-loader-config";
37
37
 
38
38
  export function buildMetaFromContext(
39
39
  metaContext: Record<string, Record<string, string>> | undefined,
@@ -4,7 +4,8 @@
4
4
  * @edit edit directly
5
5
  *
6
6
  * Shared Puck-page-data helpers for the resolved-graph → pageData conversion:
7
- * the local PuckData structural type, JSON extraction with an honest null on
7
+ * the PuckData alias of the canonical boundary Data, JSON extraction with an
8
+ * honest null on
8
9
  * malformed input, layout-default application, region root-prop picking, the
9
10
  * RegionEntry shape, and the unresolved-region-slot warn. Plus the brand-token
10
11
  * prologue shared by all three render branches.
@@ -14,19 +15,14 @@
14
15
  */
15
16
 
16
17
  import { getAppLogger } from "@teamscala/logger/app-loggers";
18
+ import type { Data } from "@teamscala/puck-boundary/data";
17
19
 
18
20
  /**
19
- * Local structural type for Puck page data. Avoids a type-only import of
20
- * `@puckeditor/core`, which is NOT a runtime dependency — `deps-derived-from-source`
21
- * excludes whole-statement type-only imports, so the bare specifier has no
22
- * installed package to resolve against at publish typecheck time. The shape used
23
- * here (root/content/zones) is all this module reads or constructs.
21
+ * The canonical identity from the generated boundary (the platform's one
22
+ * declaration site for framework shapes) — never a hand-spelled structural
23
+ * duplicate of it.
24
24
  */
25
- export type PuckData = {
26
- root: { props?: Record<string, unknown> };
27
- content: unknown[];
28
- zones?: Record<string, unknown>;
29
- };
25
+ export type PuckData = Data;
30
26
 
31
27
  export const EMPTY_PUCK_DATA: PuckData = { root: { props: {} }, content: [] };
32
28
 
@@ -20,7 +20,7 @@ mock.module("@teamscala/logger/app-loggers", () => ({
20
20
  }),
21
21
  }));
22
22
 
23
- mock.module("../resolve-from-loader-config.ts", () => ({
23
+ mock.module("../page-server-loader-graph.ts", () => ({
24
24
  resolveFromLoaderConfig: async (_cfg: unknown, params: Record<string, string>) => {
25
25
  resolveCalls.push({ params });
26
26
  return structuredClone(loaderGraph);
@@ -17,7 +17,7 @@ import type { PageRouteConfig } from "../register-registry-page-route.ts";
17
17
  import * as v from "valibot";
18
18
  import { getAppLogger } from "@teamscala/logger/app-loggers";
19
19
  import type { ResourceLoaderConfigSchema } from "@teamscala/db-validation/registry-schemas/resource-loader";
20
- import { resolveFromLoaderConfig } from "../resolve-from-loader-config.ts";
20
+ import { resolveFromLoaderConfig } from "../page-server-loader-graph.ts";
21
21
  import { decideContentPageFloor } from "../content-page-floor.ts";
22
22
  import { PrimaryResourceMissingError } from "../resource-not-found.ts";
23
23
  import {
@@ -87,7 +87,6 @@ export async function resolveRequestGraph(
87
87
  args: ResolveRequestGraphArgs,
88
88
  ): Promise<Record<string, unknown>> {
89
89
  const { pattern, routeSlug, routeConfig, loaderConfig, params, isStaticShell, serviceSlug, configFingerprint } = args;
90
- const log = getAppLogger();
91
90
 
92
91
  // A bare root ("/") that declares a resource_type is a UUID-driven
93
92
  // public site (proposals/forms/quiz) with no resource selected — it
@@ -20,6 +20,7 @@
20
20
  * proposal-*) — split when the parent held them all at 924 lines.
21
21
  */
22
22
 
23
+ import type { InferOutput } from "valibot";
23
24
  import type { RenderModeLiteral } from "./page-route-shell-types.ts";
24
25
  import { PageRouteConfigSchema } from "@teamscala/db-validation/registry-schemas/page-route";
25
26
  import { ResourceLoaderConfigSchema } from "@teamscala/db-validation/registry-schemas/resource-loader";
@@ -65,21 +66,16 @@ import {
65
66
  } from "./register-registry-page-route/live-loader-config.ts";
66
67
  import type { ResourceLoaderKey } from "@teamscala/db/registry/generated/resource-loader";
67
68
 
68
- export interface PageRouteConfig {
69
- pattern: string;
70
- service: string;
71
- loader_slug: string;
72
- meta_context?: Record<string, Record<string, string>>;
73
- resource_type?: string;
74
- /** The floating AI assistant this page offers (page_route.assistant). */
75
- assistant?: {
76
- agent_config_slug: string;
77
- resource_key: string;
78
- launch_label: string;
79
- placeholder?: string;
80
- };
81
- providers?: string[];
82
- }
69
+ /**
70
+ * The page_route row's config type is the SCHEMA'S output, never a hand
71
+ * mirror (`autosave-single-surface` rule 2: a hand-written copy of a
72
+ * db-validation schema drifts the first time the schema grows a field —
73
+ * validateRow strips undeclared keys, so the mirror cannot see what the
74
+ * row now carries). The runtime therefore reads what the row declares,
75
+ * including the optional fields the old mirror dropped (label,
76
+ * brand_org_path, regions, shell_type, render_mode).
77
+ */
78
+ export type PageRouteConfig = InferOutput<typeof PageRouteConfigSchema>;
83
79
 
84
80
  interface RegistryRouteArgs {
85
81
  app: Hono;
@@ -305,7 +301,9 @@ export async function registerRegistryPageRoute(
305
301
  await applyBrandOrgPath(
306
302
  pageData as Record<string, unknown>,
307
303
  resolved,
308
- (routeConfig as { brand_org_path?: string }).brand_org_path,
304
+ // brand_org_path is the row schema's own field now — the derived
305
+ // type sees it, so the narrowing cast the mirror needed is gone.
306
+ routeConfig.brand_org_path,
309
307
  params,
310
308
  serviceSlug,
311
309
  );