@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 +57 -1
- package/src/register-registry-page-route/apply-page-branding.ts +207 -0
- package/src/register-registry-page-route/meta-title.ts +15 -0
- package/src/register-registry-page-route/prefetch-org-id.ts +38 -0
- package/src/register-registry-page-route/resolve-panel-page.ts +45 -0
- package/src/register-registry-page-route/resolve-request-graph.ts +170 -0
- package/src/register-registry-page-route/run-content-gates.ts +80 -0
- package/src/register-registry-page-route/stamp-session-context.ts +121 -0
- package/src/register-registry-page-route/warn-uncovered-blocks.ts +64 -0
- package/src/register-registry-page-route.ts +64 -599
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.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
|
+
}
|