@teamscala/service-boot 0.23.413 → 0.23.414

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.413",
9
+ "version": "0.23.414",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -886,6 +886,62 @@
886
886
  "types": "./src/register-registry-page-route/resolve-page-data.d.ts",
887
887
  "default": "./src/register-registry-page-route/resolve-page-data.ts"
888
888
  },
889
+ "./register-registry-page-route/resolve-request-graph": {
890
+ "types": "./src/register-registry-page-route/resolve-request-graph.d.ts",
891
+ "default": "./src/register-registry-page-route/resolve-request-graph.ts"
892
+ },
893
+ "./register-registry-page-route/resolve-request-graph.ts": {
894
+ "types": "./src/register-registry-page-route/resolve-request-graph.d.ts",
895
+ "default": "./src/register-registry-page-route/resolve-request-graph.ts"
896
+ },
897
+ "./register-registry-page-route/resolve-panel-page": {
898
+ "types": "./src/register-registry-page-route/resolve-panel-page.d.ts",
899
+ "default": "./src/register-registry-page-route/resolve-panel-page.ts"
900
+ },
901
+ "./register-registry-page-route/resolve-panel-page.ts": {
902
+ "types": "./src/register-registry-page-route/resolve-panel-page.d.ts",
903
+ "default": "./src/register-registry-page-route/resolve-panel-page.ts"
904
+ },
905
+ "./register-registry-page-route/stamp-session-context": {
906
+ "types": "./src/register-registry-page-route/stamp-session-context.d.ts",
907
+ "default": "./src/register-registry-page-route/stamp-session-context.ts"
908
+ },
909
+ "./register-registry-page-route/stamp-session-context.ts": {
910
+ "types": "./src/register-registry-page-route/stamp-session-context.d.ts",
911
+ "default": "./src/register-registry-page-route/stamp-session-context.ts"
912
+ },
913
+ "./register-registry-page-route/run-content-gates": {
914
+ "types": "./src/register-registry-page-route/run-content-gates.d.ts",
915
+ "default": "./src/register-registry-page-route/run-content-gates.ts"
916
+ },
917
+ "./register-registry-page-route/run-content-gates.ts": {
918
+ "types": "./src/register-registry-page-route/run-content-gates.d.ts",
919
+ "default": "./src/register-registry-page-route/run-content-gates.ts"
920
+ },
921
+ "./register-registry-page-route/apply-page-branding": {
922
+ "types": "./src/register-registry-page-route/apply-page-branding.d.ts",
923
+ "default": "./src/register-registry-page-route/apply-page-branding.ts"
924
+ },
925
+ "./register-registry-page-route/apply-page-branding.ts": {
926
+ "types": "./src/register-registry-page-route/apply-page-branding.d.ts",
927
+ "default": "./src/register-registry-page-route/apply-page-branding.ts"
928
+ },
929
+ "./register-registry-page-route/warn-uncovered-blocks": {
930
+ "types": "./src/register-registry-page-route/warn-uncovered-blocks.d.ts",
931
+ "default": "./src/register-registry-page-route/warn-uncovered-blocks.ts"
932
+ },
933
+ "./register-registry-page-route/warn-uncovered-blocks.ts": {
934
+ "types": "./src/register-registry-page-route/warn-uncovered-blocks.d.ts",
935
+ "default": "./src/register-registry-page-route/warn-uncovered-blocks.ts"
936
+ },
937
+ "./register-registry-page-route/prefetch-org-id": {
938
+ "types": "./src/register-registry-page-route/prefetch-org-id.d.ts",
939
+ "default": "./src/register-registry-page-route/prefetch-org-id.ts"
940
+ },
941
+ "./register-registry-page-route/prefetch-org-id.ts": {
942
+ "types": "./src/register-registry-page-route/prefetch-org-id.d.ts",
943
+ "default": "./src/register-registry-page-route/prefetch-org-id.ts"
944
+ },
889
945
  "./register-static-assets": {
890
946
  "types": "./src/register-static-assets.d.ts",
891
947
  "default": "./src/register-static-assets.ts"
@@ -0,0 +1,207 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The three brand-override paths of a registered page route: the /sign-in
7
+ * callback-org override, the signed-in session-org personalisation
8
+ * (platform pages only), and the brand_org_path resource-owner override.
9
+ *
10
+ * Split out of register-registry-page-route.ts (924 lines) — the loadData
11
+ * closure segments moved byte-identical; only the closure variables became
12
+ * explicit parameters.
13
+ */
14
+
15
+ import { fetchOrganisationPageBranding } from "@teamscala/db/fetch-organisation-page-branding";
16
+ import { getPlatformOrgId } from "@teamscala/db/registry/platform";
17
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
18
+ import { getServerClient } from "../page-server-orpc/server-client.ts";
19
+ import { extractOrgIdFromCallbackPath } from "../resolvers/portal-route-paths.ts";
20
+
21
+ function spreadBranding(
22
+ existing: Record<string, unknown> | undefined,
23
+ branding: NonNullable<Awaited<ReturnType<typeof fetchOrganisationPageBranding>>>,
24
+ ): Record<string, unknown> {
25
+ const existingLogo =
26
+ ((existing as Record<string, unknown> | undefined)?.logo as Record<string, unknown> | undefined) ?? {};
27
+ return {
28
+ ...existing,
29
+ companyName: branding.company_name,
30
+ // Friendly name is the customer-facing one; falling back to the
31
+ // legal entity fed "GMG APAC" to every consumer that correctly
32
+ // read companyFriendlyName (e.g. logoAlt in the Puck host codegen).
33
+ companyFriendlyName:
34
+ branding.company_friendly_name ?? branding.company_name,
35
+ primaryColor: branding.brand_primary_color ?? existing?.primaryColor,
36
+ headingFont: branding.brand_heading_font ?? existing?.headingFont,
37
+ bodyFont: branding.brand_body_font ?? existing?.bodyFont,
38
+ logo: {
39
+ light: branding.company_logo_for_light_theme,
40
+ dark: branding.company_logo_for_dark_theme,
41
+ brand: existingLogo.brand ?? null,
42
+ },
43
+ };
44
+ }
45
+
46
+ /**
47
+ * /sign-in brand override: a shared org-scoped link 302→/sign-in for the
48
+ * unauthed crawler (WhatsApp), which saw the service's default website
49
+ * brand (the platform owner). The callbackURL carries the destination org
50
+ * — override resolved.brand (before the title/meta/og are built from it)
51
+ * so the page reflects the org the visitor is signing in to. Scoped to
52
+ * /sign-in; resource pages keep their own brand.
53
+ *
54
+ * The org is segment 1 of the canonical portal shape /<websiteId>/<orgId>/
55
+ * <path>, NOT segment 0 (the website id): reading segment 0 fed a website id
56
+ * into fetchOrganisationPageBranding, which resolved no org and left the
57
+ * default (super-admin) brand — the cross-tenant mis-branding this override
58
+ * exists to prevent (portal-branding-resolves-page-org-first, 2026-07-21).
59
+ * Shared helper so this and bind-page-handler cannot drift on the answer.
60
+ */
61
+ export async function applySignInBrandOverride(
62
+ resolved: Record<string, unknown>,
63
+ request: Request,
64
+ ): Promise<void> {
65
+ try {
66
+ const reqUrl = new URL(request.url);
67
+ if (reqUrl.pathname === "/sign-in") {
68
+ const cb = reqUrl.searchParams.get("callbackURL") ?? "";
69
+ const cbOrgId = extractOrgIdFromCallbackPath(cb);
70
+ const currentBrandOrgId =
71
+ (resolved.brand as { orgId?: string } | undefined)?.orgId ??
72
+ (resolved as { orgId?: string }).orgId;
73
+ if (cbOrgId && cbOrgId !== currentBrandOrgId) {
74
+ const branding = await fetchOrganisationPageBranding(
75
+ getServerClient() as never,
76
+ cbOrgId,
77
+ );
78
+ if (branding) {
79
+ const existing = (resolved.brand as Record<string, unknown>) ?? {};
80
+ resolved.brand = spreadBranding(existing, branding);
81
+ }
82
+ }
83
+ }
84
+ } catch {
85
+ // Branding fetch failed — keep the default brand (no regression).
86
+ }
87
+ }
88
+
89
+ /**
90
+ * brand-is-org-owned-full-scheme, reconciled with page-org-first
91
+ * (portal-branding-resolves-page-org-first): the session override now
92
+ * personalizes ONLY platform pages (page org === website owning-org).
93
+ * Tenant pages keep their resolved page-org brand — the page is
94
+ * authoritative, the menu switcher changes which tenant's pages you
95
+ * view. Anonymous/public (no session) keeps the resolved brand. Same
96
+ * fetchOrganisationPageBranding pattern as the /sign-in brand override.
97
+ *
98
+ * A PUBLIC PAGE IS NEVER PERSONALISED TO THE READER'S ORG. Personalising
99
+ * the brand is right for a portal — you are looking at your own
100
+ * workspace. It is wrong for a public document, which belongs to the org
101
+ * that PUBLISHED it and must look identical to every reader.
102
+ *
103
+ * Measured 2026-08-17: the Launcha Authority Blueprint, owned by GMG
104
+ * APAC, rendered correctly for an anonymous customer and in GBG Indo's
105
+ * navy with the scala logo for any signed-in staff member — so the two
106
+ * people checking it before it went to clients were the only two who
107
+ * could not see what a client sees. Silent, and inverted: the reviewers
108
+ * got the wrong page.
109
+ */
110
+ export async function applySessionOrgBranding(
111
+ pageData: Record<string, unknown>,
112
+ renderMode: string | undefined,
113
+ sessionOrgId: string | undefined,
114
+ ): Promise<void> {
115
+ if (
116
+ renderMode !== "public" &&
117
+ sessionOrgId &&
118
+ sessionOrgId !== (pageData.orgId as string | undefined) &&
119
+ // Page-org-first (portal-branding-resolves-page-org-first): the
120
+ // session override personalizes ONLY the shared PLATFORM website
121
+ // (super-admin org). A TENANT-owned site (a customer GPT/quiz/portal)
122
+ // keeps its page-org brand regardless of the viewer session org. The
123
+ // old pageData.orgId===website.org mis-fired for a tenant whose page org equals their own website org.
124
+ getPlatformOrgId() ===
125
+ ((pageData.website as { organisation_id?: string | null } | undefined)
126
+ ?.organisation_id ?? undefined)
127
+ ) {
128
+ try {
129
+ const branding = await fetchOrganisationPageBranding(
130
+ getServerClient() as never,
131
+ sessionOrgId,
132
+ );
133
+ if (branding) {
134
+ pageData.orgId = sessionOrgId;
135
+ const existing = (pageData.brand as Record<string, unknown>) ?? {};
136
+ pageData.brand = spreadBranding(existing, branding);
137
+ }
138
+ } catch {
139
+ // Branding fetch failed — keep the default brand (no regression).
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * BRAND FROM THE ORG THAT OWNS THE RESOURCE, not the website's owner.
146
+ * A public resource page (an assessment report, a shared document) is
147
+ * hosted on a shared website but BELONGS to a tenant. resolvePageBrand
148
+ * reads pageData.orgId, which is the WEBSITE's org, so every tenant's
149
+ * report rendered the host org's logo and palette.
150
+ *
151
+ * Measured 2026-08-17: the Launcha Authority Blueprint is owned by GMG
152
+ * APAC (#FF4500, launcha logo) and rendered with GBG Indo's navy and
153
+ * scala mark, because both pages live on the shared "Public Pages"
154
+ * site. The loader had already resolved the right org — nothing
155
+ * consumed it.
156
+ *
157
+ * `brand_org_path` names where the owning org id lives in the resolved
158
+ * loader data (e.g. "quiz.organisation_id"), so which resource owns the
159
+ * brand is a ROW, never a per-service branch
160
+ * (`business-logic-is-rows-plus-executors`). Absent or unresolvable,
161
+ * the website's org stands — this can only ever narrow the brand to a
162
+ * more specific owner, never blank it.
163
+ */
164
+ export async function applyBrandOrgPath(
165
+ pageData: Record<string, unknown>,
166
+ resolved: Record<string, unknown>,
167
+ brandOrgPath: string | undefined,
168
+ params: { path?: string },
169
+ serviceSlug: string,
170
+ ): Promise<void> {
171
+ const log = getAppLogger();
172
+ if (brandOrgPath) {
173
+ let cursor: unknown = resolved;
174
+ for (const key of brandOrgPath.split(".")) {
175
+ if (cursor == null || typeof cursor !== "object") {
176
+ cursor = undefined;
177
+ break;
178
+ }
179
+ cursor = (cursor as Record<string, unknown>)[key];
180
+ }
181
+ if (typeof cursor === "string" && cursor.length > 0) {
182
+ pageData.orgId = cursor;
183
+ // orgId alone is not enough. resolvePageBrand fills only the
184
+ // accent/secondary/muted gaps from the org; primaryColor, logo and
185
+ // companyName come from pageData.brand, which was built from the
186
+ // website's org. Replace those too, the same way the session
187
+ // override above does, or the page keeps the host's logo while
188
+ // claiming the tenant's org.
189
+ try {
190
+ const owner = await fetchOrganisationPageBranding(
191
+ getServerClient() as never,
192
+ cursor,
193
+ );
194
+ if (owner) {
195
+ const existing = (pageData.brand as Record<string, unknown>) ?? {};
196
+ pageData.brand = spreadBranding(existing, owner);
197
+ }
198
+ } catch {
199
+ // Branding fetch failed — keep the website brand (no regression).
200
+ }
201
+ } else {
202
+ log.warn(
203
+ `[scala-install:${serviceSlug}] brand_org_path "${brandOrgPath}" resolved no org id for ${params.path}; keeping the website org brand`,
204
+ );
205
+ }
206
+ }
207
+ }
@@ -23,6 +23,21 @@ export function buildMetaFromContext(
23
23
  return interpolateMeta(metaContext as Record<string, unknown>, resolved);
24
24
  }
25
25
 
26
+ /**
27
+ * The page's own meta_title row (loaderData.page first, resolved.page second)
28
+ * — used only on /sign-in, where the org's sign-in page title overrides the
29
+ * derived one. Moved from register-registry-page-route.ts.
30
+ */
31
+ export function resolvePageMetaTitle(
32
+ resolved: Record<string, unknown>,
33
+ ): unknown {
34
+ return (
35
+ (resolved.loaderData as { page?: { meta_title?: unknown } } | undefined)
36
+ ?.page?.meta_title ??
37
+ (resolved.page as { meta_title?: unknown } | undefined)?.meta_title
38
+ );
39
+ }
40
+
26
41
  /**
27
42
  * THE ONE SUBJECT DERIVATION. What a page is ABOUT — the entity whose name the
28
43
  * browser tab should carry.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Resolves the organisation id for the background AI-chat session prefetch
7
+ * from the request path. A canonical portal URL is /<websiteId>/<orgId>/
8
+ * <path> — the org is segment 1, NOT segment 0. Moved from
9
+ * register-registry-page-route.ts.
10
+ */
11
+
12
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
+
14
+ /**
15
+ * Prefer segment 1; fall back to segment 0 only when segment 1 is not a
16
+ * UUID (the single-UUID shape, which is live and supported — NOT
17
+ * transitional). The fallback is deliberately kept
18
+ * UNCHANGED-BEHAVIOUR rather than "fixed": a UUID in segment 0 is
19
+ * genuinely ambiguous — of the 40 UUID-first page paths in builda_pages,
20
+ * ZERO are websites and ZERO are organisations, so segment 0 can also be
21
+ * an unrelated record id. Position alone cannot resolve that; only
22
+ * validating the id against organisation_profile can, and that is a DB
23
+ * read this hot prefetch path should not take without caching
24
+ * (`hot-path-resolutions-are-cached`). Preferring segment 1 is strictly
25
+ * better for the canonical shape and no worse for any other. Reading
26
+ * segment 0 first captured the WEBSITE id and passed it as an
27
+ * organisation id, which failed silently because both are well-formed
28
+ * UUIDs: the prefetch simply warmed a session against the wrong tenant
29
+ * (found 2026-07-21).
30
+ */
31
+ export function resolveAiOrgId(pathname: string): string | undefined {
32
+ const pathSegments = pathname.split("/").filter(Boolean);
33
+ return UUID_RE.test(pathSegments[1] ?? "")
34
+ ? pathSegments[1]
35
+ : UUID_RE.test(pathSegments[0] ?? "")
36
+ ? pathSegments[0]
37
+ : undefined;
38
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * ?panel=<slug> resolution: reads the panel_view row, follows its
7
+ * content.page_slug to the builda_pages row, and stashes the panel page
8
+ * data + active panel slug on pageData.
9
+ *
10
+ * Split out of register-registry-page-route.ts (924 lines) — the loadData
11
+ * closure segment moved byte-identical; only the closure variables became
12
+ * explicit parameters.
13
+ */
14
+
15
+ import { getServerClient } from "../page-server-orpc/server-client.ts";
16
+
17
+ export async function attachPanelPage(
18
+ pageData: Record<string, unknown>,
19
+ panelSlug: string | null,
20
+ ): Promise<void> {
21
+ if (!panelSlug) return;
22
+ try {
23
+ const dbReg = getServerClient() as unknown as {
24
+ registry_entries: { findFirst: (a: { where: unknown; select: unknown }) => Promise<{ config: Record<string, unknown> } | null> };
25
+ };
26
+ const pv = await dbReg.registry_entries.findFirst({
27
+ where: { type: "panel_view", slug: panelSlug, is_active: true },
28
+ select: { config: true },
29
+ });
30
+ const pageSlug = (pv?.config?.content as { page_slug?: string })?.page_slug;
31
+ if (pageSlug) {
32
+ const dbPages = getServerClient() as unknown as {
33
+ builda_pages: { findFirst: (a: { where: unknown; select: unknown }) => Promise<{ puck_data: unknown } | null> };
34
+ };
35
+ const panelPage = await dbPages.builda_pages.findFirst({
36
+ where: { path: `/${pageSlug}`, is_active: true },
37
+ select: { puck_data: true },
38
+ });
39
+ if (panelPage?.puck_data) {
40
+ pageData.panelPageData = panelPage.puck_data;
41
+ pageData.activePanel = panelSlug;
42
+ }
43
+ }
44
+ } catch { /* non-critical */ }
45
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Per-request loader-graph resolution for a registered page route: SWR cache
7
+ * read (static-shell vs param-route cache), clone-on-get, defensive clone
8
+ * with logged fallbacks, primary-resource-missing dead-link 404s, and the
9
+ * content-page floor.
10
+ *
11
+ * Split out of register-registry-page-route.ts (924 lines) — the loadData
12
+ * closure segments moved byte-identical; only the closure variables became
13
+ * explicit parameters.
14
+ */
15
+
16
+ import type { PageRouteConfig } from "../register-registry-page-route.ts";
17
+ import * as v from "valibot";
18
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
19
+ import type { ResourceLoaderConfigSchema } from "@teamscala/db-validation/registry-schemas/resource-loader";
20
+ import { resolveFromLoaderConfig } from "../resolve-from-loader-config.ts";
21
+ import { decideContentPageFloor } from "../content-page-floor.ts";
22
+ import { PrimaryResourceMissingError } from "../resource-not-found.ts";
23
+ import {
24
+ staticShellGraphCache,
25
+ paramRouteGraphCache,
26
+ readCachedGraph,
27
+ } from "./graph-cache.ts";
28
+
29
+ export interface ResolveRequestGraphArgs {
30
+ pattern: string;
31
+ routeSlug: string;
32
+ routeConfig: PageRouteConfig;
33
+ loaderConfig: v.InferOutput<typeof ResourceLoaderConfigSchema>;
34
+ params: Record<string, string>;
35
+ isStaticShell: boolean;
36
+ serviceSlug: string;
37
+ }
38
+
39
+ export async function resolveRequestGraph(
40
+ args: ResolveRequestGraphArgs,
41
+ ): Promise<Record<string, unknown>> {
42
+ const { pattern, routeSlug, routeConfig, loaderConfig, params, isStaticShell, serviceSlug } = args;
43
+ const log = getAppLogger();
44
+
45
+ // A bare root ("/") that declares a resource_type is a UUID-driven
46
+ // public site (proposals/forms/quiz) with no resource selected — it
47
+ // must render the branded holding/not-found page ("no <resource> at
48
+ // this link"), never fall through the shared public loader to the
49
+ // marketing site. PrimaryResourceMissingError is the existing signal
50
+ // the page binder maps to buildResourceNotFoundResponse. A "/" route
51
+ // WITHOUT a resource_type (e.g. scala-public's marketing root) is
52
+ // unaffected and still renders its content.
53
+ if (pattern === "/" && routeConfig.resource_type) {
54
+ // Status 200: the bare root is a valid "no <resource> selected"
55
+ // landing, not an error — and kind:ui services must answer GET /
56
+ // with 200 (verify-prod-persistent-slot). An invalid /:id keeps
57
+ // the default 404 (genuinely not found).
58
+ throw new PrimaryResourceMissingError(routeConfig.resource_type, 200);
59
+ }
60
+ const graphKey = `graph:${routeSlug}:${params.host}:${params.path}`;
61
+ const primaryType = routeConfig.resource_type;
62
+ const hasPrimarySource =
63
+ !!primaryType &&
64
+ !!(loaderConfig as { sources?: { key: string }[] }).sources?.some(
65
+ (s) => s.key === primaryType,
66
+ );
67
+ const graphCache = isStaticShell
68
+ ? staticShellGraphCache
69
+ : paramRouteGraphCache;
70
+ const typedLoaderConfig = loaderConfig as v.InferOutput<
71
+ typeof ResourceLoaderConfigSchema
72
+ > & {
73
+ sources: NonNullable<
74
+ v.InferOutput<typeof ResourceLoaderConfigSchema>["sources"]
75
+ >;
76
+ };
77
+ let resolved: Record<string, unknown>;
78
+ // SWR read over @teamscala/cache: fresh hit serves the cached graph, stale
79
+ // hit serves it AND fires ONE background refresh (refreshInBackground,
80
+ // single-flight via the cache's in-flight map), miss returns undefined.
81
+ // The refresh callback re-resolves + stores a clone; a primary-missing
82
+ // (dead-link) graph THROWS so the cache is not updated — a dead link must
83
+ // stay per-request so a just-created record appears immediately
84
+ // (refreshInBackground catches, leaves the stale entry, logs one line).
85
+ const cachedGraph = readCachedGraph(
86
+ graphCache,
87
+ graphKey,
88
+ () =>
89
+ resolveFromLoaderConfig(typedLoaderConfig, params).then(
90
+ (freshGraph) => {
91
+ const freshPrimaryMissing =
92
+ hasPrimarySource &&
93
+ primaryType &&
94
+ freshGraph[primaryType] == null;
95
+ if (freshPrimaryMissing) {
96
+ throw new Error(
97
+ `primary resource "${primaryType}" missing — skip cache update for ${graphKey}`,
98
+ );
99
+ }
100
+ return structuredClone(freshGraph);
101
+ },
102
+ ),
103
+ );
104
+ if (cachedGraph !== undefined) {
105
+ // Clone-on-get: gateAgreementContent + block resolution MUTATE the
106
+ // graph in place, so each request gets its own copy.
107
+ resolved = structuredClone(cachedGraph) as Record<string, unknown>;
108
+ } else {
109
+ resolved = await resolveFromLoaderConfig(typedLoaderConfig, params);
110
+ const primaryMissing =
111
+ hasPrimarySource && primaryType && resolved[primaryType] == null;
112
+ if (!primaryMissing) {
113
+ // Defensive clone: structuredClone throws on non-cloneable loader
114
+ // graphs (functions/Proxies/Prisma instances). A swallowed throw
115
+ // here used to SILENTLY disable the graph cache for this key
116
+ // (fail-open-silent — forbidden by protective-layers-fail-explicitly),
117
+ // which left affected routes re-resolving the loader graph every
118
+ // request (measured ~1.2s on the projects utility shell). JSON-clone
119
+ // preserves the data graph (dropping functions/undefined a loader
120
+ // graph shouldn't carry) so the cache stays effective; both fallbacks
121
+ // are LOGGED so the offending non-cloneable source is observable +
122
+ // fixable, never silently disabling the cache again.
123
+ let cloned: unknown;
124
+ try {
125
+ cloned = structuredClone(resolved);
126
+ } catch (err) {
127
+ log.warn(
128
+ `[scala-install:${serviceSlug}] graph-cache structuredClone failed for ${graphKey} — falling back to JSON-clone (non-cloneable value in loader graph): ${err instanceof Error ? err.message : err}`,
129
+ );
130
+ try {
131
+ cloned = JSON.parse(JSON.stringify(resolved));
132
+ } catch (err2) {
133
+ log.warn(
134
+ `[scala-install:${serviceSlug}] graph-cache JSON-clone also failed for ${graphKey} — cache disabled for this key (observable): ${err2 instanceof Error ? err2.message : err2}`,
135
+ );
136
+ cloned = undefined;
137
+ }
138
+ }
139
+ if (cloned !== undefined) {
140
+ graphCache.set(graphKey, cloned);
141
+ }
142
+ }
143
+ }
144
+ // A route that declares resource_type is meaningless without that
145
+ // entity: a dead link (wrong id, deleted row) must 404 with the
146
+ // branded not-found page — never fall through to default content
147
+ // (the "Vision Toolbar" leak, operator-observed 2026-06-05).
148
+ if (hasPrimarySource && primaryType && resolved[primaryType] == null) {
149
+ throw new PrimaryResourceMissingError(primaryType);
150
+ }
151
+ // A CONTENT route (no resource_type) whose loader resolves a `page`
152
+ // body source must not synthesize an empty page for an unmatched path
153
+ // and serve a cacheable 200 — the generalized "Vision Toolbar" leak
154
+ // (operator-reported 5×: a deleted page, e.g. /roadmap, returned 200
155
+ // with an empty body instead of not-found). No page row → dead link →
156
+ // branded 404 floor. A page row that exists but has zero blocks → a
157
+ // content gap → the floor's empty-page surface (200). Both carry the
158
+ // "Take me home" action. The pure decision lives in content-page-floor.ts
159
+ // (unit-tested without the Hono/DB/graph machinery). Region-slot
160
+ // (utility/portal) shells without a `page` source are untouched.
161
+ const contentFloorError = decideContentPageFloor({
162
+ hasResourceType: !!primaryType,
163
+ hasPageContentSource: !!(loaderConfig as { sources?: { key: string }[] }).sources?.some(
164
+ (s) => s.key === "page",
165
+ ),
166
+ page: resolved.page,
167
+ });
168
+ if (contentFloorError) throw contentFloorError;
169
+ return resolved;
170
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Runs every registered content gate in catalog order (sequential, never
7
+ * parallel — gates depend on prior gates' graph mutations) and applies the
8
+ * page's declared assistant where authorized.
9
+ *
10
+ * Split out of register-registry-page-route.ts (924 lines) — the loadData
11
+ * closure segment moved byte-identical; only the closure variables became
12
+ * explicit parameters.
13
+ */
14
+
15
+ import type { PageRouteConfig } from "../register-registry-page-route.ts";
16
+ import { runContentGate } from "../content-gate/gate.ts";
17
+ import type { ContentGateOutcome } from "../content-gate/gate.ts";
18
+ import { CONTENT_GATES, withholdsContent } from "../content-gate/catalog.ts";
19
+ import { injectResourceChatLauncher } from "../resource-chat-inject.ts";
20
+
21
+ export async function runContentGatesForPage(
22
+ resolved: Record<string, unknown>,
23
+ gateCookie: string | null,
24
+ gateNow: number,
25
+ assistant: PageRouteConfig["assistant"],
26
+ regionSlots: string[] | undefined,
27
+ ): Promise<void> {
28
+ // EVERY REGISTERED GATE, IN CATALOG ORDER (content-gate/catalog.ts).
29
+ // This used to name proposalGateConfig, agreementGateConfig and
30
+ // gptGateConfig by hand, so a fourth gated resource type meant editing
31
+ // this file AND the second hand-written list in two-phase-boot.ts.
32
+ //
33
+ // SEQUENTIAL, NEVER PARALLEL. An authorized proposal viewer has their
34
+ // gate block removed from the resolved graph, and that removal is what
35
+ // makes the agreement gate find no resource and no-op on a page that
36
+ // carries both. Awaiting these together would race that dependency.
37
+ const gateOutcomes: ContentGateOutcome[] = [];
38
+ for (const entry of CONTENT_GATES) {
39
+ const outcome = await runContentGate(
40
+ resolved,
41
+ gateCookie,
42
+ gateNow,
43
+ entry.gate,
44
+ );
45
+ // A seed-only gate (open capture — the public GPT identity gate)
46
+ // withholds nothing, so its verdict is not evidence about whether
47
+ // this viewer may see the page and must not reach the assistant
48
+ // decision below. See withholdsContent, which derives that from the
49
+ // access config rather than restating it here.
50
+ if (withholdsContent(entry)) gateOutcomes.push(outcome);
51
+ }
52
+
53
+ // THE ASSISTANT IS A ROW, AND THIS IS THE ONE PLACE IT IS APPLIED.
54
+ // It used to be a call inside each gate's onAuthorized plus an entry
55
+ // in the hardcoded gate sequence above, so a third resource type
56
+ // meant editing this file. Now any page declaring `assistant` gets
57
+ // one and no page without the key does.
58
+ //
59
+ // GATED PAGES ONLY WHEN AUTHORIZED. The launcher's runtime context is
60
+ // the whole document serialised, so injecting past a closed gate
61
+ // would hand withheld bytes to the chat. A page no gate governs is
62
+ // public and gets the assistant unconditionally -- that is a
63
+ // different state from an unauthorized viewer, which is why the gate
64
+ // reports `applies` separately from `authorized`.
65
+ if (assistant) {
66
+ const governing = gateOutcomes.filter((outcome) => outcome.applies);
67
+ const authorized = governing.find((outcome) => outcome.authorized);
68
+ if (governing.length === 0 || authorized) {
69
+ await injectResourceChatLauncher(resolved, authorized?.email ?? "", {
70
+ resourceKey: assistant.resource_key,
71
+ agentConfigSlug: assistant.agent_config_slug as never,
72
+ launchLabel: assistant.launch_label,
73
+ placeholder: assistant.placeholder,
74
+ // From the loader row, so the injector never has to guess
75
+ // which resolved keys hold rendered content.
76
+ regionSlots,
77
+ });
78
+ }
79
+ }
80
+ }