@teamscala/service-boot 0.23.503 → 0.23.506

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 (69) hide show
  1. package/package.json +2 -1
  2. package/src/agreement-gate/signing-shell.test.ts +9 -15
  3. package/src/agreement-gate.test.ts +8 -15
  4. package/src/asset-proxy.test.ts +9 -9
  5. package/src/attach-portal-members.test.ts +9 -13
  6. package/src/attach-transfers-upload.test.ts +9 -10
  7. package/src/auth-gate.test.ts +8 -7
  8. package/src/bind-page-handler.ts +24 -8
  9. package/src/build-client-assets/manifest.test.ts +8 -14
  10. package/src/build-client-assets/source-candidates.test.ts +9 -11
  11. package/src/build-client-assets/sourcemaps-upload.test.ts +8 -15
  12. package/src/build-client-assets/styles-build.test.ts +9 -15
  13. package/src/build-client-assets.test.ts +8 -12
  14. package/src/capability-slug.test.ts +9 -0
  15. package/src/compose-page-title.test.ts +8 -9
  16. package/src/content-gate/access.ts +11 -0
  17. package/src/content-gate/catalog.test.ts +8 -8
  18. package/src/content-gate/config-types.ts +15 -0
  19. package/src/content-gate/gate-entry-clears.ts +38 -0
  20. package/src/content-gate/gate-entry-marker.test.ts +102 -0
  21. package/src/content-gate/gate.ts +16 -0
  22. package/src/content-gate/token.test.ts +8 -13
  23. package/src/content-page-floor.test.ts +9 -11
  24. package/src/csrf-body-limit.test.ts +9 -15
  25. package/src/handlers/messaging-send-reconcile-logic.test.ts +9 -11
  26. package/src/handlers/messaging-send-recording-logic.test.ts +9 -9
  27. package/src/handlers/messaging-send-recording-watch.test.ts +9 -11
  28. package/src/handlers/messaging-send-sweep-selection.test.ts +9 -7
  29. package/src/handlers/platform-fault-escalation/probe.test.ts +9 -10
  30. package/src/handlers/platform-fault-escalation-refresh.test.ts +9 -22
  31. package/src/handlers/platform-fault-escalation.test.ts +9 -10
  32. package/src/handlers/scala-tools-currency-watch.test.ts +9 -0
  33. package/src/handlers/singleton-tools-currency-watch.test.ts +9 -0
  34. package/src/handlers/whatsapp-liveness-handler.test.ts +9 -12
  35. package/src/handlers/whatsapp-liveness.test.ts +9 -9
  36. package/src/interpolate-meta.ts +123 -0
  37. package/src/is-react-element-type.test.ts +8 -9
  38. package/src/loader-errors.ts +41 -0
  39. package/src/module-not-found.test.ts +8 -12
  40. package/src/mount-orpc-router.test.ts +8 -9
  41. package/src/page-server-orpc/server-client.test.ts +8 -17
  42. package/src/quiz-gate.ts +10 -0
  43. package/src/read-html-template.test.ts +8 -12
  44. package/src/register-registry-page-route/apply-page-branding.test.ts +8 -19
  45. package/src/register-registry-page-route/captured-loader-config.test.ts +9 -13
  46. package/src/register-registry-page-route/graph-cache.test.ts +9 -12
  47. package/src/register-registry-page-route/live-loader-config.test.ts +8 -24
  48. package/src/register-registry-page-route/meta-title.ts +1 -1
  49. package/src/register-registry-page-route/page-data-shared.test.ts +9 -12
  50. package/src/register-registry-page-route/public-shell-page-data.test.ts +8 -19
  51. package/src/register-registry-page-route/resolve-entity-title.test.ts +8 -7
  52. package/src/register-registry-page-route/resolve-page-data.test.ts +9 -13
  53. package/src/register-registry-page-route/resolve-request-graph.test.ts +9 -13
  54. package/src/resolve-from-loader-config.test.ts +117 -14
  55. package/src/resolve-from-loader-config.ts +28 -115
  56. package/src/resolve-service-port.test.ts +8 -12
  57. package/src/resolvers/app-header.test.ts +9 -5
  58. package/src/resolvers/fetch-org-brand.ts +18 -2
  59. package/src/resolvers/portal-route-paths.test.ts +8 -13
  60. package/src/resource-chat-inject.page-assistant.test.ts +8 -28
  61. package/src/resource-chat-inject.test.ts +8 -25
  62. package/src/resource-not-found.test.ts +9 -8
  63. package/src/security-headers.test.ts +9 -12
  64. package/src/select-service-routes.test.ts +9 -0
  65. package/src/server-error-page.test.ts +8 -10
  66. package/src/theme-fonts-link.test.ts +8 -10
  67. package/src/two-phase-full-app.test.ts +8 -16
  68. package/src/two-phase-health-gate-client-only.test.ts +8 -28
  69. package/src/two-phase-health-gate.test.ts +8 -28
package/src/quiz-gate.ts CHANGED
@@ -30,6 +30,14 @@ import { signAccessToken, verifyAccessTokenIdentity } from "./content-gate/token
30
30
  import { quizTtlMs } from "./gate-ttls.ts";
31
31
 
32
32
  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
+ const QUIZ_ENTRY_COOKIE = "quiz_token_entered";
33
41
  const QUIZ_ACCESS_PATH = "/api/quiz-access";
34
42
 
35
43
  /** The assessment this page is for, from the quiz-with-brand loader's graph. */
@@ -40,6 +48,7 @@ function quizId(resolved: Record<string, unknown>): string {
40
48
 
41
49
  export const quizGateConfig: ContentGateConfig = {
42
50
  cookieName: QUIZ_COOKIE,
51
+ entryCookieName: QUIZ_ENTRY_COOKIE,
43
52
  getResourceId(resolved) {
44
53
  // No quiz in the graph → not a quiz page → the engine no-ops.
45
54
  return quizId(resolved);
@@ -70,6 +79,7 @@ export const quizGateConfig: ContentGateConfig = {
70
79
  export const quizAccessConfig: AccessConfig = {
71
80
  path: QUIZ_ACCESS_PATH,
72
81
  cookieName: QUIZ_COOKIE,
82
+ entryCookieName: QUIZ_ENTRY_COOKIE,
73
83
  get ttlMs() {
74
84
  return quizTtlMs();
75
85
  },
@@ -1,15 +1,11 @@
1
- /**
2
- * @system ui-service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Regression guard for the runtime-owned Puck document and manifest-keyed
7
- * HTML cache. The lifetime-pinned
8
- * cache served a deleted bundle hash after a post-boot rebuild (scala-docs
9
- * 2026-06-13): build-client-assets rewrites asset-manifest.json + deletes the
10
- * old hashed bundle, but the cached HTML kept the old hash → 404 → no
11
- * hydration. These tests pin that a manifest change re-substitutes the HTML.
12
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
13
9
 
14
10
  import { mkdir, mkdtemp, rm } from "node:fs/promises";
15
11
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
@@ -1,22 +1,11 @@
1
- /**
2
- * @system ui-service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * applySessionOrgBranding must never repaint a TENANT document in the
7
- * VIEWER's brand.
8
- *
9
- * A public document belongs to the org that PUBLISHED it and must look
10
- * identical to every reader. Personalising to the reader's org is right for a
11
- * portal (you are looking at your own workspace) and wrong for a document.
12
- *
13
- * The regression this locks: sign.scala.business's website is owned by the
14
- * PLATFORM org, so the website-level test passed and the session override
15
- * fired on agreements owned by tenants. Every signed-in staff viewer saw a
16
- * GMG APAC agreement in their own org's brand while an anonymous client saw
17
- * it correctly — so the only people who could not see what the client sees
18
- * were the people reviewing it before sending.
19
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
20
9
 
21
10
  import { describe, expect, mock, test } from "bun:test";
22
11
 
@@ -1,16 +1,12 @@
1
- /**
2
- * @system ui-service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The boot log is the ONLY observable statement of what a running service
7
- * captured from its resource_loader row (the 2026-08-18 evening-long bisect:
8
- * a footer region added to the row never reached the booted process, and
9
- * nothing anywhere said so). These tests pin the contract the line states —
10
- * the captured source keys, the captured region slots, and the restart
11
- * requirement — so the observability cannot silently regress to the old bare
12
- * "registered route" line.
13
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
9
+
14
10
  import { describe, expect, test } from "bun:test";
15
11
  import { describeCapturedLoaderConfig } from "./captured-loader-config.ts";
16
12
 
@@ -1,15 +1,12 @@
1
- /**
2
- * @system ui-service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Regression guard for readCachedGraph's stale-while-revalidate decision
7
- * (capability-harvest-on-split / what-must-have-a-test). The fold onto
8
- * @teamscala/cache's getWithMeta + refreshInBackground must keep firing the
9
- * background refresh past the fresh window — if it ever stops, the page-route
10
- * graph cache silently degrades to fresh-or-miss (the measured 3.8s
11
- * cache-expired in-request regression the SWR exists to prevent).
12
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
9
+
13
10
  import { describe, expect, test } from "bun:test";
14
11
  import { createCache } from "@teamscala/cache/create-cache";
15
12
  import { readCachedGraph } from "./graph-cache.ts";
@@ -1,27 +1,11 @@
1
- /**
2
- * @system ui-service-boot
3
- * @status handwritten
4
- * @edit extend the regression set for the live loader-config contract
5
- *
6
- * Behavioural tests for the per-request resource_loader read (the fix for the
7
- * boot-baked-loader defect): resource_loader is NOT on the deploy-time bake
8
- * list, so a registry row edit must reach the running service WITHOUT a
9
- * restart. The registration loop's boot read is warm/validate only.
10
- *
11
- * 1. Two requests to the same route with the row edited between them — the
12
- * SECOND request's graph resolution receives the NEW source set, and the
13
- * live read ran once per request (not once per boot).
14
- * 2. A row that vanishes (or stops validating) between requests — the second
15
- * request FAILS CLOSED (the read throws into the branded error path;
16
- * the boot snapshot never silently outlives its row).
17
- * 3. The graphKey fingerprint changes when the config changes — a row edit
18
- * invalidates the per-route SWR graph cache immediately instead of
19
- * serving the old graph until the fresh window lapses.
20
- *
21
- * Only the registry reader and the heavy graph collaborators are mocked; the
22
- * real registrar, real Hono app, real loadData pipeline and the real
23
- * live-loader-config unit are under test.
24
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
25
9
 
26
10
  import { GlobalRegistrator } from "@happy-dom/global-registrator";
27
11
  import { afterEach, describe, expect, mock, test } from "bun:test";
@@ -33,7 +33,7 @@
33
33
  * edit here rather than a fourth branch in three files.
34
34
  */
35
35
 
36
- import { interpolateMeta } from "../resolve-from-loader-config.ts";
36
+ import { interpolateMeta } from "../interpolate-meta.ts";
37
37
 
38
38
  export function buildMetaFromContext(
39
39
  metaContext: Record<string, Record<string, string>> | undefined,
@@ -1,15 +1,12 @@
1
- /**
2
- * @system ui-service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Behavioural pins for the shared page-data helpers. The load-bearing cases:
7
- * layout defaults FILL missing root props and never clobber existing values
8
- * (a theme row must not overwrite page-authored props), pickRootProp takes
9
- * the FIRST region carrying a usable value, brand tokens degrade to nulls —
10
- * never throw — on a missing brand row, and the unresolved-region-slot warn
11
- * fires ONCE per slot per process (the hot-path throttle).
12
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
9
+
13
10
  import { describe, expect, mock, test } from "bun:test";
14
11
 
15
12
  const warns: string[] = [];
@@ -1,22 +1,11 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Regression cover for two defects the operator reported on the launcha quiz,
7
- * both of which lived in publicShellPageData and were invisible to every other
8
- * check because the page rendered 200 with a clean log either way.
9
- *
10
- * 1. screenMode was never threaded, so a page declaring "full-screen" fell back
11
- * to build-page-tree's card-wrapper default and the site header rendered
12
- * INSIDE the card instead of across the top of the page.
13
- * 2. the page title used the LEGAL entity name (companyName) rather than the
14
- * trading name (companyFriendlyName), putting "GMG APAC" on every launcha
15
- * page.
16
- *
17
- * Both assert the OUTCOME (the emitted flag, the composed title text), not the
18
- * presence of a key — a shape assertion would have passed throughout the bug.
19
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
20
9
 
21
10
  import { describe, expect, test } from "bun:test";
22
11
  import { publicShellPageData } from "./public-shell-page-data.ts";
@@ -1,10 +1,11 @@
1
- /**
2
- * @system ui-service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Locks the single subject derivation that replaced three divergent copies.
7
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
8
9
 
9
10
  import { describe, expect, test } from "bun:test";
10
11
  import { resolveEntityTitle } from "./meta-title.ts";
@@ -1,16 +1,12 @@
1
- /**
2
- * @system ui-service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Regression test for the silent region-slot vanish (measured 2026-08-18): a
7
- * region the operator declared in resource_loader.region_slots whose source
8
- * did not resolve was skipped with no log, no error and no signal anywhere —
9
- * the page rendered minus a region while every other suspect checked out.
10
- * These tests pin the observable contract: the omission is LOGGED naming the
11
- * slot, once per slot per process (hot path never spams), and the resolved
12
- * regions still render every slot that did resolve.
13
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
9
+
14
10
  import { describe, expect, mock, test } from "bun:test";
15
11
 
16
12
  const warn = mock((_message: string) => {});
@@ -1,16 +1,12 @@
1
- /**
2
- * @system ui-service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Behavioural pins for the per-request loader-graph resolution. The load-
7
- * bearing cases: a bare root with a resource_type throws the 200 holding-page
8
- * signal (never falls through to the marketing site), a dead link (declared
9
- * primary source that resolves to nothing) throws the branded 404, a content
10
- * route with no page row throws the dead-link floor, and a content route with
11
- * an EMPTY page row throws the 200 empty-page floor — the three distinct
12
- * surfaces the page binder maps to three distinct responses.
13
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
9
+
14
10
  import { describe, expect, mock, test } from "bun:test";
15
11
 
16
12
  let loaderGraph: Record<string, unknown> = {};
@@ -1,19 +1,15 @@
1
- /**
2
- * @system ui-service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * resolve-from-loader-config.test.ts — locks in the interpolation contract that
7
- * resolver_fn sources depend on: a bare `:sourceKey` ref passes the WHOLE
8
- * resolved source object (so a resolver_input like {response: ":response"}
9
- * reaches the resolver), while a URL param (e.g. :id) still resolves from params.
10
- */
1
+ // @system codegen
2
+ // @status generated
3
+ // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
+ //
5
+ // This suite's assertions are OWNED by the codegen band: the band module
6
+ // carries them verbatim, this file is the emission, and hand edits here are
7
+ // overwritten on the next run. The rationale each assertion carries moved
8
+ // with it into the band.
11
9
 
12
10
  import { describe, expect, test } from "bun:test";
13
- import {
14
- interpolateMeta,
15
- resolveFromLoaderConfig,
16
- } from "./resolve-from-loader-config";
11
+ import { resolveFromLoaderConfig } from "./resolve-from-loader-config";
12
+ import { interpolateMeta } from "./interpolate-meta.ts";
17
13
  import {
18
14
  getServerClient,
19
15
  isServerClientRegistered,
@@ -324,3 +320,110 @@ describe("required sources refuse unresolvable where refs", () => {
324
320
  expect(captured.where).toEqual({ partner_id: null });
325
321
  });
326
322
  });
323
+
324
+ describe("non-UUID URL refs resolve absent, never throw into the breaker", () => {
325
+ // A URL param that is not a uuid (a slug link, a scanner path) can never
326
+ // match a uuid column. The query failure IS the resource being missing
327
+ // for this URL — the same state as a deleted row — so it resolves null
328
+ // and the upstream primary-missing check renders the branded 404.
329
+ // Throwing here instead counted every dead shape as a CONSECUTIVE
330
+ // render-budget failure: 53 scanner hits in a row on scala-quiz prod
331
+ // (2026-09-11) sat the layer DEGRADED fail-closed and every real page
332
+ // 503'd for hours.
333
+ const original = isServerClientRegistered() ? getServerClient() : null;
334
+ const sqlError = (msg: string) => {
335
+ const e = new Error(msg);
336
+ e.name = "SqlQueryError";
337
+ return e;
338
+ };
339
+
340
+ test("a required source rejecting a non-uuid URL ref resolves null", async () => {
341
+ setServerClient({
342
+ builda_pages: {
343
+ findFirst: async () => {
344
+ throw sqlError('invalid input syntax for type uuid: "launcha-authority-index"');
345
+ },
346
+ },
347
+ } as never);
348
+ try {
349
+ await expect(
350
+ resolveFromLoaderConfig(
351
+ {
352
+ sources: [
353
+ {
354
+ key: "assessment",
355
+ source: "orpc",
356
+ model: "builda_pages",
357
+ where: { id: ":id" },
358
+ optional: false,
359
+ },
360
+ ],
361
+ },
362
+ { id: "launcha-authority-index" },
363
+ ),
364
+ ).resolves.toEqual({ assessment: null });
365
+ } finally {
366
+ setServerClient(original as never);
367
+ }
368
+ });
369
+
370
+ test("a SqlQueryError that is NOT a non-uuid ref still throws", async () => {
371
+ setServerClient({
372
+ builda_pages: {
373
+ findFirst: async () => {
374
+ throw sqlError('column "page_id" does not exist');
375
+ },
376
+ },
377
+ } as never);
378
+ try {
379
+ await expect(
380
+ resolveFromLoaderConfig(
381
+ {
382
+ sources: [
383
+ {
384
+ key: "page",
385
+ source: "orpc",
386
+ model: "builda_pages",
387
+ where: { id: ":id" },
388
+ optional: false,
389
+ },
390
+ ],
391
+ },
392
+ { id: "p1" },
393
+ ),
394
+ ).rejects.toThrow('column "page_id" does not exist');
395
+ } finally {
396
+ setServerClient(original as never);
397
+ }
398
+ });
399
+
400
+ test("a non-SqlQueryError rejection on a required source still throws", async () => {
401
+ setServerClient({
402
+ builda_pages: {
403
+ findFirst: async () => {
404
+ throw new Error("connection refused");
405
+ },
406
+ },
407
+ } as never);
408
+ try {
409
+ await expect(
410
+ resolveFromLoaderConfig(
411
+ {
412
+ sources: [
413
+ {
414
+ key: "page",
415
+ source: "orpc",
416
+ model: "builda_pages",
417
+ where: { id: ":id" },
418
+ optional: false,
419
+ },
420
+ ],
421
+ },
422
+ { id: "p1" },
423
+ ),
424
+ ).rejects.toThrow("connection refused");
425
+ } finally {
426
+ setServerClient(original as never);
427
+ }
428
+ });
429
+ });
@@ -16,6 +16,11 @@ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
16
16
  import type { ResolverFnKey } from "@teamscala/db/registry/generated/resolver-fn";
17
17
  import { ResolverFnConfigSchema } from "@teamscala/db-validation/registry-schemas/resolver-fn";
18
18
  import { getServerClient } from "./page-server-orpc/server-client";
19
+ import { interpolateTemplate, resolveParamRef } from "./interpolate-meta";
20
+ import {
21
+ MissingLoaderModelCapabilityError,
22
+ isUrlRefNotUuidError,
23
+ } from "./loader-errors";
19
24
 
20
25
  interface SourceConfig {
21
26
  key: string;
@@ -36,105 +41,6 @@ interface LoaderConfig {
36
41
  merge_strategy?: Record<string, unknown>;
37
42
  }
38
43
 
39
- class MissingLoaderModelCapabilityError extends Error {
40
- constructor(model: string, sourceKey: string) {
41
- super(
42
- `[resource_loader] source "${sourceKey}" requires model "${model}", but the generated server client does not expose findFirst for it — regenerate the service schema from its page loaders`,
43
- );
44
- this.name = "MissingLoaderModelCapabilityError";
45
- }
46
- }
47
-
48
- function resolveParamRef(
49
- ref: string,
50
- params: Record<string, unknown>,
51
- resolved: Record<string, unknown>,
52
- ): unknown {
53
- const parts = ref.split(".");
54
- // A bare (no-dot) ref can name either a resolved source key (e.g. `:response`
55
- // passes the whole resolved source object to a resolver_fn) or a URL param
56
- // (e.g. `:id`). Prefer the resolved source key, then fall back to the param.
57
- // Dotted refs (`:response.total_score`) always read from `resolved`.
58
- if (parts.length === 1) {
59
- if (resolved != null && typeof resolved === "object" && ref in resolved) {
60
- return (resolved as Record<string, unknown>)[ref];
61
- }
62
- if (params != null && typeof params === "object" && ref in params) {
63
- return (params as Record<string, unknown>)[ref];
64
- }
65
- return undefined;
66
- }
67
- let current: unknown = resolved;
68
- for (const part of parts) {
69
- if (current == null || typeof current !== "object") return undefined;
70
- current = readField(current as Record<string, unknown>, part);
71
- }
72
- return current;
73
- }
74
-
75
- /**
76
- * A resource_loader row spells DATABASE COLUMN NAMES — `page_id`,
77
- * `organisation_id`, `prepared_for` — because that is what the schema calls
78
- * them. The ORM hands back CAMELCASE keys — `pageId`, `organisationId`,
79
- * `preparedFor`. Nothing reconciled the two, so a `:proposal.page_id` ref read
80
- * a key the object does not have and returned undefined.
81
- *
82
- * MEASURED 2026-09-03, and the consequence is far worse than a missing value.
83
- * `interpolateTemplate` DROPS a key whose value is undefined, so a dependent
84
- * source declared `where: { id: ":proposal.page_id" }` was issued as
85
- * `where: {}` — and findFirst with an empty filter returns AN ARBITRARY FIRST
86
- * ROW. A client proposal page therefore loaded an unrelated builda_page,
87
- * rendered a stranger's content under the correct brand chrome, and answered
88
- * 200 throughout. Instrumenting the live resolver showed every dependent
89
- * source doing it: page, website, contact, partnerOrg and clientOrg all with
90
- * `where={}`; only `proposal` was correct, because its ref is the URL param
91
- * `:id` rather than a sibling source.
92
- *
93
- * Read the spelled key first, then its camelCase equivalent. Column names stay
94
- * the authoring vocabulary and a row never has to know what the ORM did to its
95
- * keys.
96
- */
97
- function readField(source: Record<string, unknown>, key: string): unknown {
98
- if (key in source) return source[key];
99
- const camel = key.replace(/_([a-z0-9])/g, (_m, c: string) => c.toUpperCase());
100
- return camel === key ? undefined : source[camel];
101
- }
102
-
103
- function interpolateTemplate(
104
- template: unknown,
105
- params: Record<string, unknown>,
106
- resolved: Record<string, unknown>,
107
- ): unknown {
108
- if (typeof template === "string") {
109
- const exactMatch = template.match(/^:([a-zA-Z0-9_.]+)$/);
110
- if (exactMatch) {
111
- return resolveParamRef(exactMatch[1], params, resolved);
112
- }
113
- if (template.includes(":")) {
114
- return template.replace(
115
- /:([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*)/g,
116
- (_match, ref: string) => {
117
- const value = resolveParamRef(ref, params, resolved);
118
- return value != null ? String(value) : _match;
119
- },
120
- );
121
- }
122
- return template;
123
- }
124
- if (Array.isArray(template)) {
125
- return template.map((v) => interpolateTemplate(v, params, resolved));
126
- }
127
- if (template != null && typeof template === "object") {
128
- const out: Record<string, unknown> = {};
129
- for (const [k, v] of Object.entries(template)) {
130
- const result = interpolateTemplate(v, params, resolved);
131
- if (result !== undefined) out[k] = result;
132
- }
133
- return out;
134
- }
135
- return template;
136
- }
137
-
138
44
  function toSelectMap(
139
45
  fields: string[] | undefined,
140
46
  ): Record<string, boolean> | undefined {
@@ -370,10 +276,33 @@ export async function resolveFromLoaderConfig(
370
276
  if (!source || !result) continue;
371
277
  if (result.status === "fulfilled") {
372
278
  resolved[source.key] = result.value;
279
+ } else if (isUrlRefNotUuidError(result.reason)) {
280
+ // A URL ref that cannot be a uuid names no resource: resolve the
281
+ // source ABSENT (null) and let the existing semantics decide —
282
+ // the primary-missing check upstream turns it into the branded
283
+ // 404 (PrimaryResourceMissingError, which the render-budget
284
+ // breaker already excludes), an optional source treats it like
285
+ // any absent row. Throwing here instead is what let dead links
286
+ // poison the breaker into a site-wide 503 (see the predicate).
287
+ getAppLogger().warn(
288
+ `[resource_loader] source "${source.key}" got a non-UUID URL ref — treating as MISSING for this request: ${
289
+ result.reason instanceof Error ? result.reason.message : String(result.reason)
290
+ }`,
291
+ );
292
+ resolved[source.key] = null;
373
293
  } else if (
374
294
  source.optional &&
375
295
  !(result.reason instanceof MissingLoaderModelCapabilityError)
376
296
  ) {
297
+ // Optional means MAY BE ABSENT, never FAIL IN SILENCE: the source
298
+ // still resolves to null, but the reason becomes evidence.
299
+ getAppLogger().warn(
300
+ `[resource_loader] optional source "${source.key}" FAILED and is absent from this render: ${
301
+ result.reason instanceof Error
302
+ ? `${result.reason.message}\n${result.reason.stack ?? ""}`
303
+ : String(result.reason)
304
+ }`,
305
+ );
377
306
  resolved[source.key] = null;
378
307
  } else {
379
308
  throw result.reason;
@@ -385,19 +314,3 @@ export async function resolveFromLoaderConfig(
385
314
 
386
315
  return resolved;
387
316
  }
388
-
389
- export function interpolateMeta(
390
- template: Record<string, unknown>,
391
- data: Record<string, unknown>,
392
- ): Record<string, unknown> {
393
- const out: Record<string, unknown> = {};
394
- for (const [k, v] of Object.entries(template)) {
395
- if (v != null && typeof v === "object" && !Array.isArray(v)) {
396
- out[k] = interpolateMeta(v as Record<string, unknown>, data);
397
- } else {
398
- const result = interpolateTemplate(v, {}, data);
399
- out[k] = result ?? v;
400
- }
401
- }
402
- return out;
403
- }