@teamscala/service-boot 0.23.524 → 0.23.526
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 +1 -11
- package/src/agreement-gate/authorized-emails.ts +0 -7
- package/src/agreement-gate/placeholder-strip.ts +0 -2
- package/src/agreement-gate/settings.ts +0 -15
- package/src/agreement-gate.ts +0 -34
- package/src/attach-proposal-accept.ts +1 -1
- package/src/attach-proposal-magic.ts +1 -1
- package/src/auth/sign-out.ts +0 -2
- package/src/auth-gate/public-paths.ts +0 -86
- package/src/auth-gate.ts +0 -19
- package/src/bind-page-handler.ts +0 -76
- package/src/build-client-assets/hydrate-bundle.ts +0 -126
- package/src/build-client-assets/manifest.ts +0 -18
- package/src/build-client-assets/source-candidates.ts +0 -34
- package/src/build-client-assets/sourcemaps-upload.ts +0 -42
- package/src/build-client-assets/styles-build.ts +0 -50
- package/src/capability-slug.ts +0 -10
- package/src/compose-page-title.ts +0 -9
- package/src/content-gate/access.ts +0 -26
- package/src/content-gate/catalog.ts +0 -29
- package/src/content-gate/config-types.ts +3 -84
- package/src/content-gate/gate.ts +0 -20
- package/src/content-page-floor.ts +0 -11
- package/src/error-page-logo.ts +2 -12
- package/src/expand-roster.ts +0 -9
- package/src/gpt-gate.ts +1 -1
- package/src/hono-error-handler.ts +0 -4
- package/src/install-logging.ts +0 -32
- package/src/load-page-manifest.ts +0 -6
- package/src/load-service-install-row.ts +41 -4
- package/src/module-not-found.ts +0 -11
- package/src/mount-orpc-router.ts +1 -88
- package/src/mount-server-packages.ts +0 -29
- package/src/mutable-app-ref.ts +0 -11
- package/src/orpc-contract-rows.json +4102 -61
- package/src/orpc-openapi-document.ts +0 -6
- package/src/orpc-openapi-gate.ts +0 -10
- package/src/pg-boss-handler-registry.ts +0 -12
- package/src/pg-boss-mount.ts +0 -101
- package/src/proposal-gate.ts +1 -41
- package/src/quiz-gate.ts +1 -9
- package/src/register-page-server-client.ts +0 -53
- package/src/register-registry-page-route/ai-chat-prefetch.ts +0 -18
- package/src/register-registry-page-route/apply-page-branding.ts +0 -90
- package/src/register-registry-page-route/captured-loader-config.ts +0 -1
- package/src/register-registry-page-route/live-loader-config.ts +0 -16
- package/src/register-registry-page-route/meta-title.ts +0 -24
- package/src/register-registry-page-route/page-data-shared.ts +0 -6
- package/src/register-registry-page-route/prefetch-org-id.ts +0 -17
- package/src/register-registry-page-route/proposal-approval-email-token.ts +0 -11
- package/src/register-registry-page-route/proposal-brand-stamping.ts +0 -20
- package/src/register-registry-page-route/public-shell-page-data.ts +0 -26
- package/src/register-registry-page-route/resolve-request-graph.ts +2 -155
- package/src/register-registry-page-route/run-content-gates.ts +2 -54
- package/src/register-registry-page-route/warn-uncovered-blocks.ts +0 -10
- package/src/register-registry-page-route.ts +0 -85
- package/src/register-static-assets.ts +0 -45
- package/src/resolve-brand-favicon.ts +0 -9
- package/src/resolve-service-port.ts +0 -11
- package/src/resolve-theme-mode.ts +0 -8
- package/src/resolvers/app-header.ts +0 -1
- package/src/resolvers/fetch-org-brand.ts +0 -1
- package/src/resolvers/portal-route-paths.ts +0 -17
- package/src/resource-not-found.ts +0 -24
- package/src/select-service-routes.ts +0 -14
- package/src/theme-fonts-link.ts +0 -5
- package/src/two-phase-full-app.ts +0 -76
- package/src/two-phase-minimal-app.ts +1 -105
- package/src/types.ts +0 -14
- package/src/ui-service.ts +1 -42
- package/src/content-gate/gate-entry-marker.test.ts +0 -111
- package/src/content-gate/token.segmentation.test.ts +0 -76
- package/src/content-gate/token.test.ts +0 -65
- package/src/content-gate/token.ts +0 -197
- package/src/is-react-element-type.test.ts +0 -45
- package/src/is-react-element-type.ts +0 -10
- package/src/page-server-orpc/hub-mcp-client.ts +0 -14
- package/src/pg-boss-mount.test.ts +0 -101
- package/src/register-registry-page-route/graph-cache.test.ts +0 -81
- package/src/register-registry-page-route/graph-cache.ts +0 -71
- package/src/resource-chat-inject.page-assistant.test.ts +0 -145
- package/src/resource-chat-inject.test.ts +0 -240
- package/src/resource-chat-inject.ts +0 -357
|
@@ -43,7 +43,6 @@ import {
|
|
|
43
43
|
} from "./register-registry-page-route/proposal-brand-stamping.ts";
|
|
44
44
|
import type { CreatePageHandler, RendererComponentEntry } from "./types.ts";
|
|
45
45
|
import {
|
|
46
|
-
fingerprintLoaderConfig,
|
|
47
46
|
readLiveLoaderConfig,
|
|
48
47
|
type LoaderConfig,
|
|
49
48
|
} from "./register-registry-page-route/live-loader-config.ts";
|
|
@@ -122,22 +121,11 @@ export async function registerRegistryPageRoute(
|
|
|
122
121
|
for (const routeRow of serviceRoutes) {
|
|
123
122
|
const routeConfig = routeRow.config;
|
|
124
123
|
const pattern = routeConfig.pattern;
|
|
125
|
-
// loader_slug is a page_route runtime value and resource_loader is runtime
|
|
126
|
-
// data: a row created after this build is exactly the live-edit case this
|
|
127
|
-
// module serves, so the generated slug union (a build-time snapshot) can
|
|
128
|
-
// only be asserted, never enforced, here. An unknown slug still fails
|
|
129
|
-
// closed below (RegistryConfigMissingError skips the route).
|
|
130
124
|
const loaderSlug = routeConfig.loader_slug as ResourceLoaderKey;
|
|
131
125
|
const routeProviders = routeConfig.providers ?? baseProviders;
|
|
132
126
|
|
|
133
127
|
let loaderConfig: LoaderConfig | null = null;
|
|
134
128
|
try {
|
|
135
|
-
// Boot-time warm + validate: a missing row skips the route (warn),
|
|
136
|
-
// an invalid row fails closed. resource_loader is NOT on the
|
|
137
|
-
// deploy-time bake list (constitution installer-and-runtime) — it
|
|
138
|
-
// is re-read per request via readLiveLoaderConfig below — so this
|
|
139
|
-
// boot read exists ONLY to catch a misconfigured row early, never
|
|
140
|
-
// as the source of truth for live rendering.
|
|
141
129
|
loaderConfig = await loadRegistryConfig(
|
|
142
130
|
"resource_loader",
|
|
143
131
|
loaderSlug,
|
|
@@ -156,22 +144,7 @@ export async function registerRegistryPageRoute(
|
|
|
156
144
|
throw err;
|
|
157
145
|
}
|
|
158
146
|
|
|
159
|
-
// Gate for the static-shell graph cache (see module header).
|
|
160
|
-
const isStaticShell =
|
|
161
|
-
!routeConfig.resource_type &&
|
|
162
|
-
!pattern.includes(":") &&
|
|
163
|
-
!pattern.includes("*");
|
|
164
|
-
|
|
165
147
|
const loadData = async (request: Request) => {
|
|
166
|
-
// resource_loader is runtime data, not deploy-time baked. Re-read it
|
|
167
|
-
// per request — loadRegistryConfig's SWR cache makes the fresh-window
|
|
168
|
-
// hit a memory read, not a DB round trip. Throws into the branded
|
|
169
|
-
// error path on a vanished or invalid row: the boot-time snapshot
|
|
170
|
-
// must NEVER silently outlive the row it captured. This is the fix
|
|
171
|
-
// for the loader-row-edits-look-like-they-don't-work defect (every
|
|
172
|
-
// edit used to need a service restart; the sign-page-with-brand
|
|
173
|
-
// `agreement` source added at 22:16 still hadn't reached
|
|
174
|
-
// __PAGE_DATA__.loaderData at 22:29 — 13 minutes, and never).
|
|
175
148
|
const liveLoaderConfig = await readLiveLoaderConfig(loaderSlug);
|
|
176
149
|
const url = new URL(request.url);
|
|
177
150
|
const params = extractUrlParams(pattern, url.pathname);
|
|
@@ -179,17 +152,9 @@ export async function registerRegistryPageRoute(
|
|
|
179
152
|
params.path = url.pathname || "/";
|
|
180
153
|
const resolved = await resolveRequestGraph({
|
|
181
154
|
pattern,
|
|
182
|
-
routeSlug: routeRow.slug,
|
|
183
155
|
routeConfig,
|
|
184
156
|
loaderConfig: liveLoaderConfig as never,
|
|
185
157
|
params,
|
|
186
|
-
isStaticShell,
|
|
187
|
-
serviceSlug,
|
|
188
|
-
// Folds the live config into the per-route graphKey so a row edit
|
|
189
|
-
// invalidates the cached graph immediately (the graph is a
|
|
190
|
-
// function of the config; without this the SWR graph cache
|
|
191
|
-
// would still hold the old shape until its fresh window lapsed).
|
|
192
|
-
configFingerprint: fingerprintLoaderConfig(liveLoaderConfig),
|
|
193
158
|
});
|
|
194
159
|
const { sessionUserId, sessionOrgId } = await stampSessionContext(
|
|
195
160
|
resolved,
|
|
@@ -203,8 +168,6 @@ export async function registerRegistryPageRoute(
|
|
|
203
168
|
resolved,
|
|
204
169
|
gateCookie,
|
|
205
170
|
Date.now(),
|
|
206
|
-
routeConfig.assistant,
|
|
207
|
-
regionSlots,
|
|
208
171
|
);
|
|
209
172
|
const meta = buildMetaFromContext(routeConfig.meta_context, resolved);
|
|
210
173
|
await applySignInBrandOverride(resolved, request);
|
|
@@ -216,13 +179,6 @@ export async function registerRegistryPageRoute(
|
|
|
216
179
|
) {
|
|
217
180
|
meta.title = pageMetaTitle.trim();
|
|
218
181
|
}
|
|
219
|
-
// Org-prefixed page titles (feature flag page-title-org-prefix, read
|
|
220
|
-
// LIVE per request — feature flags are never cached). The title prefix
|
|
221
|
-
// is the OWNING ORG'S name, never the internal website label
|
|
222
|
-
// ("Public Pages: <subject>" on a client-facing document was the
|
|
223
|
-
// defect; operator ruling 2026-08-17). Absent or unreadable flag ⇒
|
|
224
|
-
// legacy <website.name>: <subject> derivation — the switch fails toward
|
|
225
|
-
// the pre-flag behaviour, and flipping it back is one registry edit.
|
|
226
182
|
let orgTitlePrefix = false;
|
|
227
183
|
try {
|
|
228
184
|
orgTitlePrefix = (
|
|
@@ -233,14 +189,8 @@ export async function registerRegistryPageRoute(
|
|
|
233
189
|
)
|
|
234
190
|
).enabled;
|
|
235
191
|
} catch {
|
|
236
|
-
// Flag row absent — legacy derivation.
|
|
237
192
|
}
|
|
238
193
|
const themeParam = url.searchParams.get("theme");
|
|
239
|
-
// ?theme is the EMBED override — only honor it when this page is
|
|
240
|
-
// actually framed (a form/quiz in a GPT tool panel, sec-fetch-dest:
|
|
241
|
-
// iframe). A DIRECT visit carrying ?theme= (e.g. copied from a GPT
|
|
242
|
-
// embed URL) must NOT override the org's DB theme — what the visitor
|
|
243
|
-
// sees is org-driven, never URL-driven.
|
|
244
194
|
const isFramedEmbed =
|
|
245
195
|
request.headers.get("sec-fetch-dest") === "iframe";
|
|
246
196
|
const themeOverride =
|
|
@@ -257,12 +207,6 @@ export async function registerRegistryPageRoute(
|
|
|
257
207
|
themeOverride,
|
|
258
208
|
orgTitlePrefix,
|
|
259
209
|
);
|
|
260
|
-
// The matched route params (extractUrlParams + host/path) ride the
|
|
261
|
-
// per-request pageData so the SSR resolve ctx can scope PUBLIC
|
|
262
|
-
// block data per request — data_source loaders read ctx.pathParams
|
|
263
|
-
// (query-data-source); without them a public table would return
|
|
264
|
-
// every person's rows. Attached AFTER the graph cache (per-request,
|
|
265
|
-
// never cached with the graph).
|
|
266
210
|
(pageData as Record<string, unknown>).pathParams = params;
|
|
267
211
|
const panelSlug = new URL(request.url).searchParams.get("panel");
|
|
268
212
|
await attachPanelPage(pageData as Record<string, unknown>, panelSlug);
|
|
@@ -284,39 +228,22 @@ export async function registerRegistryPageRoute(
|
|
|
284
228
|
await applyBrandOrgPath(
|
|
285
229
|
pageData as Record<string, unknown>,
|
|
286
230
|
resolved,
|
|
287
|
-
// brand_org_path is the row schema's own field now — the derived
|
|
288
|
-
// type sees it, so the narrowing cast the mirror needed is gone.
|
|
289
231
|
routeConfig.brand_org_path,
|
|
290
232
|
params,
|
|
291
233
|
serviceSlug,
|
|
292
234
|
);
|
|
293
235
|
warnUncoveredBlocks(pageData, components, serviceSlug, params.path, log);
|
|
294
|
-
// Detached (NOT awaited into the response). prefetchAiChatSession -> createOrRestore is DB-heavy (~1.1s), per-user, and uncached, and was
|
|
295
|
-
// awaited on EVERY loadData — the dominant board loadMs cost (confirmed
|
|
296
|
-
// aiChatPrefetch=1145ms = the full loadMs). Fire it in the background so
|
|
297
|
-
// it still warms the server-side session (users who open chat find it
|
|
298
|
-
// warm via their live fetch) without blocking the page response. The SSR
|
|
299
|
-
// chat seed (pageData.aiChatSession) is sacrificed — chat opens with a
|
|
300
|
-
// live fetch instead of SSR-instant — the right trade: most loads never
|
|
301
|
-
// open chat, so eager-awaited prefetch was the waste. orgId is the
|
|
302
|
-
// URL-derived org (resolveAiOrgId) — see prefetch-org-id.ts for why
|
|
303
|
-
// segment 1 is preferred over segment 0.
|
|
304
236
|
const aiOrgId = resolveAiOrgId(new URL(request.url).pathname);
|
|
305
237
|
void prefetchAiChatSession(resolved, sessionUserId, aiOrgId).catch((err) =>
|
|
306
238
|
log.warn(
|
|
307
239
|
`[scala-install:${serviceSlug}] background ai-chat prefetch failed: ${err instanceof Error ? err.message : err}`,
|
|
308
240
|
),
|
|
309
241
|
);
|
|
310
|
-
// Proposal pages: resolve the {{approvalEmail}} button token to the
|
|
311
|
-
// org's registered company email (creator email as fallback).
|
|
312
242
|
if (resolved.proposal) {
|
|
313
243
|
await applyApprovalEmailToken(
|
|
314
244
|
pageData as Record<string, unknown>,
|
|
315
245
|
resolved.proposal as { created_by?: string } | undefined,
|
|
316
246
|
);
|
|
317
|
-
// Stash the partner logo from the partnerOrg loader source (CRM record
|
|
318
|
-
// loaded by partner_id) onto pageData.partnerLogo — header fills
|
|
319
|
-
// automatically, no inline body-Hero prop needed.
|
|
320
247
|
applyPartnerBrand(
|
|
321
248
|
pageData as Record<string, unknown>,
|
|
322
249
|
resolved.partnerOrg as
|
|
@@ -324,10 +251,6 @@ export async function registerRegistryPageRoute(
|
|
|
324
251
|
| undefined
|
|
325
252
|
| null,
|
|
326
253
|
);
|
|
327
|
-
// Auto-resolve the client's company logo from the clientOrg loader
|
|
328
|
-
// source (contact → company → crm_organisations.logo_url). Only
|
|
329
|
-
// injects when the Hero's clientLogo prop is empty — a manually set
|
|
330
|
-
// logo always wins.
|
|
331
254
|
applyClientLogo(
|
|
332
255
|
pageData as Record<string, unknown>,
|
|
333
256
|
resolved.clientOrg as
|
|
@@ -353,10 +276,6 @@ export async function registerRegistryPageRoute(
|
|
|
353
276
|
providers: routeProviders,
|
|
354
277
|
renderMode: routeRenderMode,
|
|
355
278
|
loadData: loadData as never,
|
|
356
|
-
// The portal home redirect follows the DECLARED portal_scoped service
|
|
357
|
-
// fact (see RegistryRouteArgs.portalScoped) — a signed-in user at "/" of
|
|
358
|
-
// an org-scoped portal belongs at /<websiteId>/<orgId>/dashboard; a
|
|
359
|
-
// flat-path service (the fleet dashboard) serves its real page at "/".
|
|
360
279
|
portalHomeRedirect: portalScoped,
|
|
361
280
|
serviceSlug,
|
|
362
281
|
});
|
|
@@ -374,10 +293,6 @@ export async function registerRegistryPageRoute(
|
|
|
374
293
|
});
|
|
375
294
|
|
|
376
295
|
registeredPatterns.push(pattern);
|
|
377
|
-
// Boot-validated snapshot (the LIVE read is per request — see
|
|
378
|
-
// readLiveLoaderConfig); name the shape so a misconfigured row is
|
|
379
|
-
// visible at boot, and note the live contract so "my loader edit
|
|
380
|
-
// didn't take effect" routes to the graph SWR window, not a restart.
|
|
381
296
|
log.info(
|
|
382
297
|
`[scala-install:${serviceSlug}] registered registry page route "${pattern}" → resource_loader "${loaderSlug}" (boot-validated: ${describeCapturedLoaderConfig(loaderConfig)}; re-read live per request)`,
|
|
383
298
|
);
|
|
@@ -14,10 +14,6 @@ import { isLoopbackHost } from "@teamscala/os/observability/loopback-host";
|
|
|
14
14
|
import { handleAssetProxy } from "./asset-proxy";
|
|
15
15
|
import { resolveBrandFaviconPath, warmBrandFaviconCache } from "./resolve-brand-favicon";
|
|
16
16
|
|
|
17
|
-
// Served ONLY when the org has no brand favicon, so the correct colour is the
|
|
18
|
-
// platform brand default rather than a literal. This changes the generic
|
|
19
|
-
// fallback mark from indigo to the canonical slate; any org with its own
|
|
20
|
-
// favicon is unaffected (resolveBrandFaviconPath wins before this is reached).
|
|
21
17
|
const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="${FALLBACK_PRIMARY_COLOR}"/><text x="16" y="23" text-anchor="middle" font-size="20" font-family="system-ui" fill="white">S</text></svg>`;
|
|
22
18
|
|
|
23
19
|
export async function registerStaticAssets(
|
|
@@ -25,9 +21,6 @@ export async function registerStaticAssets(
|
|
|
25
21
|
serviceSlug: string,
|
|
26
22
|
brandOrg?: string,
|
|
27
23
|
): Promise<void> {
|
|
28
|
-
// Warm the brand-favicon cache at boot — the S3 existence probe is a 6-21s
|
|
29
|
-
// cold call that otherwise stalls the first /favicon.ico after every
|
|
30
|
-
// restart. Fire-and-forget; never blocks boot or a request.
|
|
31
24
|
warmBrandFaviconCache(brandOrg);
|
|
32
25
|
|
|
33
26
|
app.get("/health", (c) =>
|
|
@@ -38,16 +31,6 @@ export async function registerStaticAssets(
|
|
|
38
31
|
}),
|
|
39
32
|
);
|
|
40
33
|
|
|
41
|
-
// Localhost-gated heap-attribution probe — the heapSize-vs-RSS split (JS heap
|
|
42
|
-
// vs native: OTel/Prisma-engine/buffers/compiled code) plus the top object
|
|
43
|
-
// types by count. Read-only, no secrets; gated to a localhost Host so it never
|
|
44
|
-
// answers over the public domain. Registered here (before auth + page routes)
|
|
45
|
-
// so it is reachable on every service kind, like /health
|
|
46
|
-
// (`verify-via-127.0.0.1`, sibling of `runtime-metrics-via-railway-cli`).
|
|
47
|
-
//
|
|
48
|
-
// The payload shape lives in @teamscala/os so standalone `Bun.serve` daemons
|
|
49
|
-
// (scala-guard) answer this probe identically — `scala-tools heap <slug>` must
|
|
50
|
-
// not mean different things depending on which process replied.
|
|
51
34
|
app.get("/_heap", async (c) => {
|
|
52
35
|
if (!isLoopbackHost(c.req.header("host"))) return c.json({ error: "localhost only" }, 403);
|
|
53
36
|
try {
|
|
@@ -61,16 +44,6 @@ export async function registerStaticAssets(
|
|
|
61
44
|
}
|
|
62
45
|
});
|
|
63
46
|
|
|
64
|
-
// Build-id: the content hash of the CURRENT client bundle, read FRESH from the asset manifest on every call (never a process-pinned copy) so a
|
|
65
|
-
// post-restart rebuild reports its new id immediately. The client self-heal (page-client/boot/build-self-heal) polls this on tab refocus; if it differs
|
|
66
|
-
// from the build the tab booted with, a new build shipped and the open tab is
|
|
67
|
-
// stale → it reloads. no-store HTML + immutable hashed assets already prevent
|
|
68
|
-
// staleness on every FRESH load; this is what closes the one remaining hole —
|
|
69
|
-
// a tab left OPEN across a deploy, holding old HTML pinned to old asset
|
|
70
|
-
// hashes, with nothing telling it to re-fetch (the "my tablet still shows the
|
|
71
|
-
// old broken page" class). The id derives from hydratePath exactly as the
|
|
72
|
-
// SSR-stamped window.__SCALA_BUILD__ does (render-page-html), so they can
|
|
73
|
-
// never structurally disagree and trigger a reload loop.
|
|
74
47
|
app.get("/api/build-id", async (c) => {
|
|
75
48
|
const path = join(process.cwd(), "dist", "client", "asset-manifest.json");
|
|
76
49
|
let build = "dev";
|
|
@@ -84,10 +57,6 @@ export async function registerStaticAssets(
|
|
|
84
57
|
return c.json({ build }, 200, { "cache-control": "no-store" });
|
|
85
58
|
});
|
|
86
59
|
|
|
87
|
-
// A branded host (service_install.config.brand_org) never serves the
|
|
88
|
-
// generic platform mark — both favicon paths resolve the owning org's
|
|
89
|
-
// icon from its asset store; the generic SVG remains only for hosts
|
|
90
|
-
// with no declared owner (multi-tenant / infra).
|
|
91
60
|
app.get("/favicon.svg", async (_c) => {
|
|
92
61
|
const brand = await resolveBrandFaviconPath(brandOrg);
|
|
93
62
|
if (brand)
|
|
@@ -105,18 +74,10 @@ export async function registerStaticAssets(
|
|
|
105
74
|
|
|
106
75
|
app.get("/favicon.ico", async (c) => {
|
|
107
76
|
const brand = await resolveBrandFaviconPath(brandOrg);
|
|
108
|
-
// 302 (not 301): the brand asset is row-driven and must stay
|
|
109
|
-
// re-resolvable — a permanent redirect would pin stale targets in
|
|
110
|
-
// browser caches, the exact failure this route exists to end.
|
|
111
|
-
// cache-control lets the browser cache the redirect (5min) so it
|
|
112
|
-
// doesn't re-hit the origin per page load — mirrors /favicon.svg.
|
|
113
77
|
c.header("cache-control", "public, max-age=300");
|
|
114
78
|
return c.redirect(brand ?? "/favicon.svg", 302);
|
|
115
79
|
});
|
|
116
80
|
|
|
117
|
-
// Same-origin S3 asset proxy: /api/assets/<key> → bucket. Public,
|
|
118
|
-
// immutable cache, no auth. Canonical URL form for every logo, favicon,
|
|
119
|
-
// avatar, uploaded asset — emitted by toAssetPath() in @teamscala/core-ui/theme.
|
|
120
81
|
app.get("/api/assets/*", (c) => handleAssetProxy(c.req.raw));
|
|
121
82
|
|
|
122
83
|
app.use("/assets/*", etag());
|
|
@@ -137,12 +98,6 @@ export async function registerStaticAssets(
|
|
|
137
98
|
return c.notFound();
|
|
138
99
|
});
|
|
139
100
|
|
|
140
|
-
// Extension auto-update files (.crx + updates.xml) served from ./ext/.
|
|
141
|
-
// Chrome's extension updater fetches updates.xml over plain HTTPS with no
|
|
142
|
-
// auth, so this route is registered before any auth gate and on every UI
|
|
143
|
-
// service. Files are served with short-lived cache so updates propagate
|
|
144
|
-
// promptly. The ./ext/ directory is gitignored and populated by the
|
|
145
|
-
// extension build pipeline.
|
|
146
101
|
app.get("/ext/*", async (c) => {
|
|
147
102
|
const filePath = join(
|
|
148
103
|
process.cwd(),
|
|
@@ -37,15 +37,6 @@ export async function resolveBrandFaviconPath(
|
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
* Eagerly resolve + cache the favicon at boot so the FIRST user request never
|
|
42
|
-
* pays the S3 existence-probe latency. The probe (`handleAssetProxy` →
|
|
43
|
-
* `s3.file(key).stat()`) is a COLD S3 call — the connection setup to the asset
|
|
44
|
-
* store takes 6s locally / up to 21s in prod, and the in-process cache resets
|
|
45
|
-
* on every restart (deploys, dep-sync). Without warming, the first
|
|
46
|
-
* /favicon.ico after each restart stalls for that duration (operator-reported
|
|
47
|
-
* 23s). Fire-and-forget — runs in the background during boot, never blocks.
|
|
48
|
-
*/
|
|
49
40
|
export function warmBrandFaviconCache(brandOrg: string | undefined): void {
|
|
50
41
|
if (!brandOrg) return;
|
|
51
42
|
void resolveBrandFaviconPath(brandOrg);
|
|
@@ -4,11 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
export interface PortResolution {
|
|
7
|
-
/** The port to bind. */
|
|
8
7
|
port: number;
|
|
9
|
-
/** Which source decided it — for the boot log, so the choice is auditable. */
|
|
10
8
|
source: "env" | "service_routing";
|
|
11
|
-
/** Set when PORT disagreed with the row; the caller logs it. */
|
|
12
9
|
conflict?: { envPort: number; declaredPort: number };
|
|
13
10
|
}
|
|
14
11
|
|
|
@@ -16,14 +13,6 @@ function isBindablePort(value: number | undefined): value is number {
|
|
|
16
13
|
return value !== undefined && Number.isFinite(value) && value > 0 && value <= 65535;
|
|
17
14
|
}
|
|
18
15
|
|
|
19
|
-
/**
|
|
20
|
-
* @param envPort parsed PORT env, or undefined when unset/unparseable
|
|
21
|
-
* @param declaredPort `service_routing.local_port`, or undefined when the
|
|
22
|
-
* service has no routing row (the Railway-only case)
|
|
23
|
-
* @param isRailway true when booting inside Railway (caller derives from
|
|
24
|
-
* `RAILWAY_ENVIRONMENT`); there the injected PORT is authoritative
|
|
25
|
-
* regardless of the row, because Railway healthchecks it
|
|
26
|
-
*/
|
|
27
16
|
export function resolveServicePort(
|
|
28
17
|
envPort: number | undefined,
|
|
29
18
|
declaredPort: number | undefined,
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
|
|
6
6
|
export interface ResolvedThemeMode {
|
|
7
7
|
theme: "light" | "dark";
|
|
8
|
-
/** True iff the mode came from the ?theme= embed override. */
|
|
9
8
|
embedded: boolean;
|
|
10
9
|
}
|
|
11
10
|
|
|
@@ -14,15 +13,8 @@ function asMode(value: unknown): "light" | "dark" | undefined {
|
|
|
14
13
|
}
|
|
15
14
|
|
|
16
15
|
export function resolveThemeMode(opts: {
|
|
17
|
-
/** Request URL (or its href) — read for the ?theme= embed override. */
|
|
18
16
|
url?: URL | string | null;
|
|
19
|
-
/**
|
|
20
|
-
* Explicit embed override, when the caller already extracted ?theme= from
|
|
21
|
-
* the URL upstream (e.g. a pure data-shaper that has no Request). Same
|
|
22
|
-
* effect and priority as the URL form.
|
|
23
|
-
*/
|
|
24
17
|
override?: unknown;
|
|
25
|
-
/** Theme candidates in priority order: rootTheme, brandTheme, websiteTheme. */
|
|
26
18
|
candidates?: Array<unknown>;
|
|
27
19
|
}): ResolvedThemeMode {
|
|
28
20
|
let override = asMode(opts.override);
|
|
@@ -6,7 +6,6 @@ import { fetchOrgBrand } from "@teamscala/resolver-engine/data-resolvers/fetch-o
|
|
|
6
6
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
7
7
|
import type { ResolverArgs } from "./portal-route-paths.ts";
|
|
8
8
|
|
|
9
|
-
/** A null brand renders a page with no logo, colours or name — so each failure names which of the three it was. */
|
|
10
9
|
export async function fetchOrgBrandWithFallback({ input, client }: ResolverArgs): Promise<unknown> {
|
|
11
10
|
const orgId = input.orgId as string | undefined;
|
|
12
11
|
const fallbackOrgId = input.fallbackOrgId as string | undefined;
|
|
@@ -2,23 +2,6 @@
|
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
|
-
/**
|
|
6
|
-
* Extract the organisation id from a portal-shaped callback/path — the single
|
|
7
|
-
* authority for "which segment is the org" so the sign-in brand resolver and the
|
|
8
|
-
* route resolver cannot drift on the answer (portal-branding-resolves-page-org-first).
|
|
9
|
-
*
|
|
10
|
-
* The canonical portal shape is `/<websiteId>/<orgId>/<path>` — the org is
|
|
11
|
-
* segment 1, NOT segment 0. Reading segment 0 fed the WEBSITE id into brand
|
|
12
|
-
* resolution, which resolved no org and fell back to the super-admin org — the
|
|
13
|
-
* exact cross-tenant mis-branding this exists to prevent (bind-page-handler.ts,
|
|
14
|
-
* found 2026-07-21).
|
|
15
|
-
*
|
|
16
|
-
* Single-UUID hosts keep segment 0 unchanged: scala-projects routes `/<orgId>/…`
|
|
17
|
-
* and proposal/form/quiz hosts route `/<resourceId>/…` (a non-org first segment
|
|
18
|
-
* that resolves to no brand and falls back as before — no regression, only the
|
|
19
|
-
* correct-org improvement for the two-UUID portal shape). Returns undefined when
|
|
20
|
-
* neither leading segment is a UUID.
|
|
21
|
-
*/
|
|
22
5
|
|
|
23
6
|
export interface ResolverArgs {
|
|
24
7
|
input: Record<string, unknown>;
|
|
@@ -15,16 +15,7 @@ const RESOURCE_LABELS: Record<string, string> = {
|
|
|
15
15
|
|
|
16
16
|
export class PrimaryResourceMissingError extends Error {
|
|
17
17
|
readonly resourceType: string;
|
|
18
|
-
// HTTP status for the holding page. 404 for a genuinely missing/invalid
|
|
19
|
-
// resource (a dead /:id link); 200 for the bare root of a UUID-driven site
|
|
20
|
-
// ("no <resource> selected") — the root is a valid landing, not an error,
|
|
21
|
-
// and kind:ui services must answer GET / with 200 (verify-prod-persistent-slot).
|
|
22
18
|
readonly httpStatus: number;
|
|
23
|
-
// A page ROW exists but has zero content blocks — a content gap, not a dead
|
|
24
|
-
// link (operator ruling 2026-07-21: "if the page is empty, it should have a
|
|
25
|
-
// Puck block that says this page is empty... and then take me home"). The
|
|
26
|
-
// floor renders the "This page is empty" surface (200) instead of the dead-
|
|
27
|
-
// link "doesn't exist" surface (404). Distinct from a dead link (no row).
|
|
28
19
|
readonly empty: boolean;
|
|
29
20
|
constructor(resourceType: string, httpStatus = 404, opts?: { empty?: boolean }) {
|
|
30
21
|
super(`primary resource "${resourceType}" not found`);
|
|
@@ -39,23 +30,10 @@ export function buildResourceNotFoundResponse(
|
|
|
39
30
|
resourceType: string,
|
|
40
31
|
logoUrl?: string | null,
|
|
41
32
|
status = 404,
|
|
42
|
-
// Optional 4th arg keeps the existing 3-arg call sites working while letting
|
|
43
|
-
// the page binder add a "Take me home" action (operator-requested) and the
|
|
44
|
-
// empty-page variant.
|
|
45
33
|
opts?: { homeUrl?: string; empty?: boolean },
|
|
46
34
|
): Response {
|
|
47
35
|
const label = RESOURCE_LABELS[resourceType] ?? "page";
|
|
48
|
-
// Single-owner hosts show the owner's wordmark; multi-tenant hosts
|
|
49
|
-
// (no brand_org — a dead id's org is unknowable) stay platform-neutral.
|
|
50
|
-
// The helper emits the inline data-URI src with reserved width+height
|
|
51
|
-
// (logos-inline-on-branded-server-pages).
|
|
52
36
|
const logoImg = brandLogoImgTag(logoUrl, { height: 40, width: 160 });
|
|
53
|
-
// Three surfaces share this one floor (resource-not-found-is-the-root-floor):
|
|
54
|
-
// 200 root — the BARE ROOT of a UUID-driven host ("no <resource> selected").
|
|
55
|
-
// 404 dead — a content path with NO page row (a deleted/moved page, e.g.
|
|
56
|
-
// /roadmap) OR a dead /:id: a link that genuinely doesn't resolve.
|
|
57
|
-
// 200 empty — a page ROW exists but has zero blocks (a content gap, not a
|
|
58
|
-
// dead link): "This page is empty", still take-me-home.
|
|
59
37
|
const isEmpty = opts?.empty === true;
|
|
60
38
|
const isRoot = status === 200 && !isEmpty;
|
|
61
39
|
const title = isEmpty ? "Nothing here yet" : isRoot ? `No ${label} here` : "Not found";
|
|
@@ -71,8 +49,6 @@ export function buildResourceNotFoundResponse(
|
|
|
71
49
|
sent to view it — there’s nothing to see at the bare address.`
|
|
72
50
|
: `The page you’re looking for may have been moved or removed, or the
|
|
73
51
|
link may be incomplete.`;
|
|
74
|
-
// "Take me home" — operator-requested action on every not-found/empty surface.
|
|
75
|
-
// Omitted on the bare-root landing (it IS home). Defaults to the host root.
|
|
76
52
|
const homeUrl = opts?.homeUrl ?? "/";
|
|
77
53
|
const homeLink = isRoot
|
|
78
54
|
? ""
|
|
@@ -5,17 +5,10 @@
|
|
|
5
5
|
|
|
6
6
|
import { toCapabilitySlug } from "./capability-slug.ts";
|
|
7
7
|
|
|
8
|
-
/** The shape this module needs from a loaded page_route row. */
|
|
9
8
|
export interface SelectableRoute {
|
|
10
9
|
config: { pattern: string; service: string };
|
|
11
10
|
}
|
|
12
11
|
|
|
13
|
-
/**
|
|
14
|
-
* Specificity rank for registration order. Hono matches by registration
|
|
15
|
-
* order, so `/*` (the portal catchall) registered before `/sign-in` would
|
|
16
|
-
* swallow the sign-in page and render it inside the PortalTemplate instead
|
|
17
|
-
* of the standalone PublicTemplate shell.
|
|
18
|
-
*/
|
|
19
12
|
function comparePatternSpecificity(a: string, b: string): number {
|
|
20
13
|
// Wildcard `/*` goes last.
|
|
21
14
|
if (a === "/*") return 1;
|
|
@@ -28,13 +21,6 @@ function comparePatternSpecificity(a: string, b: string): number {
|
|
|
28
21
|
return 0;
|
|
29
22
|
}
|
|
30
23
|
|
|
31
|
-
/**
|
|
32
|
-
* The page_route rows this service serves, in registration order.
|
|
33
|
-
*
|
|
34
|
-
* `serviceSlug` is the deployable identity of the calling lane; both sides
|
|
35
|
-
* of the join normalize through `toCapabilitySlug`, so a row may name either
|
|
36
|
-
* the capability or a deployable form and both lanes resolve the same set.
|
|
37
|
-
*/
|
|
38
24
|
export function selectServiceRoutes<T extends SelectableRoute>(
|
|
39
25
|
allRoutes: readonly T[],
|
|
40
26
|
serviceSlug: string,
|
package/src/theme-fonts-link.ts
CHANGED
|
@@ -9,15 +9,11 @@ interface ThemeFontsConfig {
|
|
|
9
9
|
fonts?: Record<string, string>;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
/** The primary family name of a CSS font stack: "Inter, sans-serif" →
|
|
13
|
-
* "Inter"; "Playfair Display" → "Playfair Display". */
|
|
14
12
|
export function primaryFontFamily(stack: string): string {
|
|
15
13
|
const first = stack.split(",")[0] ?? "";
|
|
16
14
|
return first.trim().replace(/^['"]|['"]$/g, "");
|
|
17
15
|
}
|
|
18
16
|
|
|
19
|
-
/** The declared theme families, deduplicated by primary family, in row
|
|
20
|
-
* order (body, heading, manuscript, mono…). Empty when the row is absent. */
|
|
21
17
|
export function themeFontFamilies(): string[] {
|
|
22
18
|
try {
|
|
23
19
|
const config = getEntryConfig<ThemeFontsConfig>("css_theme", "default");
|
|
@@ -37,7 +33,6 @@ export function themeFontFamilies(): string[] {
|
|
|
37
33
|
}
|
|
38
34
|
}
|
|
39
35
|
|
|
40
|
-
/** The <link> tag loading every declared theme family, or "" when none. */
|
|
41
36
|
export function themeFontsLink(): string {
|
|
42
37
|
const families = themeFontFamilies();
|
|
43
38
|
if (families.length === 0) return "";
|
|
@@ -3,52 +3,8 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Build the full app with hydration. This adds the expensive operations that
|
|
8
|
-
* were deferred from the minimal app:
|
|
9
|
-
* - Client asset building (Vite build)
|
|
10
|
-
* - Boot hooks (may have expensive initialization)
|
|
11
|
-
* - Scheduled jobs (can start later)
|
|
12
|
-
* - pg-boss (can start later)
|
|
13
|
-
*
|
|
14
|
-
* This runs in the background after the minimal app is already serving.
|
|
15
|
-
* NOTE: All routes (including page routes) are already registered in Phase 1,
|
|
16
|
-
* so we don't need to modify the app here. The app reference stays the same.
|
|
17
|
-
*/
|
|
18
6
|
|
|
19
|
-
// Client asset building (expensive: Vite build).
|
|
20
|
-
//
|
|
21
|
-
// ISOLATED, because a PRESENTATION failure must not take out BACKGROUND WORK.
|
|
22
|
-
// This was awaited unguarded, so a throw here aborted buildFullApp before
|
|
23
|
-
// mountPgBoss ran, and the caller's catch logged
|
|
24
|
-
// "Failed to build full app, continuing with minimal app" — which reads as a
|
|
25
|
-
// UI-only degradation while having silently killed every pg-boss handler on
|
|
26
|
-
// the service.
|
|
27
|
-
//
|
|
28
|
-
// Measured 2026-07-30: a stale @teamscala/portal-ui artifact still importing
|
|
29
|
-
// the retired @teamgbg scope threw ENOENT on a missing theme CSS file in
|
|
30
|
-
// scala-agents. Zero "scheduledJobs DONE" lines appeared for ~20 minutes
|
|
31
|
-
// across repeated boots, the pane-outbox drain never mounted, and the
|
|
32
|
-
// operator's WhatsApp assistant went silent — because of a stylesheet. The
|
|
33
|
-
// service stayed `online` and healthy throughout, so nothing surfaced it.
|
|
34
|
-
//
|
|
35
|
-
// The steps after this one do not depend on client assets: pg-boss needs the
|
|
36
|
-
// registry and the DB, not a bundle. Ordering it behind a Vite build was
|
|
37
|
-
// incidental, so the coupling was never intended.
|
|
38
7
|
|
|
39
|
-
// pg-boss (mount for UI services - wasn't mounted in minimal app)
|
|
40
|
-
//
|
|
41
|
-
// Wrapped for the SAME reason buildClientAssets above is, in the other
|
|
42
|
-
// direction: a background-work failure must not cost the page. Unwrapped,
|
|
43
|
-
// anything mountPgBoss throws propagates out of the full-app build, the
|
|
44
|
-
// caller falls back to the MINIMAL app, and the service serves 0 api routes
|
|
45
|
-
// with /health 503 — so the tunnel health check fails and the site is
|
|
46
|
-
// unreachable from outside while localhost answers in 0.1s.
|
|
47
|
-
//
|
|
48
|
-
// Measured 2026-08-31 on scala-projects: a deps refresh moved pg-boss to a
|
|
49
|
-
// build expecting a newer shared pgboss schema than the database carried, its
|
|
50
|
-
// migration threw, and an outage of every page followed from a scheduled-job
|
|
51
|
-
// dependency. Jobs are the degradable half; pages are not.
|
|
52
8
|
|
|
53
9
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
54
10
|
import type { Hono } from "hono";
|
|
@@ -78,9 +34,6 @@ export async function buildFullApp(
|
|
|
78
34
|
await buildClientAssets(serviceSlug);
|
|
79
35
|
log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets DONE`);
|
|
80
36
|
} catch (error) {
|
|
81
|
-
// Loud but non-fatal: the tombstone manifest written by buildClientAssets
|
|
82
|
-
// already makes SSR serve the prior bundle, so degrading here costs the
|
|
83
|
-
// new bundle, never the background work.
|
|
84
37
|
log.error(
|
|
85
38
|
`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets FAILED — continuing to scheduled jobs and pg-boss (a client-asset failure must not stop background work)`,
|
|
86
39
|
{ error: error instanceof Error ? error.message : String(error) },
|
|
@@ -107,23 +60,6 @@ export async function buildFullApp(
|
|
|
107
60
|
);
|
|
108
61
|
}
|
|
109
62
|
|
|
110
|
-
// The readiness flip is gated on a VERIFIED client build, never on "phase 2
|
|
111
|
-
// got this far". buildClientAssets's failure above is deliberately non-fatal
|
|
112
|
-
// for background work — but a deployment whose client build never ran (or
|
|
113
|
-
// whose tombstone names unbuilt files) must not flip ready and serve pages:
|
|
114
|
-
// every served asset URL would 404 while /health and the deploy report ok.
|
|
115
|
-
// Measured 2026-09-01: proposals.scala.business served a client-facing
|
|
116
|
-
// proposal unstyled while Railway reported SUCCESS, because the phase-1
|
|
117
|
-
// catch let boot continue past the build and the flip never checked the
|
|
118
|
-
// build's outcome (`a-component-may-not-report-a-state-it-has-not-verified`).
|
|
119
|
-
// The throw lands in ui-service's boot.client-build-failed catch, which
|
|
120
|
-
// keeps the minimal app: pages and /health stay 503 (cleanly unavailable)
|
|
121
|
-
// while the pg-boss jobs mounted above KEEP RUNNING. On Railway the
|
|
122
|
-
// healthcheck (healthcheckPath: /health) then fails and the previous good
|
|
123
|
-
// deployment keeps traffic — the deploy reports FAILED, never SUCCESS.
|
|
124
|
-
// readiness-gated-boot: a service is "fully-ready or cleanly-unavailable";
|
|
125
|
-
// a UI service that cannot serve its bundle is not ready
|
|
126
|
-
// (`liveness-by-capability-not-process-existence`).
|
|
127
63
|
const clientBuild = await verifyClientBuildAssets();
|
|
128
64
|
if (!clientBuild.ok) {
|
|
129
65
|
throw new Error(
|
|
@@ -139,20 +75,12 @@ export async function buildFullApp(
|
|
|
139
75
|
`${Object.keys(components).length} component(s)`,
|
|
140
76
|
);
|
|
141
77
|
|
|
142
|
-
// Set the real page handler so the dynamic handler in Phase 1 can delegate to it.
|
|
143
|
-
// Client-only services (render_mode=client) have no SSR page handler — skip
|
|
144
|
-
// (they serve via the client bundle, never SSR). Guarded so undefined is a
|
|
145
|
-
// no-op rather than a crash.
|
|
146
78
|
if (deps.createPageHandler) {
|
|
147
79
|
setPageHandler(deps.createPageHandler);
|
|
148
80
|
log.info(
|
|
149
81
|
`[two-phase-boot:${serviceSlug}] Real page handler registered - page routes now delegate to full app`,
|
|
150
82
|
);
|
|
151
83
|
} else {
|
|
152
|
-
// The full app IS built — this service simply never renders server-side,
|
|
153
|
-
// so readiness is flipped directly rather than as a side effect of
|
|
154
|
-
// registering an SSR handler that will never exist. Without this a
|
|
155
|
-
// client-only service is permanently un-ready (see markFullAppReady).
|
|
156
84
|
markFullAppReady();
|
|
157
85
|
log.info(
|
|
158
86
|
`[two-phase-boot:${serviceSlug}] Client-only mode (render_mode=client) — no SSR page handler registered`,
|
|
@@ -172,10 +100,6 @@ export async function buildFullApp(
|
|
|
172
100
|
};
|
|
173
101
|
}
|
|
174
102
|
|
|
175
|
-
/**
|
|
176
|
-
* Swap the minimal app for the full app. This atomically replaces the
|
|
177
|
-
* app reference that Bun.serve is using.
|
|
178
|
-
*/
|
|
179
103
|
export function swapToFullApp(fullApp: Hono, serviceSlug: string): void {
|
|
180
104
|
const oldLabel = swapApp(fullApp, "full");
|
|
181
105
|
const log = getAppLogger();
|