@teamscala/service-boot 0.23.284
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 +1016 -0
- package/src/agreement-gate.test.ts +118 -0
- package/src/agreement-gate.ts +887 -0
- package/src/asset-cache-key.ts +59 -0
- package/src/asset-optimize.ts +93 -0
- package/src/asset-proxy.ts +148 -0
- package/src/asset-social-card.ts +58 -0
- package/src/attach-agreement-magic.ts +62 -0
- package/src/attach-portal-members.ts +107 -0
- package/src/attach-proposal-accept.ts +123 -0
- package/src/attach-proposal-magic.ts +55 -0
- package/src/attach-transfers-upload.ts +100 -0
- package/src/auth-gate.ts +385 -0
- package/src/bind-page-handler.ts +223 -0
- package/src/build-client-assets/input-hashing.ts +206 -0
- package/src/build-client-assets/source-candidates.ts +115 -0
- package/src/build-client-assets.test.ts +216 -0
- package/src/build-client-assets.ts +962 -0
- package/src/compose-page-title.test.ts +88 -0
- package/src/compose-page-title.ts +73 -0
- package/src/content-gate/access.ts +89 -0
- package/src/content-gate/config-types.ts +108 -0
- package/src/content-gate/gate.ts +74 -0
- package/src/content-gate/parse-cookie.ts +23 -0
- package/src/content-gate/token.test.ts +70 -0
- package/src/content-gate/token.ts +148 -0
- package/src/content-page-floor.test.ts +127 -0
- package/src/content-page-floor.ts +70 -0
- package/src/discover-blocks.ts +228 -0
- package/src/expand-roster.ts +197 -0
- package/src/fn-routers/agreement-completion-email.ts +115 -0
- package/src/fn-routers/agreement-send-router.ts +303 -0
- package/src/fn-routers/ai-health-router.ts +64 -0
- package/src/fn-routers/auth-router.ts +131 -0
- package/src/fn-routers/cache-admin-router.ts +50 -0
- package/src/fn-routers/chat-session-router.ts +455 -0
- package/src/fn-routers/email-defaults.ts +22 -0
- package/src/fn-routers/email-link-ttl.ts +23 -0
- package/src/fn-routers/index.ts +70 -0
- package/src/fn-routers/panel-data-router.ts +93 -0
- package/src/fn-routers/persist-chat-messages.test.ts +86 -0
- package/src/fn-routers/persist-chat-messages.ts +96 -0
- package/src/fn-routers/plans-router.ts +141 -0
- package/src/fn-routers/proposal-send-router.ts +207 -0
- package/src/fn-routers/public-agreement-router.ts +865 -0
- package/src/fn-routers/public-assessment-router.ts +421 -0
- package/src/fn-routers/public-gpt-session-router.ts +373 -0
- package/src/fn-routers/sign-in-flow-router.ts +196 -0
- package/src/fn-routers/task-sync-bind.test.ts +105 -0
- package/src/fn-routers/task-sync-classify.test.ts +109 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
- package/src/fn-routers/task-sync-dispatch.ts +124 -0
- package/src/fn-routers/task-sync-router.test.ts +279 -0
- package/src/fn-routers/task-sync-router.ts +1036 -0
- package/src/fn-routers/tasks-router.ts +77 -0
- package/src/fn-routers/user-preferences-router.ts +139 -0
- package/src/fn-routers/work-items-router.ts +341 -0
- package/src/gate-ttls.ts +56 -0
- package/src/gpt-gate.ts +76 -0
- package/src/handlers/bun-stable-watch.ts +89 -0
- package/src/handlers/projects-digest.ts +198 -0
- package/src/handlers/prune-stale-data.ts +110 -0
- package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
- package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
- package/src/handlers/scala-tools-currency-watch.ts +171 -0
- package/src/handlers/scheduled-browser-tests.ts +85 -0
- package/src/handlers/sweep-event-log.ts +98 -0
- package/src/handlers/sweep-host-cache.ts +211 -0
- package/src/handlers/upstream-issue-sync.ts +210 -0
- package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
- package/src/handlers/whatsapp-liveness-logic.ts +53 -0
- package/src/handlers/whatsapp-liveness.test.ts +78 -0
- package/src/handlers/whatsapp-liveness.ts +125 -0
- package/src/hono-error-handler.ts +55 -0
- package/src/install-harness.ts +80 -0
- package/src/install-logging.ts +182 -0
- package/src/load-service-install-row.ts +98 -0
- package/src/load-service-prisma-client.ts +53 -0
- package/src/mount-orpc-router.ts +148 -0
- package/src/mount-server-packages.ts +155 -0
- package/src/mount.ts +18 -0
- package/src/mutable-app-ref.ts +68 -0
- package/src/page-route-shell-types.ts +16 -0
- package/src/page-server-orpc/audit-revert-router.ts +102 -0
- package/src/page-server-orpc/hub-mcp-client.ts +20 -0
- package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
- package/src/page-server-orpc/puck-pages-router.ts +272 -0
- package/src/page-server-orpc/server-client.ts +31 -0
- package/src/page-server-orpc/templates-router.ts +116 -0
- package/src/page-server-orpc/user-preferences-router.ts +132 -0
- package/src/pg-boss-handler-registry.ts +36 -0
- package/src/pg-boss-mount.ts +295 -0
- package/src/proposal-authorized-emails.ts +65 -0
- package/src/proposal-chat-inject.ts +141 -0
- package/src/proposal-gate.ts +175 -0
- package/src/read-html-template.test.ts +74 -0
- package/src/read-html-template.ts +102 -0
- package/src/read-viewport-from-request.ts +36 -0
- package/src/readiness-gate.ts +39 -0
- package/src/register-api-routes.ts +127 -0
- package/src/register-crash-handlers.ts +39 -0
- package/src/register-page-routes.ts +62 -0
- package/src/register-page-server-client.ts +109 -0
- package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
- package/src/register-registry-page-route/graph-cache.test.ts +84 -0
- package/src/register-registry-page-route/graph-cache.ts +90 -0
- package/src/register-registry-page-route/meta-title.ts +73 -0
- package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
- package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
- package/src/register-registry-page-route/resolve-page-data.ts +518 -0
- package/src/register-registry-page-route.ts +765 -0
- package/src/register-static-assets.ts +180 -0
- package/src/register-ws-routes.ts +180 -0
- package/src/resolve-app-base-url.ts +28 -0
- package/src/resolve-brand-favicon.ts +65 -0
- package/src/resolve-from-loader-config.test.ts +44 -0
- package/src/resolve-from-loader-config.ts +291 -0
- package/src/resolve-service-context.ts +67 -0
- package/src/resolve-theme-mode.ts +61 -0
- package/src/resolvers/fetch-org-brand.ts +28 -0
- package/src/resolvers/portal-route-paths.test.ts +102 -0
- package/src/resolvers/portal-route-paths.ts +83 -0
- package/src/resolvers/website-by-host-map.ts +49 -0
- package/src/resource-chat-inject.ts +168 -0
- package/src/resource-not-found.ts +263 -0
- package/src/run-boot-hook-list.ts +75 -0
- package/src/run-boot-hooks.ts +97 -0
- package/src/scala-tools-currency-watchdog.ts +160 -0
- package/src/security-headers.ts +59 -0
- package/src/serve/serve-ws.test.ts +135 -0
- package/src/serve/serve.ts +190 -0
- package/src/server-error-page.test.ts +102 -0
- package/src/server-error-page.ts +95 -0
- package/src/start-scheduled-jobs.ts +84 -0
- package/src/two-phase-boot.ts +449 -0
- package/src/types.ts +52 -0
- package/src/ui-service.ts +171 -0
- package/src/ws-boot-self-test.ts +114 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Reads `page_route` registry rows for the current service slug and registers
|
|
7
|
+
* Hono routes that resolve data via `resource_loader` graph-based sources.
|
|
8
|
+
*
|
|
9
|
+
* Every page_route uses pattern `/:id` and every resource_loader uses a
|
|
10
|
+
* `sources[]` dependency graph. When header/footer sources are present in
|
|
11
|
+
* the resolved graph, the output includes a `regions` array for shell rendering.
|
|
12
|
+
* Otherwise it produces single-page puckData.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { RenderModeLiteral } from "./page-route-shell-types.ts";
|
|
16
|
+
import { PageRouteConfigSchema } from "@teamscala/db-validation/registry-schemas/page-route";
|
|
17
|
+
import { ResourceLoaderConfigSchema } from "@teamscala/db-validation/registry-schemas/resource-loader";
|
|
18
|
+
import {
|
|
19
|
+
loadRegistryConfig,
|
|
20
|
+
loadRegistryConfigsByType,
|
|
21
|
+
} from "@teamscala/db/registry/load-config";
|
|
22
|
+
import * as v from "valibot";
|
|
23
|
+
import { fetchOrganisationPageBranding } from "@teamscala/db/fetch-organisation-page-branding";
|
|
24
|
+
import { getPlatformOrgId } from "@teamscala/db/registry/platform";
|
|
25
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
26
|
+
import type { Hono } from "hono";
|
|
27
|
+
import { bindPageHandler } from "./bind-page-handler.ts";
|
|
28
|
+
import { getServerClient } from "./page-server-orpc/server-client.ts";
|
|
29
|
+
import { resolveFromLoaderConfig } from "./resolve-from-loader-config.ts";
|
|
30
|
+
import { runContentGate } from "./content-gate/gate.ts";
|
|
31
|
+
import { agreementGateConfig } from "./agreement-gate.ts";
|
|
32
|
+
import { proposalGateConfig } from "./proposal-gate.ts";
|
|
33
|
+
import { gptGateConfig } from "./gpt-gate.ts";
|
|
34
|
+
import { extractOrgIdFromCallbackPath } from "./resolvers/portal-route-paths.ts";
|
|
35
|
+
import { PrimaryResourceMissingError } from "./resource-not-found.ts";
|
|
36
|
+
import { decideContentPageFloor } from "./content-page-floor.ts";
|
|
37
|
+
import type { CreatePageHandler, RendererComponentEntry } from "./types.ts";
|
|
38
|
+
import {
|
|
39
|
+
staticShellGraphCache,
|
|
40
|
+
paramRouteGraphCache,
|
|
41
|
+
readCachedGraph,
|
|
42
|
+
} from "./register-registry-page-route/graph-cache.ts";
|
|
43
|
+
import { resolvedToPageData } from "./register-registry-page-route/resolve-page-data.ts";
|
|
44
|
+
import {
|
|
45
|
+
buildMetaFromContext,
|
|
46
|
+
resolveTemplateTitle,
|
|
47
|
+
} from "./register-registry-page-route/meta-title.ts";
|
|
48
|
+
import { prefetchAiChatSession } from "./register-registry-page-route/ai-chat-prefetch.ts";
|
|
49
|
+
import {
|
|
50
|
+
applyApprovalEmailToken,
|
|
51
|
+
} from "./register-registry-page-route/proposal-approval-email-token.ts";
|
|
52
|
+
import {
|
|
53
|
+
applyPartnerBrand,
|
|
54
|
+
applyClientLogo,
|
|
55
|
+
} from "./register-registry-page-route/proposal-brand-stamping.ts";
|
|
56
|
+
|
|
57
|
+
interface PageRouteConfig {
|
|
58
|
+
pattern: string;
|
|
59
|
+
service: string;
|
|
60
|
+
loader_slug: string;
|
|
61
|
+
meta_context?: Record<string, Record<string, string>>;
|
|
62
|
+
resource_type?: string;
|
|
63
|
+
providers?: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface RegistryRouteArgs {
|
|
67
|
+
app: Hono;
|
|
68
|
+
serviceSlug: string;
|
|
69
|
+
components: Record<string, RendererComponentEntry>;
|
|
70
|
+
createPageHandler: CreatePageHandler;
|
|
71
|
+
authMode: string;
|
|
72
|
+
renderMode: RenderModeLiteral;
|
|
73
|
+
providers: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function extractUrlParams(
|
|
77
|
+
pattern: string,
|
|
78
|
+
pathname: string,
|
|
79
|
+
): Record<string, string> {
|
|
80
|
+
const patternParts = pattern.split("/").filter(Boolean);
|
|
81
|
+
const pathParts = pathname.split("/").filter(Boolean);
|
|
82
|
+
const params: Record<string, string> = {};
|
|
83
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
84
|
+
const pp = patternParts[i];
|
|
85
|
+
if (pp.startsWith(":")) {
|
|
86
|
+
params[pp.slice(1)] = pathParts[i] ?? "";
|
|
87
|
+
} else if (pp === "*") {
|
|
88
|
+
params._wildcard = pathParts.slice(i).join("/");
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return params;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function registerRegistryPageRoute(
|
|
96
|
+
args: RegistryRouteArgs,
|
|
97
|
+
): Promise<string[]> {
|
|
98
|
+
const {
|
|
99
|
+
app,
|
|
100
|
+
serviceSlug,
|
|
101
|
+
components,
|
|
102
|
+
createPageHandler,
|
|
103
|
+
authMode,
|
|
104
|
+
renderMode,
|
|
105
|
+
providers: baseProviders,
|
|
106
|
+
} = args;
|
|
107
|
+
const log = getAppLogger();
|
|
108
|
+
|
|
109
|
+
const allRoutes = await loadRegistryConfigsByType(
|
|
110
|
+
"page_route",
|
|
111
|
+
PageRouteConfigSchema,
|
|
112
|
+
);
|
|
113
|
+
const serviceRoutes = allRoutes.filter(
|
|
114
|
+
(r) => r.config.service === serviceSlug,
|
|
115
|
+
);
|
|
116
|
+
if (serviceRoutes.length === 0) return [];
|
|
117
|
+
|
|
118
|
+
// Only a service serving arbitrary portal paths (a `/*` catchall route —
|
|
119
|
+
// the portal) redirects signed-in users from `/` into
|
|
120
|
+
// /<website>/<org>/dashboard. Single-site portal-mode services (the
|
|
121
|
+
// fleet dashboard) serve their real page AT `/` — the unconditional
|
|
122
|
+
// redirect 404-looped them (2026-06-06; service-runtime@1.15.82 made
|
|
123
|
+
// the redirect opt-in via this flag).
|
|
124
|
+
const servesPortalCatchall = serviceRoutes.some(
|
|
125
|
+
(r) => r.config.pattern === "/*",
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Sort routes so more specific patterns register before wildcard/param routes.
|
|
129
|
+
// Hono matches by registration order, so `/*` (the portal catchall) registered
|
|
130
|
+
// before `/sign-in` would swallow the sign-in page and render it inside the
|
|
131
|
+
// PortalTemplate instead of the standalone PublicTemplate shell.
|
|
132
|
+
serviceRoutes.sort((a, b) => {
|
|
133
|
+
const pa = a.config.pattern;
|
|
134
|
+
const pb = b.config.pattern;
|
|
135
|
+
// Wildcard `/*` goes last
|
|
136
|
+
if (pa === "/*") return 1;
|
|
137
|
+
if (pb === "/*") return -1;
|
|
138
|
+
// Param routes `/:param` sort after literal routes
|
|
139
|
+
const aIsLiteral = !pa.includes(":") && !pa.includes("*");
|
|
140
|
+
const bIsLiteral = !pb.includes(":") && !pb.includes("*");
|
|
141
|
+
if (aIsLiteral && !bIsLiteral) return -1;
|
|
142
|
+
if (!aIsLiteral && bIsLiteral) return 1;
|
|
143
|
+
return 0;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const registeredPatterns: string[] = [];
|
|
147
|
+
|
|
148
|
+
for (const routeRow of serviceRoutes) {
|
|
149
|
+
const routeConfig = routeRow.config;
|
|
150
|
+
const pattern = routeConfig.pattern;
|
|
151
|
+
const loaderSlug = routeConfig.loader_slug;
|
|
152
|
+
const routeProviders = routeConfig.providers ?? baseProviders;
|
|
153
|
+
|
|
154
|
+
let loaderConfig: Awaited<
|
|
155
|
+
ReturnType<typeof loadRegistryConfig<typeof ResourceLoaderConfigSchema>>
|
|
156
|
+
> | null = null;
|
|
157
|
+
try {
|
|
158
|
+
loaderConfig = await loadRegistryConfig(
|
|
159
|
+
"resource_loader",
|
|
160
|
+
loaderSlug,
|
|
161
|
+
ResourceLoaderConfigSchema,
|
|
162
|
+
);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (
|
|
165
|
+
err instanceof Error &&
|
|
166
|
+
err.constructor.name === "RegistryConfigMissingError"
|
|
167
|
+
) {
|
|
168
|
+
log.warn(
|
|
169
|
+
`[scala-install:${serviceSlug}] page_route "${routeRow.slug}" references resource_loader "${loaderSlug}" which does not exist — skipping`,
|
|
170
|
+
);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Gate for the static-shell graph cache (see module header).
|
|
177
|
+
const isStaticShell =
|
|
178
|
+
!routeConfig.resource_type &&
|
|
179
|
+
!pattern.includes(":") &&
|
|
180
|
+
!pattern.includes("*");
|
|
181
|
+
|
|
182
|
+
const loadData = async (request: Request) => {
|
|
183
|
+
// A bare root ("/") that declares a resource_type is a UUID-driven
|
|
184
|
+
// public site (proposals/forms/quiz) with no resource selected — it
|
|
185
|
+
// must render the branded holding/not-found page ("no <resource> at
|
|
186
|
+
// this link"), never fall through the shared public loader to the
|
|
187
|
+
// marketing site. PrimaryResourceMissingError is the existing signal
|
|
188
|
+
// the page binder maps to buildResourceNotFoundResponse. A "/" route
|
|
189
|
+
// WITHOUT a resource_type (e.g. scala-public's marketing root) is
|
|
190
|
+
// unaffected and still renders its content.
|
|
191
|
+
if (pattern === "/" && routeConfig.resource_type) {
|
|
192
|
+
// Status 200: the bare root is a valid "no <resource> selected"
|
|
193
|
+
// landing, not an error — and kind:ui services must answer GET /
|
|
194
|
+
// with 200 (verify-prod-persistent-slot). An invalid /:id keeps
|
|
195
|
+
// the default 404 (genuinely not found).
|
|
196
|
+
throw new PrimaryResourceMissingError(routeConfig.resource_type, 200);
|
|
197
|
+
}
|
|
198
|
+
const url = new URL(request.url);
|
|
199
|
+
const params = extractUrlParams(pattern, url.pathname);
|
|
200
|
+
params.host = request.headers.get("host")?.split(":")[0] || url.hostname;
|
|
201
|
+
params.path = url.pathname || "/";
|
|
202
|
+
const graphKey = `graph:${routeRow.slug}:${params.host}:${params.path}`;
|
|
203
|
+
const primaryType = routeConfig.resource_type;
|
|
204
|
+
const hasPrimarySource =
|
|
205
|
+
!!primaryType &&
|
|
206
|
+
!!(loaderConfig as { sources?: { key: string }[] }).sources?.some(
|
|
207
|
+
(s) => s.key === primaryType,
|
|
208
|
+
);
|
|
209
|
+
const graphCache = isStaticShell
|
|
210
|
+
? staticShellGraphCache
|
|
211
|
+
: paramRouteGraphCache;
|
|
212
|
+
const typedLoaderConfig = loaderConfig as v.InferOutput<
|
|
213
|
+
typeof ResourceLoaderConfigSchema
|
|
214
|
+
> & {
|
|
215
|
+
sources: NonNullable<
|
|
216
|
+
v.InferOutput<typeof ResourceLoaderConfigSchema>["sources"]
|
|
217
|
+
>;
|
|
218
|
+
};
|
|
219
|
+
let resolved: Record<string, unknown>;
|
|
220
|
+
// SWR read over @teamscala/cache: fresh hit serves the cached graph, stale
|
|
221
|
+
// hit serves it AND fires ONE background refresh (refreshInBackground,
|
|
222
|
+
// single-flight via the cache's in-flight map), miss returns undefined.
|
|
223
|
+
// The refresh callback re-resolves + stores a clone; a primary-missing
|
|
224
|
+
// (dead-link) graph THROWS so the cache is not updated — a dead link must
|
|
225
|
+
// stay per-request so a just-created record appears immediately
|
|
226
|
+
// (refreshInBackground catches, leaves the stale entry, logs one line).
|
|
227
|
+
const cachedGraph = readCachedGraph(
|
|
228
|
+
graphCache,
|
|
229
|
+
graphKey,
|
|
230
|
+
() =>
|
|
231
|
+
resolveFromLoaderConfig(typedLoaderConfig, params).then(
|
|
232
|
+
(freshGraph) => {
|
|
233
|
+
const freshPrimaryMissing =
|
|
234
|
+
hasPrimarySource &&
|
|
235
|
+
primaryType &&
|
|
236
|
+
freshGraph[primaryType] == null;
|
|
237
|
+
if (freshPrimaryMissing) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`primary resource "${primaryType}" missing — skip cache update for ${graphKey}`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return structuredClone(freshGraph);
|
|
243
|
+
},
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
if (cachedGraph !== undefined) {
|
|
247
|
+
// Clone-on-get: gateAgreementContent + block resolution MUTATE the
|
|
248
|
+
// graph in place, so each request gets its own copy.
|
|
249
|
+
resolved = structuredClone(cachedGraph) as Record<string, unknown>;
|
|
250
|
+
} else {
|
|
251
|
+
resolved = await resolveFromLoaderConfig(typedLoaderConfig, params);
|
|
252
|
+
const primaryMissing =
|
|
253
|
+
hasPrimarySource && primaryType && resolved[primaryType] == null;
|
|
254
|
+
if (!primaryMissing) {
|
|
255
|
+
// Defensive clone: structuredClone throws on non-cloneable loader
|
|
256
|
+
// graphs (functions/Proxies/Prisma instances). A swallowed throw
|
|
257
|
+
// here used to SILENTLY disable the graph cache for this key
|
|
258
|
+
// (fail-open-silent — forbidden by protective-layers-fail-explicitly),
|
|
259
|
+
// which left affected routes re-resolving the loader graph every
|
|
260
|
+
// request (measured ~1.2s on the projects utility shell). JSON-clone
|
|
261
|
+
// preserves the data graph (dropping functions/undefined a loader
|
|
262
|
+
// graph shouldn't carry) so the cache stays effective; both fallbacks
|
|
263
|
+
// are LOGGED so the offending non-cloneable source is observable +
|
|
264
|
+
// fixable, never silently disabling the cache again.
|
|
265
|
+
let cloned: unknown;
|
|
266
|
+
try {
|
|
267
|
+
cloned = structuredClone(resolved);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
log.warn(
|
|
270
|
+
`[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}`,
|
|
271
|
+
);
|
|
272
|
+
try {
|
|
273
|
+
cloned = JSON.parse(JSON.stringify(resolved));
|
|
274
|
+
} catch (err2) {
|
|
275
|
+
log.warn(
|
|
276
|
+
`[scala-install:${serviceSlug}] graph-cache JSON-clone also failed for ${graphKey} — cache disabled for this key (observable): ${err2 instanceof Error ? err2.message : err2}`,
|
|
277
|
+
);
|
|
278
|
+
cloned = undefined;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (cloned !== undefined) {
|
|
282
|
+
graphCache.set(graphKey, cloned);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// A route that declares resource_type is meaningless without that
|
|
287
|
+
// entity: a dead link (wrong id, deleted row) must 404 with the
|
|
288
|
+
// branded not-found page — never fall through to default content
|
|
289
|
+
// (the "Vision Toolbar" leak, operator-observed 2026-06-05).
|
|
290
|
+
if (hasPrimarySource && primaryType && resolved[primaryType] == null) {
|
|
291
|
+
throw new PrimaryResourceMissingError(primaryType);
|
|
292
|
+
}
|
|
293
|
+
// A CONTENT route (no resource_type) whose loader resolves a `page`
|
|
294
|
+
// body source must not synthesize an empty page for an unmatched path
|
|
295
|
+
// and serve a cacheable 200 — the generalized "Vision Toolbar" leak
|
|
296
|
+
// (operator-reported 5×: a deleted page, e.g. /roadmap, returned 200
|
|
297
|
+
// with an empty body instead of not-found). No page row → dead link →
|
|
298
|
+
// branded 404 floor. A page row that exists but has zero blocks → a
|
|
299
|
+
// content gap → the floor's empty-page surface (200). Both carry the
|
|
300
|
+
// "Take me home" action. The pure decision lives in content-page-floor.ts
|
|
301
|
+
// (unit-tested without the Hono/DB/graph machinery). Region-slot
|
|
302
|
+
// (utility/portal) shells without a `page` source are untouched.
|
|
303
|
+
const contentFloorError = decideContentPageFloor({
|
|
304
|
+
hasResourceType: !!primaryType,
|
|
305
|
+
hasPageContentSource: !!(loaderConfig as { sources?: { key: string }[] }).sources?.some(
|
|
306
|
+
(s) => s.key === "page",
|
|
307
|
+
),
|
|
308
|
+
page: resolved.page,
|
|
309
|
+
});
|
|
310
|
+
if (contentFloorError) throw contentFloorError;
|
|
311
|
+
// Withhold gated content from the SSR unless the request carries a valid
|
|
312
|
+
// signed access token — the single server-side scrape gate. One engine
|
|
313
|
+
// (runContentGate), one config per content type. Proposals run first
|
|
314
|
+
// (an authorized viewer's gate is removed, so the agreement gate then
|
|
315
|
+
// no-ops); each config scopes to its own resource/field so they never
|
|
316
|
+
// collide.
|
|
317
|
+
const gateCookie = request.headers.get("cookie");
|
|
318
|
+
const gateNow = Date.now();
|
|
319
|
+
// Resolve the signed-in viewer from the unified auth session (local
|
|
320
|
+
// verify, works on Railway) and stamp it on the graph, so a gate can
|
|
321
|
+
// release on a standard OTP-login session (config.sessionAuthorize) —
|
|
322
|
+
// the unified path, no per-gate OTP code. Anonymous if no session.
|
|
323
|
+
let sessionUserId: string | undefined;
|
|
324
|
+
let sessionOrgId: string | undefined;
|
|
325
|
+
try {
|
|
326
|
+
const { getSession } = await import("@teamscala/auth/get-session.ts");
|
|
327
|
+
const sess = await getSession(request.headers);
|
|
328
|
+
if (sess?.user?.email) {
|
|
329
|
+
(resolved as { sessionVisitor?: { email: string; name?: string } }).sessionVisitor =
|
|
330
|
+
{
|
|
331
|
+
email: sess.user.email,
|
|
332
|
+
name: sess.user.name ?? undefined,
|
|
333
|
+
};
|
|
334
|
+
sessionUserId = sess.user.id;
|
|
335
|
+
sessionOrgId = sess.session?.activeOrganizationId ?? undefined;
|
|
336
|
+
// Stamp the per-request portal identity so block portal context
|
|
337
|
+
// (useBlockPortal().userId) is populated for authed users on
|
|
338
|
+
// org/hub pages — the SSR-side WRITER the connections-block reader
|
|
339
|
+
// (ClientBlockServicesProvider → __PAGE_DATA__.portalContext.user.id)
|
|
340
|
+
// depends on. Rides the same per-request path as sessionVisitor
|
|
341
|
+
// above (never the cached loader graph); merge preserves any
|
|
342
|
+
// portalContext fields set elsewhere (e.g. viewAsMembers). Without
|
|
343
|
+
// this, connections-block short-circuits at SignInPrompt.
|
|
344
|
+
const rc = resolved as {
|
|
345
|
+
portalContext?: {
|
|
346
|
+
user?: { id?: string } | null;
|
|
347
|
+
orgId?: string;
|
|
348
|
+
};
|
|
349
|
+
};
|
|
350
|
+
rc.portalContext = {
|
|
351
|
+
...(rc.portalContext ?? {}),
|
|
352
|
+
user: sessionUserId ? { id: sessionUserId } : null,
|
|
353
|
+
orgId: sessionOrgId,
|
|
354
|
+
};
|
|
355
|
+
// Read pane widths from the portal cookie (the client writes
|
|
356
|
+
// it immediately on resize via setPortalCookie; the cookie is
|
|
357
|
+
// sent with every HTTP request so the SSR reads it here).
|
|
358
|
+
// This renders the correct pane width from the first byte —
|
|
359
|
+
// no post-hydration shift, no client-side state read.
|
|
360
|
+
const paneWidthsMatch = gateCookie?.match(
|
|
361
|
+
/portal_pane_widths=([^;]+)/,
|
|
362
|
+
);
|
|
363
|
+
if (paneWidthsMatch) {
|
|
364
|
+
try {
|
|
365
|
+
const parsed = JSON.parse(
|
|
366
|
+
decodeURIComponent(paneWidthsMatch[1]),
|
|
367
|
+
);
|
|
368
|
+
if (parsed.left)
|
|
369
|
+
(rc.portalContext as Record<string, unknown>).paneLeftWidth =
|
|
370
|
+
parsed.left;
|
|
371
|
+
if (parsed.right)
|
|
372
|
+
(rc.portalContext as Record<string, unknown>).paneRightWidth =
|
|
373
|
+
parsed.right;
|
|
374
|
+
} catch {
|
|
375
|
+
// Malformed cookie — fall back to defaults.
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (sessionUserId) {
|
|
379
|
+
try {
|
|
380
|
+
const dbClient = getServerClient() as unknown as {
|
|
381
|
+
user: {
|
|
382
|
+
findUnique: (args: {
|
|
383
|
+
where: { id: string };
|
|
384
|
+
select: { preferences: boolean };
|
|
385
|
+
}) => Promise<{ preferences: unknown } | null>;
|
|
386
|
+
};
|
|
387
|
+
};
|
|
388
|
+
const userRow = await dbClient.user.findUnique({
|
|
389
|
+
where: { id: sessionUserId },
|
|
390
|
+
select: { preferences: true },
|
|
391
|
+
});
|
|
392
|
+
const portalPrefs = (
|
|
393
|
+
(userRow?.preferences as Record<string, unknown> | null)
|
|
394
|
+
?.portal as Record<string, unknown> | undefined
|
|
395
|
+
);
|
|
396
|
+
if (portalPrefs?.paneLeftWidth) {
|
|
397
|
+
(rc.portalContext as Record<string, unknown>).paneLeftWidth =
|
|
398
|
+
portalPrefs.paneLeftWidth;
|
|
399
|
+
}
|
|
400
|
+
if (portalPrefs?.paneRightWidth) {
|
|
401
|
+
(rc.portalContext as Record<string, unknown>).paneRightWidth =
|
|
402
|
+
portalPrefs.paneRightWidth;
|
|
403
|
+
}
|
|
404
|
+
} catch {
|
|
405
|
+
// Pane widths are non-critical — fall back to defaults.
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
} catch {
|
|
410
|
+
// No auth factory / no session → anonymous; gates fall back to token.
|
|
411
|
+
}
|
|
412
|
+
await runContentGate(resolved, gateCookie, gateNow, proposalGateConfig);
|
|
413
|
+
await runContentGate(resolved, gateCookie, gateNow, agreementGateConfig);
|
|
414
|
+
// The public GPT identity gate: seeds resolved.initialVisitor from the
|
|
415
|
+
// signed gpt_token cookie so a returning visitor's conversation renders
|
|
416
|
+
// at SSR (no capture-gate→chat flash). Open capture, seed-only — it
|
|
417
|
+
// never withholds page content (gpt-gate.ts).
|
|
418
|
+
await runContentGate(resolved, gateCookie, gateNow, gptGateConfig);
|
|
419
|
+
const meta = buildMetaFromContext(routeConfig.meta_context, resolved);
|
|
420
|
+
// /sign-in brand override: a shared org-scoped link 302→/sign-in for the
|
|
421
|
+
// unauthed crawler (WhatsApp), which saw the service's default website
|
|
422
|
+
// brand (the platform owner). The callbackURL carries the destination org
|
|
423
|
+
// — override resolved.brand (before the title/meta/og are built from it)
|
|
424
|
+
// so the page reflects the org the visitor is signing in to. Scoped to
|
|
425
|
+
// /sign-in; resource pages keep their own brand.
|
|
426
|
+
//
|
|
427
|
+
// The org is segment 1 of the canonical portal shape /<websiteId>/<orgId>/
|
|
428
|
+
// <path>, NOT segment 0 (the website id): reading segment 0 fed a website id
|
|
429
|
+
// into fetchOrganisationPageBranding, which resolved no org and left the
|
|
430
|
+
// default (super-admin) brand — the cross-tenant mis-branding this override
|
|
431
|
+
// exists to prevent (portal-branding-resolves-page-org-first, 2026-07-21).
|
|
432
|
+
// Shared helper so this and bind-page-handler cannot drift on the answer.
|
|
433
|
+
try {
|
|
434
|
+
const reqUrl = new URL(request.url);
|
|
435
|
+
if (reqUrl.pathname === "/sign-in") {
|
|
436
|
+
const cb = reqUrl.searchParams.get("callbackURL") ?? "";
|
|
437
|
+
const cbOrgId = extractOrgIdFromCallbackPath(cb);
|
|
438
|
+
const currentBrandOrgId =
|
|
439
|
+
(resolved.brand as { orgId?: string } | undefined)?.orgId ??
|
|
440
|
+
(resolved as { orgId?: string }).orgId;
|
|
441
|
+
if (cbOrgId && cbOrgId !== currentBrandOrgId) {
|
|
442
|
+
const branding = await fetchOrganisationPageBranding(
|
|
443
|
+
getServerClient() as never,
|
|
444
|
+
cbOrgId,
|
|
445
|
+
);
|
|
446
|
+
if (branding) {
|
|
447
|
+
const existing = (resolved.brand as Record<string, unknown>) ?? {};
|
|
448
|
+
const existingLogo =
|
|
449
|
+
(existing.logo as Record<string, unknown> | undefined) ?? {};
|
|
450
|
+
resolved.brand = {
|
|
451
|
+
...existing,
|
|
452
|
+
companyName: branding.company_name,
|
|
453
|
+
companyFriendlyName: branding.company_name,
|
|
454
|
+
primaryColor: branding.brand_primary_color ?? existing.primaryColor,
|
|
455
|
+
headingFont: branding.brand_heading_font ?? existing.headingFont,
|
|
456
|
+
bodyFont: branding.brand_body_font ?? existing.bodyFont,
|
|
457
|
+
logo: {
|
|
458
|
+
light: branding.company_logo_for_light_theme,
|
|
459
|
+
dark: branding.company_logo_for_dark_theme,
|
|
460
|
+
brand: existingLogo.brand ?? null,
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
} catch {
|
|
467
|
+
// Branding fetch failed — keep the default brand (no regression).
|
|
468
|
+
}
|
|
469
|
+
const pageMetaTitle =
|
|
470
|
+
(
|
|
471
|
+
(resolved.loaderData as { page?: { meta_title?: unknown } } | undefined)
|
|
472
|
+
?.page?.meta_title ??
|
|
473
|
+
(resolved.page as { meta_title?: unknown } | undefined)?.meta_title
|
|
474
|
+
);
|
|
475
|
+
if (
|
|
476
|
+
typeof pageMetaTitle === "string" &&
|
|
477
|
+
pageMetaTitle.trim() &&
|
|
478
|
+
new URL(request.url).pathname === "/sign-in"
|
|
479
|
+
) {
|
|
480
|
+
meta.title = pageMetaTitle.trim();
|
|
481
|
+
}
|
|
482
|
+
const templateTitle = resolveTemplateTitle(serviceSlug, resolved);
|
|
483
|
+
if (templateTitle && !meta.title) {
|
|
484
|
+
const resolvedEntity =
|
|
485
|
+
(resolved.agent as { name?: string } | undefined)?.name ||
|
|
486
|
+
(resolved.proposal as { name?: string } | undefined)?.name ||
|
|
487
|
+
(resolved.form as { name?: string } | undefined)?.name ||
|
|
488
|
+
(resolved.quiz as { title?: string } | undefined)?.title;
|
|
489
|
+
if (!resolvedEntity) {
|
|
490
|
+
meta.title = templateTitle;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const regionSlots = (
|
|
494
|
+
loaderConfig as v.InferOutput<typeof ResourceLoaderConfigSchema> & {
|
|
495
|
+
region_slots?: string[];
|
|
496
|
+
}
|
|
497
|
+
).region_slots;
|
|
498
|
+
const themeParam = url.searchParams.get("theme");
|
|
499
|
+
// ?theme is the EMBED override — only honor it when this page is
|
|
500
|
+
// actually framed (a form/quiz in a GPT tool panel, sec-fetch-dest:
|
|
501
|
+
// iframe). A DIRECT visit carrying ?theme= (e.g. copied from a GPT
|
|
502
|
+
// embed URL) must NOT override the org's DB theme — what the visitor
|
|
503
|
+
// sees is org-driven, never URL-driven.
|
|
504
|
+
const isFramedEmbed =
|
|
505
|
+
request.headers.get("sec-fetch-dest") === "iframe";
|
|
506
|
+
const themeOverride =
|
|
507
|
+
isFramedEmbed &&
|
|
508
|
+
(themeParam === "light" || themeParam === "dark")
|
|
509
|
+
? (themeParam as "light" | "dark")
|
|
510
|
+
: undefined;
|
|
511
|
+
const pageData = resolvedToPageData(
|
|
512
|
+
resolved,
|
|
513
|
+
meta,
|
|
514
|
+
request,
|
|
515
|
+
routeProviders !== baseProviders ? routeProviders : undefined,
|
|
516
|
+
regionSlots,
|
|
517
|
+
themeOverride,
|
|
518
|
+
);
|
|
519
|
+
const panelSlug = new URL(request.url).searchParams.get("panel");
|
|
520
|
+
if (panelSlug) {
|
|
521
|
+
try {
|
|
522
|
+
const dbReg = getServerClient() as unknown as {
|
|
523
|
+
registry_entries: { findFirst: (a: { where: unknown; select: unknown }) => Promise<{ config: Record<string, unknown> } | null> };
|
|
524
|
+
};
|
|
525
|
+
const pv = await dbReg.registry_entries.findFirst({
|
|
526
|
+
where: { type: "panel_view", slug: panelSlug, is_active: true },
|
|
527
|
+
select: { config: true },
|
|
528
|
+
});
|
|
529
|
+
const pageSlug = (pv?.config?.content as { page_slug?: string })?.page_slug;
|
|
530
|
+
if (pageSlug) {
|
|
531
|
+
const dbPages = getServerClient() as unknown as {
|
|
532
|
+
builda_pages: { findFirst: (a: { where: unknown; select: unknown }) => Promise<{ puck_data: unknown } | null> };
|
|
533
|
+
};
|
|
534
|
+
const panelPage = await dbPages.builda_pages.findFirst({
|
|
535
|
+
where: { path: `/${pageSlug}`, is_active: true },
|
|
536
|
+
select: { puck_data: true },
|
|
537
|
+
});
|
|
538
|
+
if (panelPage?.puck_data) {
|
|
539
|
+
(pageData as Record<string, unknown>).panelPageData = panelPage.puck_data;
|
|
540
|
+
(pageData as Record<string, unknown>).activePanel = panelSlug;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
} catch { /* non-critical */ }
|
|
544
|
+
}
|
|
545
|
+
if (
|
|
546
|
+
new URL(request.url).pathname === "/sign-in" &&
|
|
547
|
+
typeof pageMetaTitle === "string" &&
|
|
548
|
+
pageMetaTitle.trim()
|
|
549
|
+
) {
|
|
550
|
+
pageData.meta = {
|
|
551
|
+
...(pageData.meta as Record<string, unknown>),
|
|
552
|
+
title: pageMetaTitle.trim(),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
// brand-is-org-owned-full-scheme, reconciled with page-org-first
|
|
556
|
+
// (portal-branding-resolves-page-org-first): the session override now
|
|
557
|
+
// personalizes ONLY platform pages (page org === website owning-org).
|
|
558
|
+
// Tenant pages keep their resolved page-org brand — the page is
|
|
559
|
+
// authoritative, the menu switcher changes which tenant's pages you
|
|
560
|
+
// view. Anonymous/public (no session) keeps the resolved brand. Same
|
|
561
|
+
// fetchOrganisationPageBranding pattern as the /sign-in brand override.
|
|
562
|
+
if (
|
|
563
|
+
sessionOrgId &&
|
|
564
|
+
sessionOrgId !== (pageData.orgId as string | undefined) &&
|
|
565
|
+
// Page-org-first (portal-branding-resolves-page-org-first): the
|
|
566
|
+
// session override personalizes ONLY the shared PLATFORM website
|
|
567
|
+
// (super-admin org). A TENANT-owned site (a customer GPT/quiz/portal)
|
|
568
|
+
// keeps its page-org brand regardless of the viewer session org. The
|
|
569
|
+
// old pageData.orgId===website.org mis-fired for a tenant whose page org equals their own website org.
|
|
570
|
+
getPlatformOrgId() ===
|
|
571
|
+
((pageData.website as { organisation_id?: string | null } | undefined)
|
|
572
|
+
?.organisation_id ?? undefined)
|
|
573
|
+
) {
|
|
574
|
+
try {
|
|
575
|
+
const branding = await fetchOrganisationPageBranding(
|
|
576
|
+
getServerClient() as never,
|
|
577
|
+
sessionOrgId,
|
|
578
|
+
);
|
|
579
|
+
if (branding) {
|
|
580
|
+
pageData.orgId = sessionOrgId;
|
|
581
|
+
const existing = (pageData.brand as Record<string, unknown>) ?? {};
|
|
582
|
+
const existingLogo =
|
|
583
|
+
(existing.logo as Record<string, unknown>) ?? {};
|
|
584
|
+
pageData.brand = {
|
|
585
|
+
...existing,
|
|
586
|
+
companyName: branding.company_name,
|
|
587
|
+
companyFriendlyName: branding.company_name,
|
|
588
|
+
primaryColor: branding.brand_primary_color ?? existing.primaryColor,
|
|
589
|
+
headingFont: branding.brand_heading_font ?? existing.headingFont,
|
|
590
|
+
bodyFont: branding.brand_body_font ?? existing.bodyFont,
|
|
591
|
+
logo: {
|
|
592
|
+
light: branding.company_logo_for_light_theme,
|
|
593
|
+
dark: branding.company_logo_for_dark_theme,
|
|
594
|
+
brand: existingLogo.brand ?? null,
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
} catch {
|
|
599
|
+
// Branding fetch failed — keep the default brand (no regression).
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
warnUncoveredBlocks(pageData, components, serviceSlug, params.path, log);
|
|
603
|
+
// Fire the AI-chat conversation prefetch NOW (in parallel with the
|
|
604
|
+
// proposal/partner/client post-processors below) so its ~eager cost is
|
|
605
|
+
// hidden in their window, then await it just before return. orgId is the
|
|
606
|
+
// URL :orgId (org-scoped routing — the same source AiChat uses); pageData
|
|
607
|
+
// .orgId is stamped later in service-runtime, not yet set here.
|
|
608
|
+
// A canonical portal URL is /<websiteId>/<organisationId>/<path> — the
|
|
609
|
+
// org is segment 1, NOT segment 0. Reading segment 0 captured the
|
|
610
|
+
// WEBSITE id and passed it as an organisation id, which failed silently
|
|
611
|
+
// because both are well-formed UUIDs: the prefetch simply warmed a
|
|
612
|
+
// session against the wrong tenant (found 2026-07-21).
|
|
613
|
+
//
|
|
614
|
+
// Prefer segment 1; fall back to segment 0 only when segment 1 is not a
|
|
615
|
+
// UUID (the single-UUID shape, which is live and supported — NOT
|
|
616
|
+
// transitional). The fallback is deliberately kept
|
|
617
|
+
// UNCHANGED-BEHAVIOUR rather than "fixed": a UUID in segment 0 is
|
|
618
|
+
// genuinely ambiguous — of the 40 UUID-first page paths in builda_pages,
|
|
619
|
+
// ZERO are websites and ZERO are organisations, so segment 0 can also be
|
|
620
|
+
// an unrelated record id. Position alone cannot resolve that; only
|
|
621
|
+
// validating the id against organisation_profile can, and that is a DB
|
|
622
|
+
// read this hot prefetch path should not take without caching
|
|
623
|
+
// (`hot-path-resolutions-are-cached`). Preferring segment 1 is strictly
|
|
624
|
+
// better for the canonical shape and no worse for any other.
|
|
625
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
626
|
+
const pathSegments = new URL(request.url).pathname.split("/").filter(Boolean);
|
|
627
|
+
const aiOrgId = UUID_RE.test(pathSegments[1] ?? "")
|
|
628
|
+
? pathSegments[1]
|
|
629
|
+
: UUID_RE.test(pathSegments[0] ?? "")
|
|
630
|
+
? pathSegments[0]
|
|
631
|
+
: undefined;
|
|
632
|
+
// Detached (NOT awaited into the response). prefetchAiChatSession ->
|
|
633
|
+
// createOrRestore is DB-heavy (~1.1s), per-user, and uncached, and was
|
|
634
|
+
// awaited on EVERY loadData — the dominant board loadMs cost (confirmed
|
|
635
|
+
// aiChatPrefetch=1145ms = the full loadMs). Fire it in the background so
|
|
636
|
+
// it still warms the server-side session (users who open chat find it
|
|
637
|
+
// warm via their live fetch) without blocking the page response. The SSR
|
|
638
|
+
// chat seed (pageData.aiChatSession) is sacrificed — chat opens with a
|
|
639
|
+
// live fetch instead of SSR-instant — the right trade: most loads never
|
|
640
|
+
// open chat, so eager-awaited prefetch was the waste.
|
|
641
|
+
void prefetchAiChatSession(resolved, sessionUserId, aiOrgId).catch((err) =>
|
|
642
|
+
log.warn(
|
|
643
|
+
`[scala-install:${serviceSlug}] background ai-chat prefetch failed: ${err instanceof Error ? err.message : err}`,
|
|
644
|
+
),
|
|
645
|
+
);
|
|
646
|
+
// Proposal pages: resolve the {{approvalEmail}} button token to the
|
|
647
|
+
// org's registered company email (creator email as fallback).
|
|
648
|
+
if (resolved.proposal) {
|
|
649
|
+
await applyApprovalEmailToken(
|
|
650
|
+
pageData as Record<string, unknown>,
|
|
651
|
+
resolved.proposal as { created_by?: string } | undefined,
|
|
652
|
+
);
|
|
653
|
+
// Stash the partner logo from the partnerOrg loader source (CRM record
|
|
654
|
+
// loaded by partner_id) onto pageData.partnerLogo — header fills
|
|
655
|
+
// automatically, no inline body-Hero prop needed.
|
|
656
|
+
applyPartnerBrand(
|
|
657
|
+
pageData as Record<string, unknown>,
|
|
658
|
+
resolved.partnerOrg as
|
|
659
|
+
| { logo_url?: string | null; brand_primary_color?: string | null }
|
|
660
|
+
| undefined
|
|
661
|
+
| null,
|
|
662
|
+
);
|
|
663
|
+
// Auto-resolve the client's company logo from the clientOrg loader
|
|
664
|
+
// source (contact → company → crm_organisations.logo_url). Only
|
|
665
|
+
// injects when the Hero's clientLogo prop is empty — a manually set
|
|
666
|
+
// logo always wins.
|
|
667
|
+
applyClientLogo(
|
|
668
|
+
pageData as Record<string, unknown>,
|
|
669
|
+
resolved.clientOrg as
|
|
670
|
+
| { logo_url?: string | null; name?: string | null }
|
|
671
|
+
| undefined
|
|
672
|
+
| null,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
return pageData;
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
const routeRenderMode: "public" | "portals" | "utility" =
|
|
679
|
+
routeConfig.render_mode === "portal"
|
|
680
|
+
? "portals"
|
|
681
|
+
: routeConfig.render_mode === "public"
|
|
682
|
+
? "public"
|
|
683
|
+
: routeConfig.render_mode === "utility"
|
|
684
|
+
? "utility"
|
|
685
|
+
: renderMode;
|
|
686
|
+
|
|
687
|
+
const handler = createPageHandler({
|
|
688
|
+
components,
|
|
689
|
+
providers: routeProviders,
|
|
690
|
+
renderMode: routeRenderMode,
|
|
691
|
+
loadData: loadData as never,
|
|
692
|
+
portalHomeRedirect: servesPortalCatchall,
|
|
693
|
+
serviceSlug,
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
app.get(pattern, async (c) => {
|
|
697
|
+
const routeOrgId = c.req.param("orgId");
|
|
698
|
+
return bindPageHandler(
|
|
699
|
+
c,
|
|
700
|
+
handler as never,
|
|
701
|
+
authMode,
|
|
702
|
+
serviceSlug,
|
|
703
|
+
`registry:${routeRow.slug}`,
|
|
704
|
+
routeOrgId,
|
|
705
|
+
);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
registeredPatterns.push(pattern);
|
|
709
|
+
log.info(
|
|
710
|
+
`[scala-install:${serviceSlug}] registered registry page route "${pattern}" → resource_loader "${loaderSlug}"`,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return registeredPatterns;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Block-coverage guard (protective-layers-fail-explicitly). A page that renders
|
|
719
|
+
* a block whose component is NOT registered — because its package is missing
|
|
720
|
+
* from service_install.config.block_packages — silently renders BLANK. That is
|
|
721
|
+
* the exact failure behind the 2026-06-04 scala-sign regression (sign-in-block
|
|
722
|
+
* dropped from block_packages → the gate + signature fields vanished) which left
|
|
723
|
+
* only a benign boot WARN. This turns that silent blank into a loud,
|
|
724
|
+
* GlitchTip-visible ERROR naming the page + block, throttled to once per
|
|
725
|
+
* (service, block type) so a hot path can never spam it.
|
|
726
|
+
*/
|
|
727
|
+
const uncoveredBlocksLogged = new Set<string>();
|
|
728
|
+
|
|
729
|
+
function collectBlockTypes(node: unknown, out: Set<string>): void {
|
|
730
|
+
if (Array.isArray(node)) {
|
|
731
|
+
for (const child of node) collectBlockTypes(child, out);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (node && typeof node === "object") {
|
|
735
|
+
const obj = node as Record<string, unknown>;
|
|
736
|
+
if (
|
|
737
|
+
typeof obj.type === "string" &&
|
|
738
|
+
obj.props &&
|
|
739
|
+
typeof obj.props === "object"
|
|
740
|
+
) {
|
|
741
|
+
out.add(obj.type);
|
|
742
|
+
}
|
|
743
|
+
for (const value of Object.values(obj)) collectBlockTypes(value, out);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function warnUncoveredBlocks(
|
|
748
|
+
pageData: unknown,
|
|
749
|
+
components: Record<string, RendererComponentEntry>,
|
|
750
|
+
serviceSlug: string,
|
|
751
|
+
path: unknown,
|
|
752
|
+
log: ReturnType<typeof getAppLogger>,
|
|
753
|
+
): void {
|
|
754
|
+
const used = new Set<string>();
|
|
755
|
+
collectBlockTypes(pageData, used);
|
|
756
|
+
for (const type of used) {
|
|
757
|
+
if (type in components) continue;
|
|
758
|
+
const key = `${serviceSlug}:${type}`;
|
|
759
|
+
if (uncoveredBlocksLogged.has(key)) continue;
|
|
760
|
+
uncoveredBlocksLogged.add(key);
|
|
761
|
+
log.error(
|
|
762
|
+
`[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)`,
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
}
|