@teamscala/service-boot 0.23.412 → 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 +113 -1
- package/src/build-client-assets/build-lock.ts +55 -0
- package/src/build-client-assets/hashed-output.ts +39 -0
- package/src/build-client-assets/hydrate-bundle.ts +298 -0
- package/src/build-client-assets/input-fingerprint.ts +108 -0
- package/src/build-client-assets/manifest.ts +115 -0
- package/src/build-client-assets/sourcemaps-upload.ts +101 -0
- package/src/build-client-assets/styles-build.ts +179 -0
- package/src/build-client-assets.test.ts +2 -4
- package/src/build-client-assets.ts +20 -777
- package/src/read-html-template.ts +1 -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system ui-service-boot
|
|
3
|
-
* @status handwritten
|
|
3
|
+
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
6
|
* Reads `page_route` registry rows for the current service slug and registers
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
* `sources[]` dependency graph. When header/footer sources are present in
|
|
11
11
|
* the resolved graph, the output includes a `regions` array for shell rendering.
|
|
12
12
|
* Otherwise it produces single-page puckData.
|
|
13
|
+
*
|
|
14
|
+
* This module is the ROUTE-REGISTRATION unit: row loading, specificity
|
|
15
|
+
* ordering, handler binding. The per-request loadData pipeline segments live
|
|
16
|
+
* as siblings under ./register-registry-page-route/ (graph-cache,
|
|
17
|
+
* resolve-request-graph, stamp-session-context, run-content-gates,
|
|
18
|
+
* apply-page-branding, resolve-panel-page, warn-uncovered-blocks,
|
|
19
|
+
* resolve-page-data, meta-title, ai-chat-prefetch, captured-loader-config,
|
|
20
|
+
* proposal-*) — split when the parent held them all at 924 lines.
|
|
13
21
|
*/
|
|
14
22
|
|
|
15
23
|
import type { RenderModeLiteral } from "./page-route-shell-types.ts";
|
|
@@ -20,33 +28,27 @@ import {
|
|
|
20
28
|
loadRegistryConfig,
|
|
21
29
|
loadRegistryConfigsByType,
|
|
22
30
|
} from "@teamscala/db/registry/load-config";
|
|
23
|
-
import * as v from "valibot";
|
|
24
|
-
import { fetchOrganisationPageBranding } from "@teamscala/db/fetch-organisation-page-branding";
|
|
25
|
-
import { getPlatformOrgId } from "@teamscala/db/registry/platform";
|
|
26
31
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
27
32
|
import type { Hono } from "hono";
|
|
28
33
|
import { bindPageHandler } from "./bind-page-handler.ts";
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import type { ContentGateOutcome } from "./content-gate/gate.ts";
|
|
33
|
-
import { CONTENT_GATES, withholdsContent } from "./content-gate/catalog.ts";
|
|
34
|
-
import { injectResourceChatLauncher } from "./resource-chat-inject.ts";
|
|
35
|
-
import { extractOrgIdFromCallbackPath } from "./resolvers/portal-route-paths.ts";
|
|
36
|
-
import { PrimaryResourceMissingError } from "./resource-not-found.ts";
|
|
37
|
-
import { decideContentPageFloor } from "./content-page-floor.ts";
|
|
38
|
-
import type { CreatePageHandler, RendererComponentEntry } from "./types.ts";
|
|
34
|
+
import { resolveRequestGraph } from "./register-registry-page-route/resolve-request-graph.ts";
|
|
35
|
+
import { stampSessionContext } from "./register-registry-page-route/stamp-session-context.ts";
|
|
36
|
+
import { runContentGatesForPage } from "./register-registry-page-route/run-content-gates.ts";
|
|
39
37
|
import {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
} from "./register-registry-page-route/
|
|
38
|
+
applyBrandOrgPath,
|
|
39
|
+
applySessionOrgBranding,
|
|
40
|
+
applySignInBrandOverride,
|
|
41
|
+
} from "./register-registry-page-route/apply-page-branding.ts";
|
|
42
|
+
import { attachPanelPage } from "./register-registry-page-route/resolve-panel-page.ts";
|
|
43
|
+
import { warnUncoveredBlocks } from "./register-registry-page-route/warn-uncovered-blocks.ts";
|
|
44
44
|
import { resolvedToPageData } from "./register-registry-page-route/resolve-page-data.ts";
|
|
45
45
|
import { describeCapturedLoaderConfig } from "./register-registry-page-route/captured-loader-config.ts";
|
|
46
46
|
import {
|
|
47
47
|
buildMetaFromContext,
|
|
48
|
+
resolvePageMetaTitle,
|
|
48
49
|
} from "./register-registry-page-route/meta-title.ts";
|
|
49
50
|
import { prefetchAiChatSession } from "./register-registry-page-route/ai-chat-prefetch.ts";
|
|
51
|
+
import { resolveAiOrgId } from "./register-registry-page-route/prefetch-org-id.ts";
|
|
50
52
|
import {
|
|
51
53
|
applyApprovalEmailToken,
|
|
52
54
|
} from "./register-registry-page-route/proposal-approval-email-token.ts";
|
|
@@ -54,8 +56,9 @@ import {
|
|
|
54
56
|
applyPartnerBrand,
|
|
55
57
|
applyClientLogo,
|
|
56
58
|
} from "./register-registry-page-route/proposal-brand-stamping.ts";
|
|
59
|
+
import type { CreatePageHandler, RendererComponentEntry } from "./types.ts";
|
|
57
60
|
|
|
58
|
-
interface PageRouteConfig {
|
|
61
|
+
export interface PageRouteConfig {
|
|
59
62
|
pattern: string;
|
|
60
63
|
service: string;
|
|
61
64
|
loader_slug: string;
|
|
@@ -185,350 +188,37 @@ export async function registerRegistryPageRoute(
|
|
|
185
188
|
!pattern.includes("*");
|
|
186
189
|
|
|
187
190
|
const loadData = async (request: Request) => {
|
|
188
|
-
// A bare root ("/") that declares a resource_type is a UUID-driven
|
|
189
|
-
// public site (proposals/forms/quiz) with no resource selected — it
|
|
190
|
-
// must render the branded holding/not-found page ("no <resource> at
|
|
191
|
-
// this link"), never fall through the shared public loader to the
|
|
192
|
-
// marketing site. PrimaryResourceMissingError is the existing signal
|
|
193
|
-
// the page binder maps to buildResourceNotFoundResponse. A "/" route
|
|
194
|
-
// WITHOUT a resource_type (e.g. scala-public's marketing root) is
|
|
195
|
-
// unaffected and still renders its content.
|
|
196
|
-
if (pattern === "/" && routeConfig.resource_type) {
|
|
197
|
-
// Status 200: the bare root is a valid "no <resource> selected"
|
|
198
|
-
// landing, not an error — and kind:ui services must answer GET /
|
|
199
|
-
// with 200 (verify-prod-persistent-slot). An invalid /:id keeps
|
|
200
|
-
// the default 404 (genuinely not found).
|
|
201
|
-
throw new PrimaryResourceMissingError(routeConfig.resource_type, 200);
|
|
202
|
-
}
|
|
203
191
|
const url = new URL(request.url);
|
|
204
192
|
const params = extractUrlParams(pattern, url.pathname);
|
|
205
193
|
params.host = request.headers.get("host")?.split(":")[0] || url.hostname;
|
|
206
194
|
params.path = url.pathname || "/";
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
? staticShellGraphCache
|
|
216
|
-
: paramRouteGraphCache;
|
|
217
|
-
const typedLoaderConfig = loaderConfig as v.InferOutput<
|
|
218
|
-
typeof ResourceLoaderConfigSchema
|
|
219
|
-
> & {
|
|
220
|
-
sources: NonNullable<
|
|
221
|
-
v.InferOutput<typeof ResourceLoaderConfigSchema>["sources"]
|
|
222
|
-
>;
|
|
223
|
-
};
|
|
224
|
-
let resolved: Record<string, unknown>;
|
|
225
|
-
// SWR read over @teamscala/cache: fresh hit serves the cached graph, stale
|
|
226
|
-
// hit serves it AND fires ONE background refresh (refreshInBackground,
|
|
227
|
-
// single-flight via the cache's in-flight map), miss returns undefined.
|
|
228
|
-
// The refresh callback re-resolves + stores a clone; a primary-missing
|
|
229
|
-
// (dead-link) graph THROWS so the cache is not updated — a dead link must
|
|
230
|
-
// stay per-request so a just-created record appears immediately
|
|
231
|
-
// (refreshInBackground catches, leaves the stale entry, logs one line).
|
|
232
|
-
const cachedGraph = readCachedGraph(
|
|
233
|
-
graphCache,
|
|
234
|
-
graphKey,
|
|
235
|
-
() =>
|
|
236
|
-
resolveFromLoaderConfig(typedLoaderConfig, params).then(
|
|
237
|
-
(freshGraph) => {
|
|
238
|
-
const freshPrimaryMissing =
|
|
239
|
-
hasPrimarySource &&
|
|
240
|
-
primaryType &&
|
|
241
|
-
freshGraph[primaryType] == null;
|
|
242
|
-
if (freshPrimaryMissing) {
|
|
243
|
-
throw new Error(
|
|
244
|
-
`primary resource "${primaryType}" missing — skip cache update for ${graphKey}`,
|
|
245
|
-
);
|
|
246
|
-
}
|
|
247
|
-
return structuredClone(freshGraph);
|
|
248
|
-
},
|
|
249
|
-
),
|
|
250
|
-
);
|
|
251
|
-
if (cachedGraph !== undefined) {
|
|
252
|
-
// Clone-on-get: gateAgreementContent + block resolution MUTATE the
|
|
253
|
-
// graph in place, so each request gets its own copy.
|
|
254
|
-
resolved = structuredClone(cachedGraph) as Record<string, unknown>;
|
|
255
|
-
} else {
|
|
256
|
-
resolved = await resolveFromLoaderConfig(typedLoaderConfig, params);
|
|
257
|
-
const primaryMissing =
|
|
258
|
-
hasPrimarySource && primaryType && resolved[primaryType] == null;
|
|
259
|
-
if (!primaryMissing) {
|
|
260
|
-
// Defensive clone: structuredClone throws on non-cloneable loader
|
|
261
|
-
// graphs (functions/Proxies/Prisma instances). A swallowed throw
|
|
262
|
-
// here used to SILENTLY disable the graph cache for this key
|
|
263
|
-
// (fail-open-silent — forbidden by protective-layers-fail-explicitly),
|
|
264
|
-
// which left affected routes re-resolving the loader graph every
|
|
265
|
-
// request (measured ~1.2s on the projects utility shell). JSON-clone
|
|
266
|
-
// preserves the data graph (dropping functions/undefined a loader
|
|
267
|
-
// graph shouldn't carry) so the cache stays effective; both fallbacks
|
|
268
|
-
// are LOGGED so the offending non-cloneable source is observable +
|
|
269
|
-
// fixable, never silently disabling the cache again.
|
|
270
|
-
let cloned: unknown;
|
|
271
|
-
try {
|
|
272
|
-
cloned = structuredClone(resolved);
|
|
273
|
-
} catch (err) {
|
|
274
|
-
log.warn(
|
|
275
|
-
`[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}`,
|
|
276
|
-
);
|
|
277
|
-
try {
|
|
278
|
-
cloned = JSON.parse(JSON.stringify(resolved));
|
|
279
|
-
} catch (err2) {
|
|
280
|
-
log.warn(
|
|
281
|
-
`[scala-install:${serviceSlug}] graph-cache JSON-clone also failed for ${graphKey} — cache disabled for this key (observable): ${err2 instanceof Error ? err2.message : err2}`,
|
|
282
|
-
);
|
|
283
|
-
cloned = undefined;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
if (cloned !== undefined) {
|
|
287
|
-
graphCache.set(graphKey, cloned);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
// A route that declares resource_type is meaningless without that
|
|
292
|
-
// entity: a dead link (wrong id, deleted row) must 404 with the
|
|
293
|
-
// branded not-found page — never fall through to default content
|
|
294
|
-
// (the "Vision Toolbar" leak, operator-observed 2026-06-05).
|
|
295
|
-
if (hasPrimarySource && primaryType && resolved[primaryType] == null) {
|
|
296
|
-
throw new PrimaryResourceMissingError(primaryType);
|
|
297
|
-
}
|
|
298
|
-
// A CONTENT route (no resource_type) whose loader resolves a `page`
|
|
299
|
-
// body source must not synthesize an empty page for an unmatched path
|
|
300
|
-
// and serve a cacheable 200 — the generalized "Vision Toolbar" leak
|
|
301
|
-
// (operator-reported 5×: a deleted page, e.g. /roadmap, returned 200
|
|
302
|
-
// with an empty body instead of not-found). No page row → dead link →
|
|
303
|
-
// branded 404 floor. A page row that exists but has zero blocks → a
|
|
304
|
-
// content gap → the floor's empty-page surface (200). Both carry the
|
|
305
|
-
// "Take me home" action. The pure decision lives in content-page-floor.ts
|
|
306
|
-
// (unit-tested without the Hono/DB/graph machinery). Region-slot
|
|
307
|
-
// (utility/portal) shells without a `page` source are untouched.
|
|
308
|
-
const contentFloorError = decideContentPageFloor({
|
|
309
|
-
hasResourceType: !!primaryType,
|
|
310
|
-
hasPageContentSource: !!(loaderConfig as { sources?: { key: string }[] }).sources?.some(
|
|
311
|
-
(s) => s.key === "page",
|
|
312
|
-
),
|
|
313
|
-
page: resolved.page,
|
|
195
|
+
const resolved = await resolveRequestGraph({
|
|
196
|
+
pattern,
|
|
197
|
+
routeSlug: routeRow.slug,
|
|
198
|
+
routeConfig,
|
|
199
|
+
loaderConfig: loaderConfig as never,
|
|
200
|
+
params,
|
|
201
|
+
isStaticShell,
|
|
202
|
+
serviceSlug,
|
|
314
203
|
});
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
// (an authorized viewer's gate is removed, so the agreement gate then
|
|
320
|
-
// no-ops); each config scopes to its own resource/field so they never
|
|
321
|
-
// collide.
|
|
204
|
+
const { sessionUserId, sessionOrgId } = await stampSessionContext(
|
|
205
|
+
resolved,
|
|
206
|
+
request,
|
|
207
|
+
);
|
|
322
208
|
const gateCookie = request.headers.get("cookie");
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const sess = await getSession(request.headers);
|
|
333
|
-
if (sess?.user?.email) {
|
|
334
|
-
(resolved as { sessionVisitor?: { email: string; name?: string } }).sessionVisitor =
|
|
335
|
-
{
|
|
336
|
-
email: sess.user.email,
|
|
337
|
-
name: sess.user.name ?? undefined,
|
|
338
|
-
};
|
|
339
|
-
sessionUserId = sess.user.id;
|
|
340
|
-
sessionOrgId = sess.session?.activeOrganizationId ?? undefined;
|
|
341
|
-
// Stamp the per-request portal identity so block portal context
|
|
342
|
-
// (useBlockPortal().userId) is populated for authed users on
|
|
343
|
-
// org/hub pages — the SSR-side WRITER the connections-block reader
|
|
344
|
-
// (ClientBlockServicesProvider → __PAGE_DATA__.portalContext.user.id)
|
|
345
|
-
// depends on. Rides the same per-request path as sessionVisitor
|
|
346
|
-
// above (never the cached loader graph); merge preserves any
|
|
347
|
-
// portalContext fields set elsewhere (e.g. viewAsMembers). Without
|
|
348
|
-
// this, connections-block short-circuits at SignInPrompt.
|
|
349
|
-
const rc = resolved as {
|
|
350
|
-
portalContext?: {
|
|
351
|
-
user?: { id?: string } | null;
|
|
352
|
-
orgId?: string;
|
|
353
|
-
};
|
|
354
|
-
};
|
|
355
|
-
rc.portalContext = {
|
|
356
|
-
...(rc.portalContext ?? {}),
|
|
357
|
-
user: sessionUserId ? { id: sessionUserId } : null,
|
|
358
|
-
orgId: sessionOrgId,
|
|
359
|
-
};
|
|
360
|
-
// Read pane widths from the portal cookie (the client writes
|
|
361
|
-
// it immediately on resize via setPortalCookie; the cookie is
|
|
362
|
-
// sent with every HTTP request so the SSR reads it here).
|
|
363
|
-
// This renders the correct pane width from the first byte —
|
|
364
|
-
// no post-hydration shift, no client-side state read.
|
|
365
|
-
const paneWidthsMatch = gateCookie?.match(
|
|
366
|
-
/portal_pane_widths=([^;]+)/,
|
|
367
|
-
);
|
|
368
|
-
if (paneWidthsMatch) {
|
|
369
|
-
try {
|
|
370
|
-
const parsed = JSON.parse(
|
|
371
|
-
decodeURIComponent(paneWidthsMatch[1]),
|
|
372
|
-
);
|
|
373
|
-
if (parsed.left)
|
|
374
|
-
(rc.portalContext as Record<string, unknown>).paneLeftWidth =
|
|
375
|
-
parsed.left;
|
|
376
|
-
if (parsed.right)
|
|
377
|
-
(rc.portalContext as Record<string, unknown>).paneRightWidth =
|
|
378
|
-
parsed.right;
|
|
379
|
-
} catch {
|
|
380
|
-
// Malformed cookie — fall back to defaults.
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
if (sessionUserId) {
|
|
384
|
-
try {
|
|
385
|
-
const dbClient = getServerClient() as unknown as {
|
|
386
|
-
user: {
|
|
387
|
-
findUnique: (args: {
|
|
388
|
-
where: { id: string };
|
|
389
|
-
select: { preferences: boolean };
|
|
390
|
-
}) => Promise<{ preferences: unknown } | null>;
|
|
391
|
-
};
|
|
392
|
-
};
|
|
393
|
-
const userRow = await dbClient.user.findUnique({
|
|
394
|
-
where: { id: sessionUserId },
|
|
395
|
-
select: { preferences: true },
|
|
396
|
-
});
|
|
397
|
-
const portalPrefs = (
|
|
398
|
-
(userRow?.preferences as Record<string, unknown> | null)
|
|
399
|
-
?.portal as Record<string, unknown> | undefined
|
|
400
|
-
);
|
|
401
|
-
if (portalPrefs?.paneLeftWidth) {
|
|
402
|
-
(rc.portalContext as Record<string, unknown>).paneLeftWidth =
|
|
403
|
-
portalPrefs.paneLeftWidth;
|
|
404
|
-
}
|
|
405
|
-
if (portalPrefs?.paneRightWidth) {
|
|
406
|
-
(rc.portalContext as Record<string, unknown>).paneRightWidth =
|
|
407
|
-
portalPrefs.paneRightWidth;
|
|
408
|
-
}
|
|
409
|
-
} catch {
|
|
410
|
-
// Pane widths are non-critical — fall back to defaults.
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
} catch {
|
|
415
|
-
// No auth factory / no session → anonymous; gates fall back to token.
|
|
416
|
-
}
|
|
417
|
-
// EVERY REGISTERED GATE, IN CATALOG ORDER (content-gate/catalog.ts).
|
|
418
|
-
// This used to name proposalGateConfig, agreementGateConfig and
|
|
419
|
-
// gptGateConfig by hand, so a fourth gated resource type meant editing
|
|
420
|
-
// this file AND the second hand-written list in two-phase-boot.ts.
|
|
421
|
-
//
|
|
422
|
-
// SEQUENTIAL, NEVER PARALLEL. An authorized proposal viewer has their
|
|
423
|
-
// gate block removed from the resolved graph, and that removal is what
|
|
424
|
-
// makes the agreement gate find no resource and no-op on a page that
|
|
425
|
-
// carries both. Awaiting these together would race that dependency.
|
|
426
|
-
const gateOutcomes: ContentGateOutcome[] = [];
|
|
427
|
-
for (const entry of CONTENT_GATES) {
|
|
428
|
-
const outcome = await runContentGate(
|
|
429
|
-
resolved,
|
|
430
|
-
gateCookie,
|
|
431
|
-
gateNow,
|
|
432
|
-
entry.gate,
|
|
433
|
-
);
|
|
434
|
-
// A seed-only gate (open capture — the public GPT identity gate)
|
|
435
|
-
// withholds nothing, so its verdict is not evidence about whether
|
|
436
|
-
// this viewer may see the page and must not reach the assistant
|
|
437
|
-
// decision below. See withholdsContent, which derives that from the
|
|
438
|
-
// access config rather than restating it here.
|
|
439
|
-
if (withholdsContent(entry)) gateOutcomes.push(outcome);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// THE ASSISTANT IS A ROW, AND THIS IS THE ONE PLACE IT IS APPLIED.
|
|
443
|
-
// It used to be a call inside each gate's onAuthorized plus an entry
|
|
444
|
-
// in the hardcoded gate sequence above, so a third resource type
|
|
445
|
-
// meant editing this file. Now any page declaring `assistant` gets
|
|
446
|
-
// one and no page without the key does.
|
|
447
|
-
//
|
|
448
|
-
// GATED PAGES ONLY WHEN AUTHORIZED. The launcher's runtime context is
|
|
449
|
-
// the whole document serialised, so injecting past a closed gate
|
|
450
|
-
// would hand withheld bytes to the chat. A page no gate governs is
|
|
451
|
-
// public and gets the assistant unconditionally -- that is a
|
|
452
|
-
// different state from an unauthorized viewer, which is why the gate
|
|
453
|
-
// reports `applies` separately from `authorized`.
|
|
454
|
-
const assistant = routeConfig.assistant;
|
|
455
|
-
if (assistant) {
|
|
456
|
-
const governing = gateOutcomes.filter((outcome) => outcome.applies);
|
|
457
|
-
const authorized = governing.find((outcome) => outcome.authorized);
|
|
458
|
-
if (governing.length === 0 || authorized) {
|
|
459
|
-
await injectResourceChatLauncher(resolved, authorized?.email ?? "", {
|
|
460
|
-
resourceKey: assistant.resource_key,
|
|
461
|
-
agentConfigSlug: assistant.agent_config_slug as never,
|
|
462
|
-
launchLabel: assistant.launch_label,
|
|
463
|
-
placeholder: assistant.placeholder,
|
|
464
|
-
// From the loader row, so the injector never has to guess
|
|
465
|
-
// which resolved keys hold rendered content.
|
|
466
|
-
regionSlots: (
|
|
467
|
-
loaderConfig as { region_slots?: string[] }
|
|
468
|
-
).region_slots,
|
|
469
|
-
});
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
const meta = buildMetaFromContext(routeConfig.meta_context, resolved);
|
|
473
|
-
// /sign-in brand override: a shared org-scoped link 302→/sign-in for the
|
|
474
|
-
// unauthed crawler (WhatsApp), which saw the service's default website
|
|
475
|
-
// brand (the platform owner). The callbackURL carries the destination org
|
|
476
|
-
// — override resolved.brand (before the title/meta/og are built from it)
|
|
477
|
-
// so the page reflects the org the visitor is signing in to. Scoped to
|
|
478
|
-
// /sign-in; resource pages keep their own brand.
|
|
479
|
-
//
|
|
480
|
-
// The org is segment 1 of the canonical portal shape /<websiteId>/<orgId>/
|
|
481
|
-
// <path>, NOT segment 0 (the website id): reading segment 0 fed a website id
|
|
482
|
-
// into fetchOrganisationPageBranding, which resolved no org and left the
|
|
483
|
-
// default (super-admin) brand — the cross-tenant mis-branding this override
|
|
484
|
-
// exists to prevent (portal-branding-resolves-page-org-first, 2026-07-21).
|
|
485
|
-
// Shared helper so this and bind-page-handler cannot drift on the answer.
|
|
486
|
-
try {
|
|
487
|
-
const reqUrl = new URL(request.url);
|
|
488
|
-
if (reqUrl.pathname === "/sign-in") {
|
|
489
|
-
const cb = reqUrl.searchParams.get("callbackURL") ?? "";
|
|
490
|
-
const cbOrgId = extractOrgIdFromCallbackPath(cb);
|
|
491
|
-
const currentBrandOrgId =
|
|
492
|
-
(resolved.brand as { orgId?: string } | undefined)?.orgId ??
|
|
493
|
-
(resolved as { orgId?: string }).orgId;
|
|
494
|
-
if (cbOrgId && cbOrgId !== currentBrandOrgId) {
|
|
495
|
-
const branding = await fetchOrganisationPageBranding(
|
|
496
|
-
getServerClient() as never,
|
|
497
|
-
cbOrgId,
|
|
498
|
-
);
|
|
499
|
-
if (branding) {
|
|
500
|
-
const existing = (resolved.brand as Record<string, unknown>) ?? {};
|
|
501
|
-
const existingLogo =
|
|
502
|
-
(existing.logo as Record<string, unknown> | undefined) ?? {};
|
|
503
|
-
resolved.brand = {
|
|
504
|
-
...existing,
|
|
505
|
-
companyName: branding.company_name,
|
|
506
|
-
// Friendly name is the customer-facing one; falling back to the
|
|
507
|
-
// legal entity fed "GMG APAC" to every consumer that correctly
|
|
508
|
-
// read companyFriendlyName (e.g. logoAlt in the Puck host codegen).
|
|
509
|
-
companyFriendlyName:
|
|
510
|
-
branding.company_friendly_name ?? branding.company_name,
|
|
511
|
-
primaryColor: branding.brand_primary_color ?? existing.primaryColor,
|
|
512
|
-
headingFont: branding.brand_heading_font ?? existing.headingFont,
|
|
513
|
-
bodyFont: branding.brand_body_font ?? existing.bodyFont,
|
|
514
|
-
logo: {
|
|
515
|
-
light: branding.company_logo_for_light_theme,
|
|
516
|
-
dark: branding.company_logo_for_dark_theme,
|
|
517
|
-
brand: existingLogo.brand ?? null,
|
|
518
|
-
},
|
|
519
|
-
};
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
} catch {
|
|
524
|
-
// Branding fetch failed — keep the default brand (no regression).
|
|
525
|
-
}
|
|
526
|
-
const pageMetaTitle =
|
|
527
|
-
(
|
|
528
|
-
(resolved.loaderData as { page?: { meta_title?: unknown } } | undefined)
|
|
529
|
-
?.page?.meta_title ??
|
|
530
|
-
(resolved.page as { meta_title?: unknown } | undefined)?.meta_title
|
|
209
|
+
const regionSlots = (
|
|
210
|
+
loaderConfig as { region_slots?: string[] }
|
|
211
|
+
).region_slots;
|
|
212
|
+
await runContentGatesForPage(
|
|
213
|
+
resolved,
|
|
214
|
+
gateCookie,
|
|
215
|
+
Date.now(),
|
|
216
|
+
routeConfig.assistant,
|
|
217
|
+
regionSlots,
|
|
531
218
|
);
|
|
219
|
+
const meta = buildMetaFromContext(routeConfig.meta_context, resolved);
|
|
220
|
+
await applySignInBrandOverride(resolved, request);
|
|
221
|
+
const pageMetaTitle = resolvePageMetaTitle(resolved);
|
|
532
222
|
if (
|
|
533
223
|
typeof pageMetaTitle === "string" &&
|
|
534
224
|
pageMetaTitle.trim() &&
|
|
@@ -538,7 +228,7 @@ export async function registerRegistryPageRoute(
|
|
|
538
228
|
}
|
|
539
229
|
// Org-prefixed page titles (feature flag page-title-org-prefix, read
|
|
540
230
|
// LIVE per request — feature flags are never cached). The title prefix
|
|
541
|
-
// is the OWNING ORG'
|
|
231
|
+
// is the OWNING ORG'S name, never the internal website label
|
|
542
232
|
// ("Public Pages: <subject>" on a client-facing document was the
|
|
543
233
|
// defect; operator ruling 2026-08-17). Absent or unreadable flag ⇒
|
|
544
234
|
// legacy <website.name>: <subject> derivation — the switch fails toward
|
|
@@ -555,11 +245,6 @@ export async function registerRegistryPageRoute(
|
|
|
555
245
|
} catch {
|
|
556
246
|
// Flag row absent — legacy derivation.
|
|
557
247
|
}
|
|
558
|
-
const regionSlots = (
|
|
559
|
-
loaderConfig as v.InferOutput<typeof ResourceLoaderConfigSchema> & {
|
|
560
|
-
region_slots?: string[];
|
|
561
|
-
}
|
|
562
|
-
).region_slots;
|
|
563
248
|
const themeParam = url.searchParams.get("theme");
|
|
564
249
|
// ?theme is the EMBED override — only honor it when this page is
|
|
565
250
|
// actually framed (a form/quiz in a GPT tool panel, sec-fetch-dest:
|
|
@@ -583,31 +268,7 @@ export async function registerRegistryPageRoute(
|
|
|
583
268
|
orgTitlePrefix,
|
|
584
269
|
);
|
|
585
270
|
const panelSlug = new URL(request.url).searchParams.get("panel");
|
|
586
|
-
|
|
587
|
-
try {
|
|
588
|
-
const dbReg = getServerClient() as unknown as {
|
|
589
|
-
registry_entries: { findFirst: (a: { where: unknown; select: unknown }) => Promise<{ config: Record<string, unknown> } | null> };
|
|
590
|
-
};
|
|
591
|
-
const pv = await dbReg.registry_entries.findFirst({
|
|
592
|
-
where: { type: "panel_view", slug: panelSlug, is_active: true },
|
|
593
|
-
select: { config: true },
|
|
594
|
-
});
|
|
595
|
-
const pageSlug = (pv?.config?.content as { page_slug?: string })?.page_slug;
|
|
596
|
-
if (pageSlug) {
|
|
597
|
-
const dbPages = getServerClient() as unknown as {
|
|
598
|
-
builda_pages: { findFirst: (a: { where: unknown; select: unknown }) => Promise<{ puck_data: unknown } | null> };
|
|
599
|
-
};
|
|
600
|
-
const panelPage = await dbPages.builda_pages.findFirst({
|
|
601
|
-
where: { path: `/${pageSlug}`, is_active: true },
|
|
602
|
-
select: { puck_data: true },
|
|
603
|
-
});
|
|
604
|
-
if (panelPage?.puck_data) {
|
|
605
|
-
(pageData as Record<string, unknown>).panelPageData = panelPage.puck_data;
|
|
606
|
-
(pageData as Record<string, unknown>).activePanel = panelSlug;
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
} catch { /* non-critical */ }
|
|
610
|
-
}
|
|
271
|
+
await attachPanelPage(pageData as Record<string, unknown>, panelSlug);
|
|
611
272
|
if (
|
|
612
273
|
new URL(request.url).pathname === "/sign-in" &&
|
|
613
274
|
typeof pageMetaTitle === "string" &&
|
|
@@ -618,168 +279,19 @@ export async function registerRegistryPageRoute(
|
|
|
618
279
|
title: pageMetaTitle.trim(),
|
|
619
280
|
};
|
|
620
281
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
//
|
|
634
|
-
// Measured 2026-08-17: the Launcha Authority Blueprint, owned by GMG
|
|
635
|
-
// APAC, rendered correctly for an anonymous customer and in GBG Indo's
|
|
636
|
-
// navy with the scala logo for any signed-in staff member — so the two
|
|
637
|
-
// people checking it before it went to clients were the only two who
|
|
638
|
-
// could not see what a client sees. Silent, and inverted: the reviewers
|
|
639
|
-
// got the wrong page.
|
|
640
|
-
if (
|
|
641
|
-
routeConfig.render_mode !== "public" &&
|
|
642
|
-
sessionOrgId &&
|
|
643
|
-
sessionOrgId !== (pageData.orgId as string | undefined) &&
|
|
644
|
-
// Page-org-first (portal-branding-resolves-page-org-first): the
|
|
645
|
-
// session override personalizes ONLY the shared PLATFORM website
|
|
646
|
-
// (super-admin org). A TENANT-owned site (a customer GPT/quiz/portal)
|
|
647
|
-
// keeps its page-org brand regardless of the viewer session org. The
|
|
648
|
-
// old pageData.orgId===website.org mis-fired for a tenant whose page org equals their own website org.
|
|
649
|
-
getPlatformOrgId() ===
|
|
650
|
-
((pageData.website as { organisation_id?: string | null } | undefined)
|
|
651
|
-
?.organisation_id ?? undefined)
|
|
652
|
-
) {
|
|
653
|
-
try {
|
|
654
|
-
const branding = await fetchOrganisationPageBranding(
|
|
655
|
-
getServerClient() as never,
|
|
656
|
-
sessionOrgId,
|
|
657
|
-
);
|
|
658
|
-
if (branding) {
|
|
659
|
-
pageData.orgId = sessionOrgId;
|
|
660
|
-
const existing = (pageData.brand as Record<string, unknown>) ?? {};
|
|
661
|
-
const existingLogo =
|
|
662
|
-
(existing.logo as Record<string, unknown>) ?? {};
|
|
663
|
-
pageData.brand = {
|
|
664
|
-
...existing,
|
|
665
|
-
companyName: branding.company_name,
|
|
666
|
-
// Friendly name is the customer-facing one; falling back to the
|
|
667
|
-
// legal entity fed "GMG APAC" to every consumer that correctly
|
|
668
|
-
// read companyFriendlyName (e.g. logoAlt in the Puck host codegen).
|
|
669
|
-
companyFriendlyName:
|
|
670
|
-
branding.company_friendly_name ?? branding.company_name,
|
|
671
|
-
primaryColor: branding.brand_primary_color ?? existing.primaryColor,
|
|
672
|
-
headingFont: branding.brand_heading_font ?? existing.headingFont,
|
|
673
|
-
bodyFont: branding.brand_body_font ?? existing.bodyFont,
|
|
674
|
-
logo: {
|
|
675
|
-
light: branding.company_logo_for_light_theme,
|
|
676
|
-
dark: branding.company_logo_for_dark_theme,
|
|
677
|
-
brand: existingLogo.brand ?? null,
|
|
678
|
-
},
|
|
679
|
-
};
|
|
680
|
-
}
|
|
681
|
-
} catch {
|
|
682
|
-
// Branding fetch failed — keep the default brand (no regression).
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
// BRAND FROM THE ORG THAT OWNS THE RESOURCE, not the website's owner.
|
|
686
|
-
// A public resource page (an assessment report, a shared document) is
|
|
687
|
-
// hosted on a shared website but BELONGS to a tenant. resolvePageBrand
|
|
688
|
-
// reads pageData.orgId, which is the WEBSITE's org, so every tenant's
|
|
689
|
-
// report rendered the host org's logo and palette.
|
|
690
|
-
//
|
|
691
|
-
// Measured 2026-08-17: the Launcha Authority Blueprint is owned by GMG
|
|
692
|
-
// APAC (#FF4500, launcha logo) and rendered with GBG Indo's navy and
|
|
693
|
-
// scala mark, because both pages live on the shared "Public Pages"
|
|
694
|
-
// site. The loader had already resolved the right org — nothing
|
|
695
|
-
// consumed it.
|
|
696
|
-
//
|
|
697
|
-
// `brand_org_path` names where the owning org id lives in the resolved
|
|
698
|
-
// loader data (e.g. "quiz.organisation_id"), so which resource owns the
|
|
699
|
-
// brand is a ROW, never a per-service branch
|
|
700
|
-
// (`business-logic-is-rows-plus-executors`). Absent or unresolvable,
|
|
701
|
-
// the website's org stands — this can only ever narrow the brand to a
|
|
702
|
-
// more specific owner, never blank it.
|
|
703
|
-
const brandOrgPath = (routeConfig as { brand_org_path?: string }).brand_org_path;
|
|
704
|
-
if (brandOrgPath) {
|
|
705
|
-
let cursor: unknown = resolved;
|
|
706
|
-
for (const key of brandOrgPath.split(".")) {
|
|
707
|
-
if (cursor == null || typeof cursor !== "object") {
|
|
708
|
-
cursor = undefined;
|
|
709
|
-
break;
|
|
710
|
-
}
|
|
711
|
-
cursor = (cursor as Record<string, unknown>)[key];
|
|
712
|
-
}
|
|
713
|
-
if (typeof cursor === "string" && cursor.length > 0) {
|
|
714
|
-
pageData.orgId = cursor;
|
|
715
|
-
// orgId alone is not enough. resolvePageBrand fills only the
|
|
716
|
-
// accent/secondary/muted gaps from the org; primaryColor, logo and
|
|
717
|
-
// companyName come from pageData.brand, which was built from the
|
|
718
|
-
// website's org. Replace those too, the same way the session
|
|
719
|
-
// override above does, or the page keeps the host's logo while
|
|
720
|
-
// claiming the tenant's org.
|
|
721
|
-
try {
|
|
722
|
-
const owner = await fetchOrganisationPageBranding(
|
|
723
|
-
getServerClient() as never,
|
|
724
|
-
cursor,
|
|
725
|
-
);
|
|
726
|
-
if (owner) {
|
|
727
|
-
const existing = (pageData.brand as Record<string, unknown>) ?? {};
|
|
728
|
-
const existingLogo = (existing.logo as Record<string, unknown>) ?? {};
|
|
729
|
-
pageData.brand = {
|
|
730
|
-
...existing,
|
|
731
|
-
companyName: owner.company_name,
|
|
732
|
-
companyFriendlyName:
|
|
733
|
-
owner.company_friendly_name ?? owner.company_name,
|
|
734
|
-
primaryColor: owner.brand_primary_color ?? existing.primaryColor,
|
|
735
|
-
headingFont: owner.brand_heading_font ?? existing.headingFont,
|
|
736
|
-
bodyFont: owner.brand_body_font ?? existing.bodyFont,
|
|
737
|
-
logo: {
|
|
738
|
-
light: owner.company_logo_for_light_theme,
|
|
739
|
-
dark: owner.company_logo_for_dark_theme,
|
|
740
|
-
brand: existingLogo.brand ?? null,
|
|
741
|
-
},
|
|
742
|
-
};
|
|
743
|
-
}
|
|
744
|
-
} catch {
|
|
745
|
-
// Branding fetch failed — keep the website brand (no regression).
|
|
746
|
-
}
|
|
747
|
-
} else {
|
|
748
|
-
log.warn(
|
|
749
|
-
`[scala-install:${serviceSlug}] brand_org_path "${brandOrgPath}" resolved no org id for ${params.path}; keeping the website org brand`,
|
|
750
|
-
);
|
|
751
|
-
}
|
|
752
|
-
}
|
|
282
|
+
await applySessionOrgBranding(
|
|
283
|
+
pageData as Record<string, unknown>,
|
|
284
|
+
routeConfig.render_mode,
|
|
285
|
+
sessionOrgId,
|
|
286
|
+
);
|
|
287
|
+
await applyBrandOrgPath(
|
|
288
|
+
pageData as Record<string, unknown>,
|
|
289
|
+
resolved,
|
|
290
|
+
(routeConfig as { brand_org_path?: string }).brand_org_path,
|
|
291
|
+
params,
|
|
292
|
+
serviceSlug,
|
|
293
|
+
);
|
|
753
294
|
warnUncoveredBlocks(pageData, components, serviceSlug, params.path, log);
|
|
754
|
-
// Fire the AI-chat conversation prefetch NOW (in parallel with the
|
|
755
|
-
// proposal/partner/client post-processors below) so its ~eager cost is
|
|
756
|
-
// hidden in their window, then await it just before return. orgId is the
|
|
757
|
-
// URL :orgId (org-scoped routing — the same source AiChat uses); pageData
|
|
758
|
-
// .orgId is stamped later in service-runtime, not yet set here.
|
|
759
|
-
// A canonical portal URL is /<websiteId>/<organisationId>/<path> — the
|
|
760
|
-
// org is segment 1, NOT segment 0. Reading segment 0 captured the
|
|
761
|
-
// WEBSITE id and passed it as an organisation id, which failed silently
|
|
762
|
-
// because both are well-formed UUIDs: the prefetch simply warmed a
|
|
763
|
-
// session against the wrong tenant (found 2026-07-21).
|
|
764
|
-
//
|
|
765
|
-
// Prefer segment 1; fall back to segment 0 only when segment 1 is not a
|
|
766
|
-
// UUID (the single-UUID shape, which is live and supported — NOT
|
|
767
|
-
// transitional). The fallback is deliberately kept
|
|
768
|
-
// UNCHANGED-BEHAVIOUR rather than "fixed": a UUID in segment 0 is
|
|
769
|
-
// genuinely ambiguous — of the 40 UUID-first page paths in builda_pages,
|
|
770
|
-
// ZERO are websites and ZERO are organisations, so segment 0 can also be
|
|
771
|
-
// an unrelated record id. Position alone cannot resolve that; only
|
|
772
|
-
// validating the id against organisation_profile can, and that is a DB
|
|
773
|
-
// read this hot prefetch path should not take without caching
|
|
774
|
-
// (`hot-path-resolutions-are-cached`). Preferring segment 1 is strictly
|
|
775
|
-
// better for the canonical shape and no worse for any other.
|
|
776
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
777
|
-
const pathSegments = new URL(request.url).pathname.split("/").filter(Boolean);
|
|
778
|
-
const aiOrgId = UUID_RE.test(pathSegments[1] ?? "")
|
|
779
|
-
? pathSegments[1]
|
|
780
|
-
: UUID_RE.test(pathSegments[0] ?? "")
|
|
781
|
-
? pathSegments[0]
|
|
782
|
-
: undefined;
|
|
783
295
|
// Detached (NOT awaited into the response). prefetchAiChatSession ->
|
|
784
296
|
// createOrRestore is DB-heavy (~1.1s), per-user, and uncached, and was
|
|
785
297
|
// awaited on EVERY loadData — the dominant board loadMs cost (confirmed
|
|
@@ -788,7 +300,10 @@ export async function registerRegistryPageRoute(
|
|
|
788
300
|
// warm via their live fetch) without blocking the page response. The SSR
|
|
789
301
|
// chat seed (pageData.aiChatSession) is sacrificed — chat opens with a
|
|
790
302
|
// live fetch instead of SSR-instant — the right trade: most loads never
|
|
791
|
-
// open chat, so eager-awaited prefetch was the waste.
|
|
303
|
+
// open chat, so eager-awaited prefetch was the waste. orgId is the
|
|
304
|
+
// URL-derived org (resolveAiOrgId) — see prefetch-org-id.ts for why
|
|
305
|
+
// segment 1 is preferred over segment 0.
|
|
306
|
+
const aiOrgId = resolveAiOrgId(new URL(request.url).pathname);
|
|
792
307
|
void prefetchAiChatSession(resolved, sessionUserId, aiOrgId).catch((err) =>
|
|
793
308
|
log.warn(
|
|
794
309
|
`[scala-install:${serviceSlug}] background ai-chat prefetch failed: ${err instanceof Error ? err.message : err}`,
|
|
@@ -872,53 +387,3 @@ export async function registerRegistryPageRoute(
|
|
|
872
387
|
|
|
873
388
|
return registeredPatterns;
|
|
874
389
|
}
|
|
875
|
-
|
|
876
|
-
/**
|
|
877
|
-
* Block-coverage guard (protective-layers-fail-explicitly). A page that renders
|
|
878
|
-
* a block whose component is NOT registered — because its package is missing
|
|
879
|
-
* from service_install.config.block_packages — silently renders BLANK. That is
|
|
880
|
-
* the exact failure behind the 2026-06-04 scala-sign regression (sign-in-block
|
|
881
|
-
* dropped from block_packages → the gate + signature fields vanished) which left
|
|
882
|
-
* only a benign boot WARN. This turns that silent blank into a loud,
|
|
883
|
-
* GlitchTip-visible ERROR naming the page + block, throttled to once per
|
|
884
|
-
* (service, block type) so a hot path can never spam it.
|
|
885
|
-
*/
|
|
886
|
-
const uncoveredBlocksLogged = new Set<string>();
|
|
887
|
-
|
|
888
|
-
function collectBlockTypes(node: unknown, out: Set<string>): void {
|
|
889
|
-
if (Array.isArray(node)) {
|
|
890
|
-
for (const child of node) collectBlockTypes(child, out);
|
|
891
|
-
return;
|
|
892
|
-
}
|
|
893
|
-
if (node && typeof node === "object") {
|
|
894
|
-
const obj = node as Record<string, unknown>;
|
|
895
|
-
if (
|
|
896
|
-
typeof obj.type === "string" &&
|
|
897
|
-
obj.props &&
|
|
898
|
-
typeof obj.props === "object"
|
|
899
|
-
) {
|
|
900
|
-
out.add(obj.type);
|
|
901
|
-
}
|
|
902
|
-
for (const value of Object.values(obj)) collectBlockTypes(value, out);
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
function warnUncoveredBlocks(
|
|
907
|
-
pageData: unknown,
|
|
908
|
-
components: Record<string, RendererComponentEntry>,
|
|
909
|
-
serviceSlug: string,
|
|
910
|
-
path: unknown,
|
|
911
|
-
log: ReturnType<typeof getAppLogger>,
|
|
912
|
-
): void {
|
|
913
|
-
const used = new Set<string>();
|
|
914
|
-
collectBlockTypes(pageData, used);
|
|
915
|
-
for (const type of used) {
|
|
916
|
-
if (type in components) continue;
|
|
917
|
-
const key = `${serviceSlug}:${type}`;
|
|
918
|
-
if (uncoveredBlocksLogged.has(key)) continue;
|
|
919
|
-
uncoveredBlocksLogged.add(key);
|
|
920
|
-
log.error(
|
|
921
|
-
`[scala-install:${serviceSlug}] page "${String(path)}" renders block "${type}" but NO component is registered for it — its package is almost certainly missing from service_install.config.block_packages, so it renders BLANK. Add the owning @teamscala/* package to block_packages and restart. (block-coverage guard)`,
|
|
922
|
-
);
|
|
923
|
-
}
|
|
924
|
-
}
|