@teamscala/service-boot 0.23.453 → 0.23.455
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 +9 -2
- package/src/agreement-gate/signing-shell.test.ts +168 -0
- package/src/agreement-gate/signing-shell.ts +0 -3
- package/src/attach-portal-members.test.ts +124 -0
- package/src/attach-portal-members.ts +11 -9
- package/src/auth-gate.ts +7 -1
- package/src/build-client-assets/source-candidates.test.ts +62 -0
- package/src/build-client-assets/source-candidates.ts +21 -1
- package/src/fn-routers/assessment-report-email.ts +3 -2
- package/src/register-registry-page-route/page-data-shared.test.ts +164 -0
- package/src/register-registry-page-route/resolve-request-graph.test.ts +165 -0
- package/src/register-registry-page-route.ts +9 -4
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
9
|
+
"version": "0.23.455",
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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(
|
|
193
|
+
sendResendEmail(resendToken, {
|
|
193
194
|
from: fromHeader,
|
|
194
195
|
to: opts.to,
|
|
195
196
|
subject: tpl.subject,
|
|
@@ -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
|
-
|
|
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:
|
|
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
|