@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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @system ui-service-boot
3
- * @status handwritten @indivisible-unit
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 { getServerClient } from "./page-server-orpc/server-client.ts";
30
- import { resolveFromLoaderConfig } from "./resolve-from-loader-config.ts";
31
- import { runContentGate } from "./content-gate/gate.ts";
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
- staticShellGraphCache,
41
- paramRouteGraphCache,
42
- readCachedGraph,
43
- } from "./register-registry-page-route/graph-cache.ts";
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 graphKey = `graph:${routeRow.slug}:${params.host}:${params.path}`;
208
- const primaryType = routeConfig.resource_type;
209
- const hasPrimarySource =
210
- !!primaryType &&
211
- !!(loaderConfig as { sources?: { key: string }[] }).sources?.some(
212
- (s) => s.key === primaryType,
213
- );
214
- const graphCache = isStaticShell
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
- if (contentFloorError) throw contentFloorError;
316
- // Withhold gated content from the SSR unless the request carries a valid
317
- // signed access token — the single server-side scrape gate. One engine
318
- // (runContentGate), one config per content type. Proposals run first
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 gateNow = Date.now();
324
- // Resolve the signed-in viewer from the unified auth session (local
325
- // verify, works on Railway) and stamp it on the graph, so a gate can
326
- // release on a standard OTP-login session (config.sessionAuthorize) —
327
- // the unified path, no per-gate OTP code. Anonymous if no session.
328
- let sessionUserId: string | undefined;
329
- let sessionOrgId: string | undefined;
330
- try {
331
- const { getSession } = await import("@teamscala/auth/get-session.ts");
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's name, never the internal website label
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
- if (panelSlug) {
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
- // brand-is-org-owned-full-scheme, reconciled with page-org-first
622
- // (portal-branding-resolves-page-org-first): the session override now
623
- // personalizes ONLY platform pages (page org === website owning-org).
624
- // Tenant pages keep their resolved page-org brand — the page is
625
- // authoritative, the menu switcher changes which tenant's pages you
626
- // view. Anonymous/public (no session) keeps the resolved brand. Same
627
- // fetchOrganisationPageBranding pattern as the /sign-in brand override.
628
- //
629
- // A PUBLIC PAGE IS NEVER PERSONALISED TO THE READER'S ORG. Personalising
630
- // the brand is right for a portal — you are looking at your own
631
- // workspace. It is wrong for a public document, which belongs to the org
632
- // that PUBLISHED it and must look identical to every reader.
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
- }