@teamscala/service-boot 0.23.449 → 0.23.451

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.449",
9
+ "version": "0.23.451",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -27,6 +27,7 @@
27
27
  "@teamscala/document-signing": "*",
28
28
  "@teamscala/email": "*",
29
29
  "@teamscala/email-client": "*",
30
+ "@teamscala/email-template": "*",
30
31
  "@teamscala/encryption": "*",
31
32
  "@teamscala/event-log": "*",
32
33
  "@teamscala/file-lock": "*",
@@ -42,6 +43,7 @@
42
43
  "@teamscala/orpc": "*",
43
44
  "@teamscala/os": "*",
44
45
  "@teamscala/publish-registry": "*",
46
+ "@teamscala/puck-boundary": "*",
45
47
  "@teamscala/realtime-utils": "*",
46
48
  "@teamscala/resolver-engine": "*",
47
49
  "@teamscala/safe-fallback": "*",
@@ -1207,6 +1209,14 @@
1207
1209
  "types": "./src/resolve-theme-mode.d.ts",
1208
1210
  "default": "./src/resolve-theme-mode.ts"
1209
1211
  },
1212
+ "./resolvers/app-header": {
1213
+ "types": "./src/resolvers/app-header.d.ts",
1214
+ "default": "./src/resolvers/app-header.ts"
1215
+ },
1216
+ "./resolvers/app-header.ts": {
1217
+ "types": "./src/resolvers/app-header.d.ts",
1218
+ "default": "./src/resolvers/app-header.ts"
1219
+ },
1210
1220
  "./resolvers/fetch-org-brand": {
1211
1221
  "types": "./src/resolvers/fetch-org-brand.d.ts",
1212
1222
  "default": "./src/resolvers/fetch-org-brand.ts"
@@ -1287,6 +1297,14 @@
1287
1297
  "types": "./src/server-error-page.d.ts",
1288
1298
  "default": "./src/server-error-page.ts"
1289
1299
  },
1300
+ "./theme-fonts-link": {
1301
+ "types": "./src/theme-fonts-link.d.ts",
1302
+ "default": "./src/theme-fonts-link.ts"
1303
+ },
1304
+ "./theme-fonts-link.ts": {
1305
+ "types": "./src/theme-fonts-link.d.ts",
1306
+ "default": "./src/theme-fonts-link.ts"
1307
+ },
1290
1308
  "./two-phase-full-app": {
1291
1309
  "types": "./src/two-phase-full-app.d.ts",
1292
1310
  "default": "./src/two-phase-full-app.ts"
package/src/auth-gate.ts CHANGED
@@ -70,7 +70,7 @@
70
70
  // service-to-service auth still flows through the cookie/secret on the
71
71
  // request, never the source IP.
72
72
 
73
- import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
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
76
  import { resolveAccess } from "@teamscala/auth/resolve-access";
@@ -18,7 +18,7 @@ import type { PrismaClient } from "@teamscala/db/client";
18
18
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
19
19
  import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
20
20
  import { sendResendEmail } from "./resend-client.ts";
21
- import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
21
+ import { applyTemplateVars, lookupTemplate } from "@teamscala/email-template/lookup";
22
22
  import { getAppLogger } from "@teamscala/logger/app-loggers";
23
23
  import { signAgreementAccessToken } from "@teamscala/document-signing/agreement-access-token";
24
24
  import { parseAgreementParties } from "@teamscala/document-signing/agreement-parties";
@@ -30,7 +30,7 @@ import {
30
30
  type SecretConfigLike,
31
31
  } from "@teamscala/encryption/crypto/secret-config";
32
32
  import { sendResendEmail } from "./resend-client.ts";
33
- import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
33
+ import { applyTemplateVars, lookupTemplate } from "@teamscala/email-template/lookup";
34
34
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
35
35
  import type { PrismaClient } from "@teamscala/db/client";
36
36
  import { getAppLogger } from "@teamscala/logger/app-loggers";
@@ -15,7 +15,7 @@
15
15
  import * as v from "valibot";
16
16
  import { getAppLogger } from "@teamscala/logger/app-loggers";
17
17
  import { sendResendEmail } from "./resend-client.ts";
18
- import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
18
+ import { applyTemplateVars, lookupTemplate } from "@teamscala/email-template/lookup";
19
19
  import { resolveEmailSender } from "@teamscala/email/identity/resolve-email-sender";
20
20
  import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
21
21
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
@@ -13,7 +13,7 @@ import { os } from "@orpc/server";
13
13
  import * as v from "valibot";
14
14
  import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
15
15
  import { sendResendEmail } from "./resend-client.ts";
16
- import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
16
+ import { applyTemplateVars, lookupTemplate } from "@teamscala/email-template/lookup";
17
17
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
18
18
  import { getAppLogger } from "@teamscala/logger/app-loggers";
19
19
  import { signAccessToken } from "../content-gate/token.ts";
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { beforeEach, describe, expect, mock, test } from "bun:test";
16
- import { applyTemplateVars } from "@teamscala/email/templates/lookup";
16
+ import { applyTemplateVars } from "@teamscala/email-template/lookup";
17
17
 
18
18
  interface CapturedEmail {
19
19
  to: string;
@@ -58,7 +58,7 @@ mock.module("@teamscala/api-resend", () => ({
58
58
  // The row owns copy + layout; the test mirrors the authority-index-report
59
59
  // row's merge tokens so the substitution surface exercised is the real one.
60
60
  // applyTemplateVars stays the REAL implementation — only lookup is stubbed.
61
- mock.module("@teamscala/email/templates/lookup", () => ({
61
+ mock.module("@teamscala/email-template/lookup", () => ({
62
62
  lookupTemplate: async () => ({
63
63
  // Mirrors the live authority-index-report row, which leads with {{orgName}}
64
64
  // — a stub without it cannot catch a wrong org name in the subject.
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { getAllEntries } from "@teamscala/db/registry/accessors";
27
27
  import { persistRegistryEntryConfig } from "@teamscala/db/registry/persist-entry-config";
28
+ import { validateRow } from "@teamscala/db-validation/validate-row";
28
29
  import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
29
30
  import { recordEvent } from "@teamscala/event-log/record-event";
30
31
  import { getAppLogger } from "@teamscala/logger/app-loggers";
@@ -181,7 +182,7 @@ async function upstreamIssueSync(_ctx: { config: ScheduledJobConfig }): Promise<
181
182
  };
182
183
 
183
184
  try {
184
- await persistRegistryEntryConfig("upstream_issue", row.slug, nextConfig);
185
+ await persistRegistryEntryConfig("upstream_issue", row.slug, nextConfig, validateRow);
185
186
  } catch (err) {
186
187
  log.warn(
187
188
  `[upstream-issue-sync] row ${row.slug} write failed (validation or db): ${err instanceof Error ? err.message : String(err)}`,
@@ -24,7 +24,7 @@
24
24
  * only warms the cache, never gates boot.
25
25
  */
26
26
 
27
- import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
27
+ import type { ServiceInstallConfig } from "./load-service-install-row";
28
28
  import { getAppLogger } from "@teamscala/logger/app-loggers";
29
29
  import { BootFatalError } from "@teamscala/os/errors/boot-error";
30
30
  import {
@@ -13,7 +13,7 @@
13
13
  * walking when explicit caller headers are absent.
14
14
  */
15
15
 
16
- import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
16
+ import type { ServiceInstallConfig } from "./load-service-install-row";
17
17
  import { getAppLogger } from "@teamscala/logger/app-loggers";
18
18
  import { BootFatalError } from "@teamscala/os/errors/boot-error";
19
19
  import type { MountRegistry } from "@teamscala/os/runtime-contracts/mount-context";
@@ -0,0 +1,219 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit extend the regression set for the live loader-config contract
5
+ *
6
+ * Behavioural tests for the per-request resource_loader read (the fix for the
7
+ * boot-baked-loader defect): resource_loader is NOT on the deploy-time bake
8
+ * list, so a registry row edit must reach the running service WITHOUT a
9
+ * restart. The registration loop's boot read is warm/validate only.
10
+ *
11
+ * 1. Two requests to the same route with the row edited between them — the
12
+ * SECOND request's graph resolution receives the NEW source set, and the
13
+ * live read ran once per request (not once per boot).
14
+ * 2. A row that vanishes (or stops validating) between requests — the second
15
+ * request FAILS CLOSED (the read throws into the branded error path;
16
+ * the boot snapshot never silently outlives its row).
17
+ * 3. The graphKey fingerprint changes when the config changes — a row edit
18
+ * invalidates the per-route SWR graph cache immediately instead of
19
+ * serving the old graph until the fresh window lapses.
20
+ *
21
+ * Only the registry reader and the heavy graph collaborators are mocked; the
22
+ * real registrar, real Hono app, real loadData pipeline and the real
23
+ * live-loader-config unit are under test.
24
+ */
25
+
26
+ import { GlobalRegistrator } from "@happy-dom/global-registrator";
27
+ import { afterEach, describe, expect, mock, test } from "bun:test";
28
+
29
+ if (!globalThis.document) GlobalRegistrator.register();
30
+
31
+ // ── The mutable registry ────────────────────────────────────────────────
32
+ const calls: { slug: string; type: string }[] = [];
33
+ const store: {
34
+ routeRows: { slug: string; config: Record<string, unknown> }[];
35
+ loaderConfig: Record<string, unknown> | null;
36
+ } = {
37
+ routeRows: [],
38
+ loaderConfig: null,
39
+ };
40
+
41
+ mock.module("@teamscala/db/registry/load-config", () => {
42
+ class RegistryConfigMissingError extends Error {}
43
+ return {
44
+ RegistryConfigMissingError,
45
+ loadRegistryConfig: async (
46
+ type: string,
47
+ slug: string,
48
+ _schema: unknown,
49
+ ) => {
50
+ calls.push({ type, slug });
51
+ if (type === "resource_loader") {
52
+ if (!store.loaderConfig) throw new RegistryConfigMissingError(slug);
53
+ return store.loaderConfig;
54
+ }
55
+ // feature_flag rows absent — the registrar's legacy catch.
56
+ throw new RegistryConfigMissingError(slug);
57
+ },
58
+ loadRegistryConfigsByType: async () => store.routeRows,
59
+ };
60
+ });
61
+
62
+ // ── The heavy collaborators (recorded, not executed) ───────────────────
63
+ const graphCalls: { loaderConfig: Record<string, unknown>; configFingerprint?: string }[] = [];
64
+
65
+ mock.module("./resolve-request-graph.ts", () => ({
66
+ resolveRequestGraph: async (args: {
67
+ loaderConfig: Record<string, unknown>;
68
+ configFingerprint?: string;
69
+ }) => {
70
+ graphCalls.push({
71
+ loaderConfig: args.loaderConfig,
72
+ configFingerprint: args.configFingerprint,
73
+ });
74
+ return {};
75
+ },
76
+ }));
77
+ mock.module("./stamp-session-context.ts", () => ({
78
+ stampSessionContext: async () => ({ sessionUserId: null, sessionOrgId: null }),
79
+ }));
80
+ mock.module("./run-content-gates.ts", () => ({
81
+ runContentGatesForPage: async () => {},
82
+ }));
83
+ mock.module("./apply-page-branding.ts", () => ({
84
+ applyBrandOrgPath: async () => {},
85
+ applySessionOrgBranding: async () => {},
86
+ applySignInBrandOverride: async () => {},
87
+ }));
88
+ mock.module("./resolve-panel-page.ts", () => ({
89
+ attachPanelPage: async () => {},
90
+ }));
91
+ mock.module("./warn-uncovered-blocks.ts", () => ({
92
+ warnUncoveredBlocks: () => {},
93
+ }));
94
+ mock.module("./resolve-page-data.ts", () => ({
95
+ resolvedToPageData: () => ({ puckData: { content: [] } }),
96
+ }));
97
+ mock.module("./ai-chat-prefetch.ts", () => ({
98
+ prefetchAiChatSession: async () => {},
99
+ }));
100
+ mock.module("./proposal-approval-email-token.ts", () => ({
101
+ applyApprovalEmailToken: async () => {},
102
+ }));
103
+ // bindPageHandler → run the captured loadData directly so the test drives
104
+ // the per-request path end to end.
105
+ mock.module("../bind-page-handler.ts", () => ({
106
+ bindPageHandler: async (
107
+ c: { req: { raw: Request }; json: (body: unknown) => Response },
108
+ handler: { loadData: (req: Request) => Promise<unknown> },
109
+ _request: Request,
110
+ ) => c.json(await handler.loadData(c.req.raw)),
111
+ }));
112
+ mock.module("@teamscala/logger/app-loggers", () => ({
113
+ getAppLogger: () => ({ info: () => {}, warn: () => {} }),
114
+ }));
115
+
116
+ const { registerRegistryPageRoute } = await import(
117
+ "../register-registry-page-route.ts"
118
+ );
119
+ const { fingerprintLoaderConfig } = await import("./live-loader-config.ts");
120
+ const { Hono } = await import("hono");
121
+
122
+ afterEach(() => {
123
+ calls.length = 0;
124
+ graphCalls.length = 0;
125
+ store.routeRows = [];
126
+ store.loaderConfig = null;
127
+ });
128
+
129
+ async function registerRoute(): Promise<InstanceType<typeof Hono>> {
130
+ const app = new Hono();
131
+ await registerRegistryPageRoute({
132
+ app: app as never,
133
+ serviceSlug: "test-svc",
134
+ components: {},
135
+ createPageHandler: (args: { loadData: unknown }) =>
136
+ ({ loadData: args.loadData }) as never,
137
+ authMode: "none",
138
+ renderMode: "public" as never,
139
+ providers: [],
140
+ portalScoped: false,
141
+ } as never);
142
+ return app;
143
+ }
144
+
145
+ function seedRouteRow() {
146
+ store.routeRows = [
147
+ {
148
+ slug: "test-route",
149
+ config: {
150
+ pattern: "/:id",
151
+ service: "test-svc",
152
+ loader_slug: "test-loader",
153
+ render_mode: "public",
154
+ },
155
+ },
156
+ ];
157
+ }
158
+
159
+ const requestRoute = (app: InstanceType<typeof Hono>) =>
160
+ app.request("/abc", { headers: { host: "test.local" } });
161
+
162
+ describe("live loader config — per-request read (boot-baked-loader fix)", () => {
163
+ test("a row edit between two requests reaches the second request without a restart", async () => {
164
+ seedRouteRow();
165
+ store.loaderConfig = { sources: [{ key: "page" }] };
166
+ const app = await registerRoute();
167
+
168
+ await requestRoute(app);
169
+ expect(graphCalls[0]?.loaderConfig).toEqual({
170
+ sources: [{ key: "page" }],
171
+ });
172
+
173
+ // THE EDIT — no restart, no redeploy.
174
+ store.loaderConfig = {
175
+ sources: [{ key: "page" }, { key: "agreement" }],
176
+ };
177
+
178
+ await requestRoute(app);
179
+ const loaderReads = calls.filter(
180
+ (c) => c.type === "resource_loader" && c.slug === "test-loader",
181
+ );
182
+ // Boot warm/validate counts once; per-request reads add one per request.
183
+ // The fix's contract is that the count GROWS past the boot read — a
184
+ // single boot-time capture would read once total, never three.
185
+ expect(loaderReads.length).toBeGreaterThanOrEqual(3);
186
+ // The second request's graph resolution received the new source set.
187
+ const second = graphCalls.at(-1)?.loaderConfig as {
188
+ sources: { key: string }[];
189
+ };
190
+ expect(
191
+ second.sources.some((s) => s.key === "agreement"),
192
+ ).toBe(true);
193
+ });
194
+
195
+ test("a row that vanishes between requests fails closed", async () => {
196
+ seedRouteRow();
197
+ store.loaderConfig = { sources: [{ key: "page" }] };
198
+ const app = await registerRoute();
199
+ await requestRoute(app);
200
+
201
+ store.loaderConfig = null;
202
+
203
+ // Fail closed = the request ERORS (Hono maps the thrown read to a 500),
204
+ // never a 200 served from the boot-time snapshot of a dead row.
205
+ const res = await requestRoute(app);
206
+ expect(res.status).toBe(500);
207
+ });
208
+
209
+ test("the graphKey fingerprint changes with the config and is key-order stable", () => {
210
+ const a = fingerprintLoaderConfig({ sources: [{ key: "page" }] });
211
+ const b = fingerprintLoaderConfig({
212
+ sources: [{ key: "page" }, { key: "agreement" }],
213
+ });
214
+ expect(a).not.toBe(b);
215
+ expect(fingerprintLoaderConfig({ a: 1, b: 2 })).toBe(
216
+ fingerprintLoaderConfig({ b: 2, a: 1 }),
217
+ );
218
+ });
219
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * live-loader-config.ts — the per-request resource_loader read.
7
+ *
8
+ * The constitution's deploy-time bake list (installer-and-runtime) names
9
+ * service_install, service_manifest, block_schema, page_route, api_route and
10
+ * scheduled_job — resource_loader is NOT among it, so a loader row is runtime
11
+ * data: a registry edit must reach the running service on a subsequent request
12
+ * without a restart. This module is that contract: `readLiveLoaderConfig`
13
+ * re-reads the row through `loadRegistryConfig` on EVERY request (its SWR
14
+ * cache makes the fresh-window hit a memory read, not a DB round trip) and
15
+ * FAILS CLOSED — a row that vanished or stopped validating mid-flight throws
16
+ * into the branded error path rather than silently serving the boot-time
17
+ * snapshot of a row that no longer exists.
18
+ *
19
+ * `fingerprintLoaderConfig` gives the per-request graph cache a key component
20
+ * derived from the LIVE config, so a row edit invalidates cached graphs for
21
+ * that route immediately (the graph cache keys on route+host+path alone would
22
+ * otherwise serve a stale graph until its SWR window lapsed — right cache,
23
+ * wrong key: the graph is a function of the config, so the config belongs in
24
+ * the key).
25
+ */
26
+
27
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
28
+ import { ResourceLoaderConfigSchema } from "@teamscala/db-validation/registry-schemas/resource-loader";
29
+ import type { ResourceLoaderKey } from "@teamscala/db/registry/generated/resource-loader";
30
+ import * as v from "valibot";
31
+
32
+ export type LoaderConfig = v.InferOutput<typeof ResourceLoaderConfigSchema>;
33
+
34
+ export async function readLiveLoaderConfig(
35
+ loaderSlug: ResourceLoaderKey,
36
+ ): Promise<LoaderConfig> {
37
+ return loadRegistryConfig(
38
+ "resource_loader",
39
+ loaderSlug,
40
+ ResourceLoaderConfigSchema,
41
+ );
42
+ }
43
+
44
+ /** djb2 over the config's stable JSON — short, deterministic, SSR-safe. */
45
+ export function fingerprintLoaderConfig(config: unknown): string {
46
+ const json = JSON.stringify(config, sortedTopLevelKeys(config));
47
+ let hash = 5381;
48
+ for (let i = 0; i < json.length; i++) {
49
+ hash = ((hash << 5) + hash + json.charCodeAt(i)) | 0;
50
+ }
51
+ return (hash >>> 0).toString(36);
52
+ }
53
+
54
+ function sortedTopLevelKeys(config: unknown): string[] {
55
+ return config != null && typeof config === "object"
56
+ ? Object.keys(config).sort()
57
+ : [];
58
+ }
@@ -34,12 +34,17 @@ export interface ResolveRequestGraphArgs {
34
34
  params: Record<string, string>;
35
35
  isStaticShell: boolean;
36
36
  serviceSlug: string;
37
+ /** Stable fingerprint of the LIVE loader config (fingerprintLoaderConfig).
38
+ * Appended to the graphKey so a registry row edit invalidates cached
39
+ * graphs for the route on the very next request — the graph is a
40
+ * function of the config, so the config belongs in the cache key. */
41
+ configFingerprint?: string;
37
42
  }
38
43
 
39
44
  export async function resolveRequestGraph(
40
45
  args: ResolveRequestGraphArgs,
41
46
  ): Promise<Record<string, unknown>> {
42
- const { pattern, routeSlug, routeConfig, loaderConfig, params, isStaticShell, serviceSlug } = args;
47
+ const { pattern, routeSlug, routeConfig, loaderConfig, params, isStaticShell, serviceSlug, configFingerprint } = args;
43
48
  const log = getAppLogger();
44
49
 
45
50
  // A bare root ("/") that declares a resource_type is a UUID-driven
@@ -57,7 +62,7 @@ export async function resolveRequestGraph(
57
62
  // the default 404 (genuinely not found).
58
63
  throw new PrimaryResourceMissingError(routeConfig.resource_type, 200);
59
64
  }
60
- const graphKey = `graph:${routeSlug}:${params.host}:${params.path}`;
65
+ const graphKey = `graph:${routeSlug}:${params.host}:${params.path}${configFingerprint ? `:${configFingerprint}` : ""}`;
61
66
  const primaryType = routeConfig.resource_type;
62
67
  const hasPrimarySource =
63
68
  !!primaryType &&
@@ -57,6 +57,10 @@ import {
57
57
  applyClientLogo,
58
58
  } from "./register-registry-page-route/proposal-brand-stamping.ts";
59
59
  import type { CreatePageHandler, RendererComponentEntry } from "./types.ts";
60
+ import {
61
+ fingerprintLoaderConfig,
62
+ readLiveLoaderConfig,
63
+ } from "./register-registry-page-route/live-loader-config.ts";
60
64
 
61
65
  export interface PageRouteConfig {
62
66
  pattern: string;
@@ -163,6 +167,12 @@ export async function registerRegistryPageRoute(
163
167
  ReturnType<typeof loadRegistryConfig<typeof ResourceLoaderConfigSchema>>
164
168
  > | null = null;
165
169
  try {
170
+ // Boot-time warm + validate: a missing row skips the route (warn),
171
+ // an invalid row fails closed. resource_loader is NOT on the
172
+ // deploy-time bake list (constitution installer-and-runtime) — it
173
+ // is re-read per request via readLiveLoaderConfig below — so this
174
+ // boot read exists ONLY to catch a misconfigured row early, never
175
+ // as the source of truth for live rendering.
166
176
  loaderConfig = await loadRegistryConfig(
167
177
  "resource_loader",
168
178
  loaderSlug,
@@ -188,6 +198,16 @@ export async function registerRegistryPageRoute(
188
198
  !pattern.includes("*");
189
199
 
190
200
  const loadData = async (request: Request) => {
201
+ // resource_loader is runtime data, not deploy-time baked. Re-read it
202
+ // per request — loadRegistryConfig's SWR cache makes the fresh-window
203
+ // hit a memory read, not a DB round trip. Throws into the branded
204
+ // error path on a vanished or invalid row: the boot-time snapshot
205
+ // must NEVER silently outlive the row it captured. This is the fix
206
+ // for the loader-row-edits-look-like-they-don't-work defect (every
207
+ // edit used to need a service restart; the sign-page-with-brand
208
+ // `agreement` source added at 22:16 still hadn't reached
209
+ // __PAGE_DATA__.loaderData at 22:29 — 13 minutes, and never).
210
+ const liveLoaderConfig = await readLiveLoaderConfig(loaderSlug);
191
211
  const url = new URL(request.url);
192
212
  const params = extractUrlParams(pattern, url.pathname);
193
213
  params.host = request.headers.get("host")?.split(":")[0] || url.hostname;
@@ -196,10 +216,15 @@ export async function registerRegistryPageRoute(
196
216
  pattern,
197
217
  routeSlug: routeRow.slug,
198
218
  routeConfig,
199
- loaderConfig: loaderConfig as never,
219
+ loaderConfig: liveLoaderConfig as never,
200
220
  params,
201
221
  isStaticShell,
202
222
  serviceSlug,
223
+ // Folds the live config into the per-route graphKey so a row edit
224
+ // invalidates the cached graph immediately (the graph is a
225
+ // function of the config; without this the SWR graph cache
226
+ // would still hold the old shape until its fresh window lapsed).
227
+ configFingerprint: fingerprintLoaderConfig(liveLoaderConfig),
203
228
  });
204
229
  const { sessionUserId, sessionOrgId } = await stampSessionContext(
205
230
  resolved,
@@ -207,7 +232,7 @@ export async function registerRegistryPageRoute(
207
232
  );
208
233
  const gateCookie = request.headers.get("cookie");
209
234
  const regionSlots = (
210
- loaderConfig as { region_slots?: string[] }
235
+ liveLoaderConfig as { region_slots?: string[] }
211
236
  ).region_slots;
212
237
  await runContentGatesForPage(
213
238
  resolved,
@@ -375,12 +400,12 @@ export async function registerRegistryPageRoute(
375
400
  });
376
401
 
377
402
  registeredPatterns.push(pattern);
378
- // The loader config was captured at boot into the loadData closure — name
379
- // the captured shape + the restart contract so a row edit that "doesn't
380
- // work" on a running service is answered by this line, not an evening of
381
- // bisecting (measured 2026-08-18).
403
+ // Boot-validated snapshot (the LIVE read is per request — see
404
+ // readLiveLoaderConfig); name the shape so a misconfigured row is
405
+ // visible at boot, and note the live contract so "my loader edit
406
+ // didn't take effect" routes to the graph SWR window, not a restart.
382
407
  log.info(
383
- `[scala-install:${serviceSlug}] registered registry page route "${pattern}" → resource_loader "${loaderSlug}" (${describeCapturedLoaderConfig(loaderConfig)})`,
408
+ `[scala-install:${serviceSlug}] registered registry page route "${pattern}" → resource_loader "${loaderSlug}" (boot-validated: ${describeCapturedLoaderConfig(loaderConfig)}; re-read live per request)`,
384
409
  );
385
410
  }
386
411
 
@@ -25,7 +25,7 @@
25
25
 
26
26
  import { getAppRouter } from "@teamscala/orpc/app-router";
27
27
  import { mountOrpcWs } from "@teamscala/orpc/ws/server";
28
- import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
28
+ import type { ServiceInstallConfig } from "./load-service-install-row";
29
29
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
30
30
  import { getAppLogger } from "@teamscala/logger/app-loggers";
31
31
  import { BootFatalError } from "@teamscala/os/errors/boot-error";
@@ -13,8 +13,8 @@ import type { PrismaClient } from "@teamscala/db/client";
13
13
  import { loadServicePrismaClient } from "@teamscala/db/load-service-prisma-client";
14
14
  import { registerPrismaClient } from "@teamscala/db/prisma-registry";
15
15
  import { loadRegistryEntries } from "@teamscala/db/registry/loader/load-entries";
16
- import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
17
- import { loadServiceInstallRow } from "@teamscala/db/registry/loader/load-service-install-row";
16
+ import type { ServiceInstallConfig } from "./load-service-install-row";
17
+ import { loadServiceInstallRow } from "./load-service-install-row";
18
18
 
19
19
  export interface ServiceContext {
20
20
  kind: "ui" | "backend" | "runtime";
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ */
6
+ import { describe, expect, test } from "bun:test";
7
+ import { resolveAppHeader } from "./app-header.ts";
8
+
9
+ const ARGS = { client: null, params: {} } as const;
10
+
11
+ describe("resolveAppHeader", () => {
12
+ test("passes interpolated values through as strings", () => {
13
+ const out = resolveAppHeader({
14
+ ...ARGS,
15
+ input: {
16
+ title: "Anna's Proposal",
17
+ row1Label: "Prepared for",
18
+ row1Value: "Joe Bellissimo",
19
+ row2Label: "Organisation",
20
+ row2Value: "GMG APAC",
21
+ row3Label: "Date",
22
+ row3Value: "2026-09-03T00:00:00.000Z",
23
+ },
24
+ });
25
+ expect(out.title).toBe("Anna's Proposal");
26
+ expect(out.row1Label).toBe("Prepared for");
27
+ expect(out.row1Value).toBe("Joe Bellissimo");
28
+ expect(out.row3Value).toBe("2026-09-03T00:00:00.000Z");
29
+ });
30
+
31
+ test("coerces Prisma Date values to ISO strings", () => {
32
+ const out = resolveAppHeader({
33
+ ...ARGS,
34
+ input: {
35
+ title: "Report",
36
+ row3Value: new Date("2026-08-25T15:46:58.698Z"),
37
+ },
38
+ });
39
+ expect(out.row3Value).toBe("2026-08-25T15:46:58.698Z");
40
+ });
41
+
42
+ test("expands the today directive to a noon-UTC instant of the AEST date", () => {
43
+ const out = resolveAppHeader({ ...ARGS, input: { row3Value: "today" } });
44
+ const value = out.row3Value as string;
45
+ // Noon UTC keeps the calendar date stable in every timezone.
46
+ expect(value.endsWith("T12:00:00.000Z")).toBe(true);
47
+ const [isoDate] = value.split("T");
48
+ const aestDate = new Intl.DateTimeFormat("en-CA", {
49
+ timeZone: "Australia/Sydney",
50
+ year: "numeric",
51
+ month: "2-digit",
52
+ day: "2-digit",
53
+ }).format(new Date());
54
+ expect(isoDate).toBe(aestDate);
55
+ });
56
+
57
+ test("maps absent fields to null (the resource-not-blank contract: null, never a stale value)", () => {
58
+ const out = resolveAppHeader({ ...ARGS, input: { title: "Only a title" } });
59
+ expect(out.title).toBe("Only a title");
60
+ expect(out.row1Label).toBeNull();
61
+ expect(out.row1Value).toBeNull();
62
+ expect(out.row2Value).toBeNull();
63
+ expect(out.row3Value).toBeNull();
64
+ });
65
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * app-header.ts — shared application header resolver_fn.
7
+ *
8
+ * Shapes an app's per-row resolver_input into the canonical header-data object
9
+ * the ONE shared header Puck page (`/shared-app-header`) binds through the
10
+ * unified page-data surface (`dataSource:"page-data"` + `fieldPath:"headerData.<key>"`).
11
+ * The loader row owns the mapping (title field path, label/value pairs — the
12
+ * per-app variation); this executor owns the shape and the two coercions the
13
+ * rows cannot express:
14
+ *
15
+ * - `Date` values (Prisma returns Date objects) → ISO strings, so
16
+ * __PAGE_DATA__ serialises them the same way the client reads them
17
+ * (server-serializes-client-reads-verbatim).
18
+ * - the literal `"today"` → a noon-UTC ISO instant of the AEST calendar date.
19
+ * Noon keeps the calendar date stable under every timezone offset, so the
20
+ * header shows the same day on the server and on any visitor's client —
21
+ * the deterministic replacement for the old `format:"today"` clock block.
22
+ */
23
+
24
+ /** AEST calendar date (the operator timezone) as `YYYY-MM-DD`. */
25
+ function aestCalendarDate(now: Date): string {
26
+ return new Intl.DateTimeFormat("en-CA", {
27
+ timeZone: "Australia/Sydney",
28
+ year: "numeric",
29
+ month: "2-digit",
30
+ day: "2-digit",
31
+ }).format(now);
32
+ }
33
+
34
+ function coerceValue(value: unknown): string | null {
35
+ if (value == null) return null;
36
+ if (value instanceof Date) return value.toISOString();
37
+ if (value === "today") return `${aestCalendarDate(new Date())}T12:00:00.000Z`;
38
+ return String(value);
39
+ }
40
+
41
+ export interface AppHeaderInput {
42
+ title?: unknown;
43
+ row1Label?: unknown;
44
+ row1Value?: unknown;
45
+ row2Label?: unknown;
46
+ row2Value?: unknown;
47
+ row3Label?: unknown;
48
+ row3Value?: unknown;
49
+ }
50
+
51
+ export interface AppHeaderData {
52
+ title: string | null;
53
+ row1Label: string | null;
54
+ row1Value: string | null;
55
+ row2Label: string | null;
56
+ row2Value: string | null;
57
+ row3Label: string | null;
58
+ row3Value: string | null;
59
+ }
60
+
61
+ export function resolveAppHeader({
62
+ input,
63
+ }: {
64
+ input: AppHeaderInput;
65
+ client: unknown;
66
+ params: Record<string, unknown>;
67
+ }): AppHeaderData {
68
+ return {
69
+ title: coerceValue(input.title),
70
+ row1Label: coerceValue(input.row1Label),
71
+ row1Value: coerceValue(input.row1Value),
72
+ row2Label: coerceValue(input.row2Label),
73
+ row2Value: coerceValue(input.row2Value),
74
+ row3Label: coerceValue(input.row3Label),
75
+ row3Value: coerceValue(input.row3Value),
76
+ };
77
+ }
@@ -15,7 +15,7 @@
15
15
  * relay never started (2026-06-29).
16
16
  */
17
17
 
18
- import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
18
+ import type { ServiceInstallConfig } from "./load-service-install-row";
19
19
  import { getAppLogger } from "@teamscala/logger/app-loggers";
20
20
  import { BootFatalError } from "@teamscala/os/errors/boot-error";
21
21
  import type { BootHookHandler, MountRegistry } from "@teamscala/os/runtime-contracts/mount-context";
@@ -21,7 +21,7 @@
21
21
  * top-level boot-error handler.
22
22
  */
23
23
 
24
- import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
24
+ import type { ServiceInstallConfig } from "./load-service-install-row";
25
25
  import { BootFatalError } from "@teamscala/os/errors/boot-error";
26
26
 
27
27
  export interface BootHookContext {
package/src/types.ts CHANGED
@@ -49,4 +49,4 @@ export interface PageHandlerConfig {
49
49
  portalHomeRedirect?: boolean;
50
50
  }
51
51
 
52
- export type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
52
+ export type { ServiceInstallConfig } from "./load-service-install-row";
@@ -16,7 +16,7 @@
16
16
  * reports healthy.
17
17
  */
18
18
 
19
- import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
19
+ import type { ServiceInstallConfig } from "./load-service-install-row";
20
20
  import { getAppLogger } from "@teamscala/logger/app-loggers";
21
21
  import { BootFatalError } from "@teamscala/os/errors/boot-error";
22
22