@teamscala/service-boot 0.23.453 → 0.23.456

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 CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.453",
9
+ "version": "0.23.456",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -43,7 +43,6 @@
43
43
  "@teamscala/orpc": "*",
44
44
  "@teamscala/os": "*",
45
45
  "@teamscala/publish-registry": "*",
46
- "@teamscala/puck-boundary": "*",
47
46
  "@teamscala/realtime-utils": "*",
48
47
  "@teamscala/resolver-engine": "*",
49
48
  "@teamscala/safe-fallback": "*",
@@ -1033,6 +1032,14 @@
1033
1032
  "types": "./src/register-registry-page-route/graph-cache.d.ts",
1034
1033
  "default": "./src/register-registry-page-route/graph-cache.ts"
1035
1034
  },
1035
+ "./register-registry-page-route/live-loader-config": {
1036
+ "types": "./src/register-registry-page-route/live-loader-config.d.ts",
1037
+ "default": "./src/register-registry-page-route/live-loader-config.ts"
1038
+ },
1039
+ "./register-registry-page-route/live-loader-config.ts": {
1040
+ "types": "./src/register-registry-page-route/live-loader-config.d.ts",
1041
+ "default": "./src/register-registry-page-route/live-loader-config.ts"
1042
+ },
1036
1043
  "./register-registry-page-route/meta-title": {
1037
1044
  "types": "./src/register-registry-page-route/meta-title.d.ts",
1038
1045
  "default": "./src/register-registry-page-route/meta-title.ts"
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Behavioural pins for the agreement signing-shell units. The load-bearing
7
+ * cases: the gate block carries the OTP switch as the STRING the renderer
8
+ * validates against (a boolean is stripped before the block reaches the
9
+ * component — the same coercion that bit the pre-placed gate), an empty
10
+ * registry title falls back to the DB-driven default, the signing shell
11
+ * ALWAYS renders (registry failure falls back to the hardcoded shell), the
12
+ * shell is deep-cloned per call (injection mutates the tree in place), and
13
+ * the pre-placed-gate probe recognises an agreement gate by its context
14
+ * field — a PROPOSAL gate in the same region must not read as an agreement.
15
+ */
16
+ import { describe, expect, mock, test } from "bun:test";
17
+
18
+ let registryBlocks: unknown = undefined;
19
+ let registryThrows = false;
20
+ mock.module("@teamscala/db/registry/load-config", () => {
21
+ class RegistryConfigMissingError extends Error {}
22
+ return {
23
+ RegistryConfigMissingError,
24
+ loadRegistryConfig: async () => {
25
+ if (registryThrows) throw new RegistryConfigMissingError("missing");
26
+ if (registryBlocks === undefined)
27
+ throw new RegistryConfigMissingError("missing");
28
+ return { blocks: registryBlocks };
29
+ },
30
+ };
31
+ });
32
+
33
+ import {
34
+ AGREEMENT_META_KEY,
35
+ buildGateBlock,
36
+ buildSigningShellBlocks,
37
+ hasPrePlacedGate,
38
+ stashAgreementMeta,
39
+ } from "./signing-shell.ts";
40
+ import type { AgreementGateSettings } from "./settings.ts";
41
+
42
+ const SETTINGS: AgreementGateSettings = {
43
+ title: "DB title",
44
+ subtitle: "Sign to continue",
45
+ submitLabel: "Sign now",
46
+ requireOtp: true,
47
+ };
48
+
49
+ describe("buildGateBlock", () => {
50
+ test("carries the agreement uuid as the gate context", () => {
51
+ const block = buildGateBlock("agreement-uuid-1", "Custom title", SETTINGS);
52
+ expect(block.type).toBe("SignIn");
53
+ expect(block.props?.mode).toBe("gate");
54
+ expect(block.props?.gateContextField).toBe("agreementId");
55
+ expect(block.props?.gateContextValue).toBe("agreement-uuid-1");
56
+ });
57
+
58
+ test("requireOtp is the STRING the renderer validates, never a boolean", () => {
59
+ expect(buildGateBlock("u", "t", SETTINGS).props?.gateRequireOtp).toBe("true");
60
+ expect(
61
+ buildGateBlock("u", "t", { ...SETTINGS, requireOtp: false }).props
62
+ ?.gateRequireOtp,
63
+ ).toBe("false");
64
+ });
65
+
66
+ test("a blank per-agreement title falls back to the DB-driven default", () => {
67
+ expect(buildGateBlock("u", "", SETTINGS).props?.title).toBe("DB title");
68
+ });
69
+ });
70
+
71
+ describe("buildSigningShellBlocks", () => {
72
+ test("an unreadable registry row still renders — the hardcoded shell is the fallback", async () => {
73
+ registryThrows = true;
74
+ const blocks = await buildSigningShellBlocks();
75
+ expect(blocks.length).toBeGreaterThanOrEqual(3);
76
+ expect(
77
+ blocks.some(
78
+ (b) => b.type === "SignatureField" || b.type === "Container",
79
+ ),
80
+ ).toBe(true);
81
+ });
82
+
83
+ test("registry-configured blocks replace the default, deep-cloned per call", async () => {
84
+ registryThrows = false;
85
+ registryBlocks = [
86
+ {
87
+ type: "Text",
88
+ props: { id: "custom-clause", text: "Custom clause" },
89
+ },
90
+ ];
91
+ const first = await buildSigningShellBlocks();
92
+ expect(first).toHaveLength(1);
93
+ expect((first[0].props as { text?: string }).text).toBe("Custom clause");
94
+
95
+ // Injection mutates the tree in place: the mutation must not leak into
96
+ // the NEXT call's blocks (the registry value or default stays intact).
97
+ first.pop();
98
+ const second = await buildSigningShellBlocks();
99
+ expect(second).toHaveLength(1);
100
+ });
101
+
102
+ test("an EMPTY registry block list falls back to the default shell", async () => {
103
+ registryBlocks = [];
104
+ const blocks = await buildSigningShellBlocks();
105
+ expect(blocks.length).toBeGreaterThanOrEqual(3);
106
+ registryBlocks = undefined;
107
+ });
108
+ });
109
+
110
+ describe("stashAgreementMeta + hasPrePlacedGate", () => {
111
+ test("stashed meta round-trips under the module's own key", () => {
112
+ const resolved: Record<string, unknown> = {};
113
+ stashAgreementMeta(resolved, "uuid-77", "Q3 Agreement");
114
+ expect(resolved[AGREEMENT_META_KEY]).toEqual({
115
+ id: "uuid-77",
116
+ name: "Q3 Agreement",
117
+ });
118
+ });
119
+
120
+ test("a pre-placed SignIn gate block reads as an agreement gate", () => {
121
+ const resolved: Record<string, unknown> = {
122
+ page: {
123
+ puck_data: {
124
+ content: [
125
+ {
126
+ type: "SignIn",
127
+ props: {
128
+ mode: "gate",
129
+ gateContextField: "agreementId",
130
+ gateContextValue: "uuid-77",
131
+ },
132
+ },
133
+ ],
134
+ },
135
+ },
136
+ };
137
+ expect(hasPrePlacedGate(resolved)).toBe(true);
138
+ });
139
+
140
+ test("a PROPOSAL gate (proposalId context) is not an agreement gate", () => {
141
+ const resolved: Record<string, unknown> = {
142
+ page: {
143
+ puck_data: {
144
+ content: [
145
+ {
146
+ type: "SignIn",
147
+ props: {
148
+ mode: "gate",
149
+ gateContextField: "proposalId",
150
+ gateContextValue: "p1",
151
+ },
152
+ },
153
+ ],
154
+ },
155
+ },
156
+ };
157
+ expect(hasPrePlacedGate(resolved)).toBe(false);
158
+ });
159
+
160
+ test("a page with no gate block reads as new-flow (no pre-placed gate)", () => {
161
+ const resolved: Record<string, unknown> = {
162
+ page: {
163
+ puck_data: { content: [{ type: "Text", props: { id: "t1" } }] },
164
+ },
165
+ };
166
+ expect(hasPrePlacedGate(resolved)).toBe(false);
167
+ });
168
+ });
@@ -28,9 +28,6 @@
28
28
  * identified, not heuristically guessed.
29
29
  */
30
30
 
31
- // Why: The body content array for the agreement. The public shell c…
32
- // (full note hoisted to this file's leading header — kept out of the body per
33
- // no-inline-prose-blocks / prose-stays-in-docs)
34
31
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
35
32
  import * as v from "valibot";
36
33
  import { findAgreementGate } from "./signing-session.ts";
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Behavioural pins for the portal member directory endpoint. The load-bearing
7
+ * cases: org-scoped reads bind the orgId as a PARAMETER (never interpolated —
8
+ * the orgId arrives from the query string), the all-orgs "View As" mode is
9
+ * gated to admin/super_admin sessions (any other caller without an orgId is
10
+ * a 400, and an unauthenticated caller never reaches the probe query at
11
+ * all), and a database fault is reported as resolve-failed rather than a
12
+ * 500 leaking driver detail.
13
+ */
14
+ import { describe, expect, mock, test } from "bun:test";
15
+ import { Hono } from "hono";
16
+
17
+ type QueryRowsCall = { sql: string; values: unknown[] };
18
+
19
+ let session: { user?: { id?: string } } | null = null;
20
+ let probeRows: Array<{ id: string }> = [];
21
+ let memberRows: Array<Record<string, unknown>> = [];
22
+ let failMemberQuery = false;
23
+ const queryCalls: QueryRowsCall[] = [];
24
+
25
+ mock.module("@teamscala/auth/get-session", () => ({
26
+ getSession: async () => session,
27
+ }));
28
+
29
+ mock.module("@teamscala/db/query-rows", () => ({
30
+ queryRows: (strings: TemplateStringsArray, ...values: unknown[]) => {
31
+ const sql = strings.join("?");
32
+ queryCalls.push({ sql, values });
33
+ if (sql.includes("role IN")) {
34
+ if (failMemberQuery) return Promise.reject(new Error("probe down"));
35
+ return Promise.resolve(probeRows);
36
+ }
37
+ if (failMemberQuery) return Promise.reject(new Error("connection reset"));
38
+ return Promise.resolve(memberRows);
39
+ },
40
+ }));
41
+
42
+ import { attachPortalMembers } from "./attach-portal-members.ts";
43
+
44
+ function makeApp(): Hono {
45
+ const app = new Hono();
46
+ return app;
47
+ }
48
+
49
+ async function get(app: Hono, url: string): Promise<Response> {
50
+ return app.request(url, { headers: new Headers({ cookie: "s=1" }) });
51
+ }
52
+
53
+ describe("GET /api/portal/members", () => {
54
+ test("an orgId read binds the orgId as a parameter and returns the joined rows", async () => {
55
+ const app = makeApp();
56
+ await attachPortalMembers(app);
57
+ memberRows = [
58
+ {
59
+ id: "m1",
60
+ user_id: "u1",
61
+ organization_id: "org-9",
62
+ role: "admin",
63
+ email: "a@x.test",
64
+ name: "A",
65
+ image: null,
66
+ },
67
+ ];
68
+ const res = await get(app, "/api/portal/members?orgId=org-9");
69
+ expect(res.status).toBe(200);
70
+ const body = (await res.json()) as { members: Array<{ id: string }> };
71
+ expect(body.members).toHaveLength(1);
72
+ expect(body.members[0].id).toBe("m1");
73
+ const memberCall = queryCalls.at(-1);
74
+ expect(memberCall?.values).toContain("org-9");
75
+ });
76
+
77
+ test("a session without an admin role gets 400 on the org-less (all-orgs) mode", async () => {
78
+ const app = makeApp();
79
+ await attachPortalMembers(app);
80
+ session = { user: { id: "u-viewer" } };
81
+ probeRows = [];
82
+ const res = await get(app, "/api/portal/members");
83
+ expect(res.status).toBe(400);
84
+ const body = (await res.json()) as { error: string; members: string[] };
85
+ expect(body.error).toContain("orgId query parameter required");
86
+ // The probe DID run (the role check is the only thing that refused) —
87
+ // assert the probe SQL is the role-scoped one, parameterised by user id.
88
+ const probe = queryCalls.at(-1);
89
+ expect(probe?.sql).toContain("role IN");
90
+ expect(probe?.values).toContain("u-viewer");
91
+ });
92
+
93
+ test("an admin session unlocks the all-orgs mode", async () => {
94
+ const app = makeApp();
95
+ await attachPortalMembers(app);
96
+ session = { user: { id: "u-admin" } };
97
+ probeRows = [{ id: "u-admin" }];
98
+ memberRows = [{ id: "m-all", organization_id: "org-1" }];
99
+ const res = await get(app, "/api/portal/members");
100
+ expect(res.status).toBe(200);
101
+ const body = (await res.json()) as { members: Array<{ id: string }> };
102
+ expect(body.members[0].id).toBe("m-all");
103
+ });
104
+
105
+ test("no session never reaches the member query and answers 400", async () => {
106
+ const app = makeApp();
107
+ await attachPortalMembers(app);
108
+ session = null;
109
+ const callsBefore = queryCalls.length;
110
+ const res = await get(app, "/api/portal/members");
111
+ expect(res.status).toBe(400);
112
+ expect(queryCalls.length).toBe(callsBefore);
113
+ });
114
+
115
+ test("a database fault reports resolve-failed instead of leaking driver detail", async () => {
116
+ const app = makeApp();
117
+ await attachPortalMembers(app);
118
+ failMemberQuery = true;
119
+ const res = await get(app, "/api/portal/members?orgId=org-9");
120
+ const body = (await res.json()) as { error: string };
121
+ expect(body.error).toBe("resolve-failed");
122
+ failMemberQuery = false;
123
+ });
124
+ });
@@ -42,14 +42,17 @@ export async function attachPortalMembers(app: Hono): Promise<void> {
42
42
  // View As switcher): user.role is "admin" OR "super_admin".
43
43
  const session = await getSession(c.req.raw.headers);
44
44
  const userId = session?.user?.id;
45
- const { getPrisma } = await import("@teamscala/db/prisma-registry");
46
- const superAdminUser = userId
47
- ? await getPrisma().user.findFirst({
48
- where: { id: userId, role: { in: ["admin", "super_admin"] } },
49
- select: { id: true },
50
- })
51
- : null;
52
- if (!superAdminUser) {
45
+ // Same data-access shape as the member query below (queryRows, the
46
+ // sanctioned raw-SQL runner): the super-admin probe is a scalar
47
+ // lookup, not a Prisma model call (no-direct-prisma-outside-orpc).
48
+ const superAdminRows = userId
49
+ ? await queryRows`
50
+ SELECT id FROM "user"
51
+ WHERE id = ${userId} AND role IN ('admin', 'super_admin')
52
+ LIMIT 1
53
+ ` as Array<{ id: string }>
54
+ : [];
55
+ if (superAdminRows.length === 0) {
53
56
  return c.json({ error: "orgId query parameter required", members: [] }, 400);
54
57
  }
55
58
  try {
@@ -78,7 +81,6 @@ export async function attachPortalMembers(app: Hono): Promise<void> {
78
81
  }
79
82
 
80
83
  try {
81
- const { getPrisma } = await import("@teamscala/db/prisma-registry");
82
84
  const rows = await queryRows`
83
85
  SELECT m.id, m.user_id, m.organization_id, m.role,
84
86
  u.email, u.name, u.image
package/src/auth-gate.ts CHANGED
@@ -73,6 +73,7 @@
73
73
  import type { ServiceInstallConfig } from "./load-service-install-row";
74
74
  import { getPrisma } from "@teamscala/db/prisma-registry";
75
75
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
76
+ import type { ConfigKey } from "@teamscala/db/registry/generated/config";
76
77
  import { resolveAccess } from "@teamscala/auth/resolve-access";
77
78
  import * as v from "valibot";
78
79
  import { configure as configureEventLog } from "@teamscala/event-log/configure";
@@ -276,9 +277,14 @@ export async function attachAuthGate(
276
277
  const accessCfg = (config as { access?: { allowlist_config_slug?: string } })
277
278
  .access;
278
279
  if (accessCfg?.allowlist_config_slug && session?.user && !onSignInPath) {
280
+ // allowlist_config_slug is service_install runtime config: a config row
281
+ // created after this build is a legitimate value, so the generated slug
282
+ // union (a build-time snapshot) is asserted, never enforced. An unknown
283
+ // slug still fails closed — loadRegistryConfig throws on a missing row
284
+ // and the gate below treats an unread allowlist as not-allowed.
279
285
  const allowRow = await loadRegistryConfig(
280
286
  "config",
281
- accessCfg.allowlist_config_slug,
287
+ accessCfg.allowlist_config_slug as ConfigKey,
282
288
  v.looseObject({}),
283
289
  );
284
290
  const allowed =
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @system server-handlers
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Fixtures for the @source candidate scan. The load-bearing case is the
7
+ * DIRECTORY probe: sources are directories, and a Bun.file(dir).exists()
8
+ * probe answers false for them — which silently skipped every source,
9
+ * zeroed the candidate set, and stripped all Tailwind utilities from every
10
+ * kind:ui service's stylesheet (2026-09-01, operator-visible).
11
+ */
12
+ import { describe, expect, test } from "bun:test";
13
+ import { mkdir, rm, writeFile } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+ import { scanSourceCandidates } from "./source-candidates.ts";
17
+
18
+ const noopLog = { warn: () => {}, info: () => {}, error: () => {} } as never;
19
+
20
+ async function makeSourceTree(tag: string, files: Record<string, string>) {
21
+ const root = join(tmpdir(), `source-candidates-${tag}-${process.pid}`);
22
+ await rm(root, { recursive: true, force: true });
23
+ for (const [rel, content] of Object.entries(files)) {
24
+ const p = join(root, rel);
25
+ await mkdir(p.slice(0, p.lastIndexOf("/")), { recursive: true });
26
+ await writeFile(p, content);
27
+ }
28
+ return root;
29
+ }
30
+
31
+ describe("scanSourceCandidates", () => {
32
+ test("scans a DIRECTORY source and extracts utility candidates", async () => {
33
+ const root = await makeSourceTree("dir-src", {
34
+ "src/Button.tsx": 'export const k = "h-12 px-6 text-base rounded-md";',
35
+ });
36
+ const candidates = await scanSourceCandidates(
37
+ [{ base: root, pattern: "src" }],
38
+ noopLog,
39
+ );
40
+ expect(candidates).toContain("h-12");
41
+ expect(candidates).toContain("px-6");
42
+ expect(candidates).toContain("text-base");
43
+ expect(candidates).toContain("rounded-md");
44
+ await rm(root, { recursive: true, force: true });
45
+ });
46
+
47
+ test("skips an absent source silently instead of zeroing the scan", async () => {
48
+ const root = await makeSourceTree("absent-src", {
49
+ "src/Keep.tsx": "flex gap-2",
50
+ });
51
+ const candidates = await scanSourceCandidates(
52
+ [
53
+ { base: root, pattern: "src" },
54
+ { base: root, pattern: "does-not-exist" },
55
+ ],
56
+ noopLog,
57
+ );
58
+ expect(candidates).toContain("flex");
59
+ expect(candidates).toContain("gap-2");
60
+ await rm(root, { recursive: true, force: true });
61
+ });
62
+ });
@@ -21,8 +21,21 @@
21
21
  */
22
22
 
23
23
  import { resolve } from "node:path";
24
+ import { stat } from "node:fs/promises";
24
25
  import { getAppLogger } from "@teamscala/logger/app-loggers";
25
26
 
27
+ // Directory-capable existence probe. Bun.file().exists() answers false for
28
+ // directories, so the @source roots (directories by construction) were all
29
+ // "absent" and the candidate scan returned nothing.
30
+ async function existsStat(path: string): Promise<boolean> {
31
+ try {
32
+ await stat(path);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
26
39
  // Tailwind v4's low-level `compile()` does NOT scan content — `compiler.build( candidates)` only emits utilities for the candidate class names it is GIVEN.
27
40
  // `build([])` therefore emits ZERO content-driven utilities (only the static theme/base + @utility/@layer + imported package CSS). The workspace's own
28
41
  // blocks mostly use inline styles + hand-written `blk-*` package classes (no-tailwind-in-blocks / layout-properties-inline), so the missing utility
@@ -67,7 +80,14 @@ export async function scanSourceCandidates(
67
80
  // that package resolves to a non-existent dir. Skip silently rather than
68
81
  // warn on every UI-service boot (the log-flood class — the ENOENT whose
69
82
  // path rendered with a trailing null byte). Real errors below still warn.
70
- if (!await Bun.file(root).exists()) continue;
83
+ // The probe MUST be a stat, never Bun.file(root).exists(): root is a
84
+ // DIRECTORY and Bun.file() on a directory reports exists() === false
85
+ // (the same defect discover-blocks.ts measured 2026-09-01). That probe
86
+ // skipped EVERY source, collapsed the candidate set to zero, and
87
+ // `build([])` stripped every Tailwind utility from every kind:ui
88
+ // service's stylesheet at once — the operator's "the css is totally
89
+ // fucked" with a stylesheet that loads and 15k package rules intact.
90
+ if (!await existsStat(root)) continue;
71
91
  // A root INSIDE node_modules is an explicit `@source` into an installed
72
92
  // block package (the app-css transform emits one per package) — scan it
73
93
  // fully. A root OUTSIDE node_modules is the service's own tree — scan it
@@ -0,0 +1,157 @@
1
+ /**
2
+ * @system server-handlers
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Behavioural pins for the stylesheet resolver (operator ruling 2026-09-03:
7
+ * the fallback chain IS the drift — a bare id resolved via Bun.resolve
8
+ * returned whatever the package's main was (a .js for a normal package),
9
+ * the resolve(base, id) tail invented nonexistent paths whose ENOENT
10
+ * surfaced as misleading downstream CSS errors, and inlineCssImports
11
+ * SWALLOWED unresolved imports, leaving literals that failed downstream
12
+ * instead of at the build. Every case below pins the ruled contract:
13
+ * resolve or THROW naming the specifier; remote https imports are skipped
14
+ * explicitly, never resolved and never thrown on.
15
+ */
16
+ import { describe, expect, test } from "bun:test";
17
+ import { mkdir, rm, writeFile } from "node:fs/promises";
18
+ import { join } from "node:path";
19
+ import { tmpdir } from "node:os";
20
+ import { bunResolveStylesheet } from "./styles-build.ts";
21
+
22
+ async function makeTree(tag: string, files: Record<string, string>) {
23
+ const root = join(tmpdir(), `styles-build-${tag}-${process.pid}`);
24
+ await rm(root, { recursive: true, force: true });
25
+ for (const [rel, content] of Object.entries(files)) {
26
+ const p = join(root, rel);
27
+ await mkdir(p.slice(0, p.lastIndexOf("/")), { recursive: true });
28
+ await writeFile(p, content);
29
+ }
30
+ return root;
31
+ }
32
+
33
+ describe("bunResolveStylesheet — relative ids", () => {
34
+ test("a relative import that exists resolves and inlines its nested @imports", async () => {
35
+ const root = await makeTree("rel-ok", {
36
+ "src/entry.css": '@import "./leaf.css";\n.body { color: red; }\n',
37
+ "src/leaf.css": ".leaf { color: blue; }\n",
38
+ });
39
+ const r = await bunResolveStylesheet("./entry.css", join(root, "src"), root);
40
+ expect(r.content).toContain(".body");
41
+ expect(r.content).toContain(".leaf");
42
+ await rm(root, { recursive: true, force: true });
43
+ });
44
+
45
+ test("a relative import that does NOT exist throws naming the specifier and the path", async () => {
46
+ const root = await makeTree("rel-missing", {
47
+ "src/entry.css": ".body { color: red; }\n",
48
+ });
49
+ const err = await bunResolveStylesheet(
50
+ "./nope.css",
51
+ join(root, "src"),
52
+ root,
53
+ ).catch((e) => e);
54
+ expect(err).toBeInstanceOf(Error);
55
+ expect((err as Error).message).toContain('"./nope.css"');
56
+ expect((err as Error).message).toContain("not found");
57
+ await rm(root, { recursive: true, force: true });
58
+ });
59
+ });
60
+
61
+ describe("bunResolveStylesheet — bare ids", () => {
62
+ test("a CSS-only package resolves via its declared style entry", async () => {
63
+ const root = await makeTree("bare-style", {
64
+ "node_modules/tw-animate-css/package.json": JSON.stringify({
65
+ name: "tw-animate-css",
66
+ exports: { ".": { style: "./dist/tw-animate.css" } },
67
+ }),
68
+ "node_modules/tw-animate-css/dist/tw-animate.css":
69
+ ".tw-animate { animation: none; }\n",
70
+ "src/app.css": '.x { color: red; }\n',
71
+ });
72
+ const r = await bunResolveStylesheet(
73
+ "tw-animate-css",
74
+ join(root, "src"),
75
+ root,
76
+ );
77
+ expect(r.content).toContain(".tw-animate");
78
+ await rm(root, { recursive: true, force: true });
79
+ });
80
+
81
+ test("a normal JS package (no style entry) THROWS naming the specifier — never its .js main", async () => {
82
+ const root = await makeTree("bare-js-main", {
83
+ "node_modules/lodash/package.json": JSON.stringify({
84
+ name: "lodash",
85
+ main: "lodash.js",
86
+ }),
87
+ "node_modules/lodash/lodash.js": "module.exports = {};\n",
88
+ "src/app.css": ".x { color: red; }\n",
89
+ });
90
+ const err = await bunResolveStylesheet(
91
+ "lodash",
92
+ join(root, "src"),
93
+ root,
94
+ ).catch((e) => e);
95
+ expect(err).toBeInstanceOf(Error);
96
+ expect((err as Error).message).toContain('"lodash"');
97
+ expect((err as Error).message).toContain("no style entry");
98
+ expect((err as Error).message).not.toContain("lodash.js");
99
+ await rm(root, { recursive: true, force: true });
100
+ });
101
+
102
+ test("a bare id with no package anywhere THROWS naming the specifier and both roots", async () => {
103
+ const root = await makeTree("bare-missing", {
104
+ "src/app.css": ".x { color: red; }\n",
105
+ });
106
+ const src = join(root, "src");
107
+ const err = await bunResolveStylesheet("no-such-pkg", src, root).catch(
108
+ (e) => e,
109
+ );
110
+ expect(err).toBeInstanceOf(Error);
111
+ expect((err as Error).message).toContain('"no-such-pkg"');
112
+ expect((err as Error).message).toContain("unresolved");
113
+ expect((err as Error).message).toContain(src);
114
+ await rm(root, { recursive: true, force: true });
115
+ });
116
+ });
117
+
118
+ describe("bunResolveStylesheet — remote @imports", () => {
119
+ test("a remote https @import is kept verbatim (stripped later by stripRemoteImports), never resolved", async () => {
120
+ const root = await makeTree("remote-skip", {
121
+ "node_modules/cdn-fonts/package.json": JSON.stringify({
122
+ name: "cdn-fonts",
123
+ exports: { ".": { style: "./index.css" } },
124
+ }),
125
+ "node_modules/cdn-fonts/index.css":
126
+ '@import "https://rsms.me/inter/inter.css";\n.cdn { font-family: inter; }\n',
127
+ "src/app.css": ".x { color: red; }\n",
128
+ });
129
+ const r = await bunResolveStylesheet(
130
+ "cdn-fonts",
131
+ join(root, "src"),
132
+ root,
133
+ );
134
+ expect(r.content).toContain("https://rsms.me/inter/inter.css");
135
+ expect(r.content).toContain(".cdn");
136
+ await rm(root, { recursive: true, force: true });
137
+ });
138
+
139
+ test("a LOCAL @import of a missing file THROWS at the build instead of leaving a literal", async () => {
140
+ const root = await makeTree("local-missing-nested", {
141
+ "node_modules/broken-pkg/package.json": JSON.stringify({
142
+ name: "broken-pkg",
143
+ exports: { ".": { style: "./index.css" } },
144
+ }),
145
+ "node_modules/broken-pkg/index.css": '@import "./gone.css";\n.real { color: red; }\n',
146
+ "src/app.css": ".x { color: red; }\n",
147
+ });
148
+ const err = await bunResolveStylesheet(
149
+ "broken-pkg",
150
+ join(root, "src"),
151
+ root,
152
+ ).catch((e) => e);
153
+ expect(err).toBeInstanceOf(Error);
154
+ expect((err as Error).message).toContain('"./gone.css"');
155
+ await rm(root, { recursive: true, force: true });
156
+ });
157
+ });
@@ -31,31 +31,52 @@ async function resolveStylesheetRaw(
31
31
  const r = await Bun.resolve("tailwindcss/index.css", cwd);
32
32
  return { path: r, base: dirname(r), content: await Bun.file(r).text() };
33
33
  }
34
- try {
35
- const r = await Bun.resolve(id, base);
36
- return { path: r, base: dirname(r), content: await Bun.file(r).text() };
37
- } catch (error) {
38
- getAppLogger().warn("style import resolve failed — trying package fallback", error);
34
+ if (id.startsWith(".")) {
35
+ // Relative import: resolve against the importing file's directory and
36
+ // REQUIRE existence — a missing leaf is a build-breaking error naming
37
+ // the specifier, never a silently-dropped subtree.
38
+ const p = resolve(base, id);
39
+ if (!(await Bun.file(p).exists())) {
40
+ throw new Error(
41
+ `style import "${id}" not found at ${p} (resolved relative to ${base})`,
42
+ );
43
+ }
44
+ return { path: p, base: dirname(p), content: await Bun.file(p).text() };
39
45
  }
40
- if (!id.startsWith(".")) {
41
- try {
42
- const pkgPath = await Bun.resolve(id + "/package.json", cwd);
43
- const pkg = JSON.parse(await Bun.file(pkgPath).text());
44
- const entry =
45
- pkg.exports?.["."]?.style || pkg.style || pkg.main;
46
- if (entry) {
46
+ // Bare specifier: find node_modules/<id>/package.json by walking upward
47
+ // from the importing file's dir then cwd, and read the DECLARED STYLE
48
+ // entry (exports["."].style / style). Bun.resolve is not used here: it
49
+ // hands back whatever the package's main is — for a normal JS package
50
+ // that is a .js entry, not the declared stylesheet. A package with no
51
+ // style entry is not a stylesheet import: that is a build-breaking
52
+ // error naming the specifier, never an invented path whose ENOENT
53
+ // surfaces as a misleading downstream CSS error.
54
+ const tried: string[] = [];
55
+ for (const root of [base, cwd]) {
56
+ let dir = root;
57
+ for (;;) {
58
+ const pkgPath = join(dir, "node_modules", id, "package.json");
59
+ if (await Bun.file(pkgPath).exists()) {
60
+ const pkg = await Bun.file(pkgPath).json();
61
+ const entry: string | undefined =
62
+ pkg.exports?.["."]?.style ?? pkg.style;
63
+ if (!entry) {
64
+ throw new Error(
65
+ `style import "${id}" resolved to ${pkgPath} but the package declares no style entry (exports["."].style / style) — not a stylesheet package`,
66
+ );
67
+ }
47
68
  const r = resolve(dirname(pkgPath), entry);
48
69
  return { path: r, base: dirname(r), content: await Bun.file(r).text() };
49
70
  }
50
- } catch (error) {
51
- getAppLogger().warn(
52
- "package style entry resolve failed — falling back to bare resolve",
53
- error,
54
- );
71
+ tried.push(pkgPath);
72
+ const parent = dirname(dir);
73
+ if (parent === dir) break;
74
+ dir = parent;
55
75
  }
56
76
  }
57
- const r = resolve(base, id);
58
- return { path: r, base: dirname(r), content: await Bun.file(r).text() };
77
+ throw new Error(
78
+ `style import "${id}" unresolved — no node_modules/${id}/package.json found walking up from ${base} or ${cwd} (${tried.length} location(s) tried)`,
79
+ );
59
80
  }
60
81
 
61
82
  // Tailwind v4's compile() invokes `loadStylesheet` for DIRECT @imports in the entry (app.css) but does NOT re-invoke it for @imports WITHIN the loaded
@@ -87,14 +108,18 @@ async function inlineCssImports(
87
108
  result += m[0];
88
109
  continue;
89
110
  }
111
+ // Remote @imports are CSP-forbidden and stripped by
112
+ // stripRemoteImports AFTER the compile: skip them here EXPLICITLY —
113
+ // they must never reach resolveStylesheetRaw, and an unresolved
114
+ // LOCAL import below is a throw, not a swallow.
115
+ if (spec.startsWith("http://") || spec.startsWith("https://")) {
116
+ result += m[0];
117
+ continue;
118
+ }
90
119
  if (seen.has(spec)) continue; // cycle guard
91
120
  seen.add(spec);
92
- try {
93
- const resolved = await resolveStylesheetRaw(spec, base, cwd);
94
- result += await inlineCssImports(resolved.content, resolved.base, cwd, seen);
95
- } catch {
96
- result += m[0]; // unresolved — keep literal; CSS/Tailwind will surface it
97
- }
121
+ const resolved = await resolveStylesheetRaw(spec, base, cwd);
122
+ result += await inlineCssImports(resolved.content, resolved.base, cwd, seen);
98
123
  }
99
124
  result += css.slice(lastIdx);
100
125
  return result;
@@ -111,6 +136,8 @@ async function bunResolveStylesheet(
111
136
  return { ...resolved, content };
112
137
  }
113
138
 
139
+ export { bunResolveStylesheet };
140
+
114
141
  // The app owns its fonts via an explicit, CSP-allowlisted <link> in index.html (Google Fonts — `style-src ... fonts.googleapis.com`, `font-src ...
115
142
  // fonts.gstatic.com`). Third-party package CSS MUST NOT inject its own remote `@import url(http…)`: @puckeditor/core/dist/index.css opens with
116
143
  // `@import "https://rsms.me/inter/inter.css"`, an arbitrary CDN our CSP does NOT allow. The Tailwind compile inlines that mid-output, where it was either
@@ -56,7 +56,8 @@ export async function sendAuthorityReportEmail(opts: {
56
56
  const decrypted = rawSecret
57
57
  ? (decryptSecretConfig(rawSecret as never) as { token?: string })
58
58
  : null;
59
- if (!decrypted?.token) {
59
+ const resendToken = decrypted?.token;
60
+ if (!resendToken) {
60
61
  log.warn("[authority-report-email] Resend token unavailable — skipping");
61
62
  return;
62
63
  }
@@ -189,7 +190,7 @@ export async function sendAuthorityReportEmail(opts: {
189
190
  });
190
191
 
191
192
  const sendOnce = async (fromHeader: string, replyToHeader: string | undefined) =>
192
- sendResendEmail(decrypted.token, {
193
+ sendResendEmail(resendToken, {
193
194
  from: fromHeader,
194
195
  to: opts.to,
195
196
  subject: tpl.subject,
@@ -33,8 +33,6 @@ export async function resolveProposalAuthorizedEmails(
33
33
  proposalId: string,
34
34
  ): Promise<Set<string>> {
35
35
  try {
36
- const { getPrisma } = await import("@teamscala/db/prisma-registry");
37
- const prisma = getPrisma();
38
36
  const rows = await queryRows`
39
37
  SELECT lower(u.email) AS email
40
38
  FROM better_auth_member m
@@ -0,0 +1,164 @@
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
+ */
13
+ import { describe, expect, mock, test } from "bun:test";
14
+
15
+ const warns: string[] = [];
16
+ mock.module("@teamscala/logger/app-loggers", () => ({
17
+ getAppLogger: () => ({
18
+ warn: (message: string) => warns.push(message),
19
+ info: () => {},
20
+ error: () => {},
21
+ }),
22
+ }));
23
+
24
+ import {
25
+ applyLayoutDefaults,
26
+ EMPTY_PUCK_DATA,
27
+ pickRootProp,
28
+ readBrandTokens,
29
+ warnUnresolvedRegionSlot,
30
+ type RegionEntry,
31
+ } from "./page-data-shared.ts";
32
+
33
+ function region(
34
+ slot: string,
35
+ puckData: { root?: { props?: Record<string, unknown> }; content: unknown[] } | null,
36
+ ): RegionEntry {
37
+ return {
38
+ slot,
39
+ puckData: (puckData ?? {}) as RegionEntry["puckData"],
40
+ pageId: `${slot}-page`,
41
+ };
42
+ }
43
+
44
+ describe("EMPTY_PUCK_DATA", () => {
45
+ test("is the documented empty render: no props, no content", () => {
46
+ expect(EMPTY_PUCK_DATA).toEqual({ root: { props: {} }, content: [] });
47
+ });
48
+ });
49
+
50
+ describe("applyLayoutDefaults", () => {
51
+ test("fills a missing root and props object when absent", () => {
52
+ const data = { content: [] } as unknown as Parameters<
53
+ typeof applyLayoutDefaults
54
+ >[0];
55
+ applyLayoutDefaults(data, { orgId: "org-1" });
56
+ expect((data as { root?: { props?: Record<string, unknown> } }).root?.props).toEqual({
57
+ orgId: "org-1",
58
+ });
59
+ });
60
+
61
+ test("fills ONLY nullish props — an authored value is never clobbered", () => {
62
+ const data = {
63
+ root: { props: { orgId: "authored", empty: "", zero: 0 } },
64
+ content: [],
65
+ } as unknown as Parameters<typeof applyLayoutDefaults>[0];
66
+ applyLayoutDefaults(data, {
67
+ orgId: "default",
68
+ missing: "filled",
69
+ empty: "filled",
70
+ zero: "filled",
71
+ });
72
+ expect(data.root?.props).toEqual({
73
+ orgId: "authored",
74
+ empty: "",
75
+ zero: 0,
76
+ missing: "filled",
77
+ });
78
+ });
79
+
80
+ test("an empty defaults map is a no-op", () => {
81
+ const data = { content: [] } as unknown as Parameters<
82
+ typeof applyLayoutDefaults
83
+ >[0];
84
+ applyLayoutDefaults(data, {});
85
+ expect((data as { root?: unknown }).root).toBeUndefined();
86
+ });
87
+ });
88
+
89
+ describe("pickRootProp", () => {
90
+ test("returns the FIRST region carrying a non-empty value", () => {
91
+ const regions = [
92
+ region("a", { root: { props: { title: "" } }, content: [] }),
93
+ region("b", { root: { props: { title: "winner" } }, content: [] }),
94
+ region("c", { root: { props: { title: "loser" } }, content: [] }),
95
+ ];
96
+ expect(pickRootProp<string>(regions, "title")).toBe("winner");
97
+ });
98
+
99
+ test("skips regions without root props and returns undefined when nothing carries the key", () => {
100
+ const regions = [
101
+ region("a", null),
102
+ region("b", { content: [] }),
103
+ region("c", { root: { props: { other: 1 } }, content: [] }),
104
+ ];
105
+ expect(pickRootProp<string>(regions, "title")).toBeUndefined();
106
+ });
107
+ });
108
+
109
+ describe("readBrandTokens", () => {
110
+ test("maps a full brand row to every token the render branches read", () => {
111
+ const tokens = readBrandTokens({
112
+ brand: {
113
+ primaryColor: "#0055ff",
114
+ headingFont: "Serif A",
115
+ bodyFont: "Sans B",
116
+ logo: { light: "/l.svg", dark: "/d.svg", brand: "/b.svg" },
117
+ theme: "dark",
118
+ },
119
+ });
120
+ expect(tokens).toEqual({
121
+ brand: {
122
+ primaryColor: "#0055ff",
123
+ headingFont: "Serif A",
124
+ bodyFont: "Sans B",
125
+ logo: { light: "/l.svg", dark: "/d.svg", brand: "/b.svg" },
126
+ theme: "dark",
127
+ },
128
+ primaryColor: "#0055ff",
129
+ headingFont: "Serif A",
130
+ bodyFont: "Sans B",
131
+ logo: { light: "/l.svg", dark: "/d.svg", brand: "/b.svg" },
132
+ brandTheme: "dark",
133
+ });
134
+ });
135
+
136
+ test("a missing brand row degrades to nulls instead of throwing", () => {
137
+ const tokens = readBrandTokens({});
138
+ expect(tokens.primaryColor).toBe("");
139
+ expect(tokens.headingFont).toBeNull();
140
+ expect(tokens.bodyFont).toBeNull();
141
+ expect(tokens.logo).toEqual({ light: null, dark: null, brand: null });
142
+ expect(tokens.brandTheme).toBeUndefined();
143
+ });
144
+
145
+ test("a non-string theme is ignored rather than passed through", () => {
146
+ const tokens = readBrandTokens({ brand: { theme: 7 } });
147
+ expect(tokens.brandTheme).toBeUndefined();
148
+ });
149
+ });
150
+
151
+ describe("warnUnresolvedRegionSlot", () => {
152
+ test("warns once per slot and stays silent on repeat calls", () => {
153
+ const slot = `test-unresolved-slot-${process.pid}-${Date.now()}`;
154
+ const before = warns.length;
155
+ warnUnresolvedRegionSlot(slot);
156
+ const afterFirst = warns.length;
157
+ expect(afterFirst).toBe(before + 1);
158
+ warnUnresolvedRegionSlot(slot);
159
+ warnUnresolvedRegionSlot(slot);
160
+ expect(warns.length).toBe(afterFirst);
161
+ // The warn names the slot so the operator can find the loader row.
162
+ expect(warns[afterFirst - 1]).toContain(slot);
163
+ });
164
+ });
@@ -0,0 +1,165 @@
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
+ */
14
+ import { describe, expect, mock, test } from "bun:test";
15
+
16
+ let loaderGraph: Record<string, unknown> = {};
17
+ const resolveCalls: Array<{ params: Record<string, string> }> = [];
18
+
19
+ mock.module("@teamscala/logger/app-loggers", () => ({
20
+ getAppLogger: () => ({
21
+ warn: () => {},
22
+ info: () => {},
23
+ error: () => {},
24
+ }),
25
+ }));
26
+
27
+ mock.module("../resolve-from-loader-config.ts", () => ({
28
+ resolveFromLoaderConfig: async (_cfg: unknown, params: Record<string, string>) => {
29
+ resolveCalls.push({ params });
30
+ return structuredClone(loaderGraph);
31
+ },
32
+ }));
33
+
34
+ mock.module("../content-page-floor.ts", () => ({
35
+ pageBodyIsEmpty: (page: unknown) =>
36
+ page != null &&
37
+ typeof page === "object" &&
38
+ Array.isArray((page as { content?: unknown }).content) &&
39
+ ((page as { content: unknown[] }).content.length === 0),
40
+ decideContentPageFloor: (opts: {
41
+ hasResourceType: boolean;
42
+ hasPageContentSource: boolean;
43
+ page: unknown;
44
+ }) => {
45
+ if (opts.hasResourceType || !opts.hasPageContentSource) return null;
46
+ if (opts.page == null) {
47
+ const e = new Error("page missing") as Error & { resourceType: string; httpStatus: number; empty: boolean };
48
+ e.resourceType = "page";
49
+ e.httpStatus = 404;
50
+ e.empty = false;
51
+ return e;
52
+ }
53
+ if (
54
+ Array.isArray((opts.page as { content?: unknown }).content) &&
55
+ (opts.page as { content: unknown[] }).content.length === 0
56
+ ) {
57
+ const e = new Error("page empty") as Error & { resourceType: string; httpStatus: number; empty: boolean };
58
+ e.resourceType = "page";
59
+ e.httpStatus = 200;
60
+ e.empty = true;
61
+ return e;
62
+ }
63
+ return null;
64
+ },
65
+ }));
66
+
67
+ mock.module("./graph-cache.ts", () => ({
68
+ staticShellGraphCache: { getWithMeta: () => undefined, set: () => {}, refreshInBackground: () => {} },
69
+ paramRouteGraphCache: { getWithMeta: () => undefined, set: () => {}, refreshInBackground: () => {} },
70
+ readCachedGraph: () => undefined,
71
+ }));
72
+
73
+ import { resolveRequestGraph, type ResolveRequestGraphArgs } from "./resolve-request-graph.ts";
74
+ import type { PageRouteConfig } from "../register-registry-page-route.ts";
75
+
76
+ type LoaderConfig = ResolveRequestGraphArgs["loaderConfig"];
77
+
78
+ function route(overrides: Partial<PageRouteConfig> = {}): PageRouteConfig {
79
+ return {
80
+ pattern: "/:id",
81
+ service: "scala-proposals",
82
+ loader_slug: "proposal-with-brand",
83
+ ...overrides,
84
+ };
85
+ }
86
+
87
+ function loader(sources: Array<{ key: string }>): LoaderConfig {
88
+ return { description: "test", loader_type: "shell", sources } as LoaderConfig;
89
+ }
90
+
91
+ describe("resolveRequestGraph", () => {
92
+ test("a bare root with a resource_type throws the 200 holding-page signal", async () => {
93
+ const err = (await resolveRequestGraph({
94
+ pattern: "/",
95
+ routeSlug: "proposal",
96
+ routeConfig: route({ pattern: "/", resource_type: "proposal" }),
97
+ loaderConfig: loader([{ key: "proposal" }]),
98
+ params: { host: "proposals.scala.business", path: "/" },
99
+ isStaticShell: false,
100
+ serviceSlug: "scala-proposals",
101
+ }).catch((e) => e)) as Error & { httpStatus: number; resourceType: string };
102
+ expect(err).toBeInstanceOf(Error);
103
+ expect((err as { httpStatus?: number }).httpStatus).toBe(200);
104
+ expect((err as { resourceType?: string }).resourceType).toBe("proposal");
105
+ });
106
+
107
+ test("a dead link (declared primary source resolving to nothing) throws the 404", async () => {
108
+ loaderGraph = { proposal: null };
109
+ const err = (await resolveRequestGraph({
110
+ pattern: "/:id",
111
+ routeSlug: "proposal",
112
+ routeConfig: route({ resource_type: "proposal" }),
113
+ loaderConfig: loader([{ key: "proposal" }]),
114
+ params: { host: "proposals.scala.business", path: "/missing" },
115
+ isStaticShell: false,
116
+ serviceSlug: "scala-proposals",
117
+ }).catch((e) => e)) as Error & { httpStatus: number; resourceType: string };
118
+ expect((err as { httpStatus?: number }).httpStatus).toBe(404);
119
+ expect((err as { resourceType?: string }).resourceType).toBe("proposal");
120
+ });
121
+
122
+ test("a content route with no page row throws the dead-link floor", async () => {
123
+ loaderGraph = { page: null };
124
+ const err = (await resolveRequestGraph({
125
+ pattern: "/:id",
126
+ routeSlug: "public-page",
127
+ routeConfig: route({ resource_type: undefined }),
128
+ loaderConfig: loader([{ key: "page" }]),
129
+ params: { host: "public.scala.business", path: "/deleted" },
130
+ isStaticShell: false,
131
+ serviceSlug: "scala-public",
132
+ }).catch((e) => e)) as Error & { httpStatus: number; empty: boolean };
133
+ expect((err as { httpStatus?: number }).httpStatus).toBe(404);
134
+ expect((err as { empty?: boolean }).empty).toBe(false);
135
+ });
136
+
137
+ test("a content route with an EMPTY page row throws the 200 empty-page floor", async () => {
138
+ loaderGraph = { page: { content: [] } };
139
+ const err = (await resolveRequestGraph({
140
+ pattern: "/:id",
141
+ routeSlug: "public-page",
142
+ routeConfig: route({ resource_type: undefined }),
143
+ loaderConfig: loader([{ key: "page" }]),
144
+ params: { host: "public.scala.business", path: "/empty" },
145
+ isStaticShell: false,
146
+ serviceSlug: "scala-public",
147
+ }).catch((e) => e)) as Error & { httpStatus: number; empty: boolean };
148
+ expect((err as { httpStatus?: number }).httpStatus).toBe(200);
149
+ expect((err as { empty?: boolean }).empty).toBe(true);
150
+ });
151
+
152
+ test("a resolved graph with the primary present returns it", async () => {
153
+ loaderGraph = { proposal: { id: "p1", name: "Q3" } };
154
+ const resolved = await resolveRequestGraph({
155
+ pattern: "/:id",
156
+ routeSlug: "proposal",
157
+ routeConfig: route({ resource_type: "proposal" }),
158
+ loaderConfig: loader([{ key: "proposal" }]),
159
+ params: { host: "proposals.scala.business", path: "/p1" },
160
+ isStaticShell: false,
161
+ serviceSlug: "scala-proposals",
162
+ });
163
+ expect((resolved.proposal as { id: string }).id).toBe("p1");
164
+ });
165
+ });
@@ -60,7 +60,9 @@ import type { CreatePageHandler, RendererComponentEntry } from "./types.ts";
60
60
  import {
61
61
  fingerprintLoaderConfig,
62
62
  readLiveLoaderConfig,
63
+ type LoaderConfig,
63
64
  } from "./register-registry-page-route/live-loader-config.ts";
65
+ import type { ResourceLoaderKey } from "@teamscala/db/registry/generated/resource-loader";
64
66
 
65
67
  export interface PageRouteConfig {
66
68
  pattern: string;
@@ -160,12 +162,15 @@ export async function registerRegistryPageRoute(
160
162
  for (const routeRow of serviceRoutes) {
161
163
  const routeConfig = routeRow.config;
162
164
  const pattern = routeConfig.pattern;
163
- const loaderSlug = routeConfig.loader_slug;
165
+ // loader_slug is a page_route runtime value and resource_loader is runtime
166
+ // data: a row created after this build is exactly the live-edit case this
167
+ // module serves, so the generated slug union (a build-time snapshot) can
168
+ // only be asserted, never enforced, here. An unknown slug still fails
169
+ // closed below (RegistryConfigMissingError skips the route).
170
+ const loaderSlug = routeConfig.loader_slug as ResourceLoaderKey;
164
171
  const routeProviders = routeConfig.providers ?? baseProviders;
165
172
 
166
- let loaderConfig: Awaited<
167
- ReturnType<typeof loadRegistryConfig<typeof ResourceLoaderConfigSchema>>
168
- > | null = null;
173
+ let loaderConfig: LoaderConfig | null = null;
169
174
  try {
170
175
  // Boot-time warm + validate: a missing row skips the route (warn),
171
176
  // an invalid row fails closed. resource_loader is NOT on the