@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.
- package/package.json +2 -1
- package/src/agreement-gate/signing-shell.test.ts +9 -15
- package/src/agreement-gate.test.ts +8 -15
- package/src/asset-proxy.test.ts +9 -9
- package/src/attach-portal-members.test.ts +9 -13
- package/src/attach-transfers-upload.test.ts +9 -10
- package/src/auth-gate.test.ts +8 -7
- package/src/bind-page-handler.ts +24 -8
- package/src/build-client-assets/manifest.test.ts +8 -14
- package/src/build-client-assets/source-candidates.test.ts +9 -11
- package/src/build-client-assets/sourcemaps-upload.test.ts +8 -15
- package/src/build-client-assets/styles-build.test.ts +9 -15
- package/src/build-client-assets.test.ts +8 -12
- package/src/capability-slug.test.ts +9 -0
- package/src/compose-page-title.test.ts +8 -9
- package/src/content-gate/access.ts +11 -0
- package/src/content-gate/catalog.test.ts +8 -8
- package/src/content-gate/config-types.ts +15 -0
- package/src/content-gate/gate-entry-clears.ts +38 -0
- package/src/content-gate/gate-entry-marker.test.ts +102 -0
- package/src/content-gate/gate.ts +16 -0
- package/src/content-gate/token.test.ts +8 -13
- package/src/content-page-floor.test.ts +9 -11
- package/src/csrf-body-limit.test.ts +9 -15
- package/src/handlers/messaging-send-reconcile-logic.test.ts +9 -11
- package/src/handlers/messaging-send-recording-logic.test.ts +9 -9
- package/src/handlers/messaging-send-recording-watch.test.ts +9 -11
- package/src/handlers/messaging-send-sweep-selection.test.ts +9 -7
- package/src/handlers/platform-fault-escalation/probe.test.ts +9 -10
- package/src/handlers/platform-fault-escalation-refresh.test.ts +9 -22
- package/src/handlers/platform-fault-escalation.test.ts +9 -10
- package/src/handlers/scala-tools-currency-watch.test.ts +9 -0
- package/src/handlers/singleton-tools-currency-watch.test.ts +9 -0
- package/src/handlers/whatsapp-liveness-handler.test.ts +9 -12
- package/src/handlers/whatsapp-liveness.test.ts +9 -9
- package/src/interpolate-meta.ts +123 -0
- package/src/is-react-element-type.test.ts +8 -9
- package/src/loader-errors.ts +41 -0
- package/src/module-not-found.test.ts +8 -12
- package/src/mount-orpc-router.test.ts +8 -9
- package/src/page-server-orpc/server-client.test.ts +8 -17
- package/src/quiz-gate.ts +10 -0
- package/src/read-html-template.test.ts +8 -12
- package/src/register-registry-page-route/apply-page-branding.test.ts +8 -19
- package/src/register-registry-page-route/captured-loader-config.test.ts +9 -13
- package/src/register-registry-page-route/graph-cache.test.ts +9 -12
- package/src/register-registry-page-route/live-loader-config.test.ts +8 -24
- package/src/register-registry-page-route/meta-title.ts +1 -1
- package/src/register-registry-page-route/page-data-shared.test.ts +9 -12
- package/src/register-registry-page-route/public-shell-page-data.test.ts +8 -19
- package/src/register-registry-page-route/resolve-entity-title.test.ts +8 -7
- package/src/register-registry-page-route/resolve-page-data.test.ts +9 -13
- package/src/register-registry-page-route/resolve-request-graph.test.ts +9 -13
- package/src/resolve-from-loader-config.test.ts +117 -14
- package/src/resolve-from-loader-config.ts +28 -115
- package/src/resolve-service-port.test.ts +8 -12
- package/src/resolvers/app-header.test.ts +9 -5
- package/src/resolvers/fetch-org-brand.ts +18 -2
- package/src/resolvers/portal-route-paths.test.ts +8 -13
- package/src/resource-chat-inject.page-assistant.test.ts +8 -28
- package/src/resource-chat-inject.test.ts +8 -25
- package/src/resource-not-found.test.ts +9 -8
- package/src/security-headers.test.ts +9 -12
- package/src/select-service-routes.test.ts +9 -0
- package/src/server-error-page.test.ts +8 -10
- package/src/theme-fonts-link.test.ts +8 -10
- package/src/two-phase-full-app.test.ts +8 -16
- package/src/two-phase-health-gate-client-only.test.ts +8 -28
- 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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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 "../
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
}
|