@teamscala/service-boot 0.23.524 → 0.23.525
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 -9
- 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 +289 -0
- 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/graph-cache.ts +0 -17
- 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 +0 -57
- 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 -75
- 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/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
|
@@ -6,11 +6,6 @@
|
|
|
6
6
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
7
7
|
import type { Data } from "@teamscala/puck-boundary/data";
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* The canonical identity from the generated boundary (the platform's one
|
|
11
|
-
* declaration site for framework shapes) — never a hand-spelled structural
|
|
12
|
-
* duplicate of it.
|
|
13
|
-
*/
|
|
14
9
|
export type PuckData = Data;
|
|
15
10
|
|
|
16
11
|
export const EMPTY_PUCK_DATA: PuckData = { root: { props: {} }, content: [] };
|
|
@@ -86,7 +81,6 @@ export function warnUnresolvedRegionSlot(slot: string): void {
|
|
|
86
81
|
);
|
|
87
82
|
}
|
|
88
83
|
|
|
89
|
-
/** The resolved brand tokens every branch reads (the old file's shared prologue). */
|
|
90
84
|
export function readBrandTokens(resolved: Record<string, unknown>): {
|
|
91
85
|
brand: Record<string, unknown>;
|
|
92
86
|
primaryColor: string;
|
|
@@ -3,23 +3,6 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Prefer segment 1; fall back to segment 0 only when segment 1 is not a
|
|
8
|
-
* UUID (the single-UUID shape, which is live and supported — NOT
|
|
9
|
-
* transitional). The fallback is deliberately kept
|
|
10
|
-
* UNCHANGED-BEHAVIOUR rather than "fixed": a UUID in segment 0 is
|
|
11
|
-
* genuinely ambiguous — of the 40 UUID-first page paths in builda_pages,
|
|
12
|
-
* ZERO are websites and ZERO are organisations, so segment 0 can also be
|
|
13
|
-
* an unrelated record id. Position alone cannot resolve that; only
|
|
14
|
-
* validating the id against organisation_profile can, and that is a DB
|
|
15
|
-
* read this hot prefetch path should not take without caching
|
|
16
|
-
* (`hot-path-resolutions-are-cached`). Preferring segment 1 is strictly
|
|
17
|
-
* better for the canonical shape and no worse for any other. Reading
|
|
18
|
-
* segment 0 first captured the WEBSITE id and passed it as an
|
|
19
|
-
* organisation id, which failed silently because both are well-formed
|
|
20
|
-
* UUIDs: the prefetch simply warmed a session against the wrong tenant
|
|
21
|
-
* (found 2026-07-21).
|
|
22
|
-
*/
|
|
23
6
|
|
|
24
7
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
25
8
|
|
|
@@ -2,17 +2,6 @@
|
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
|
-
/**
|
|
6
|
-
* Resolve the `{{approvalEmail}}` placeholder in a proposal page's button props
|
|
7
|
-
* (e.g. the Approve CTA) in place. Routing: the organisation's registered
|
|
8
|
-
* company email FIRST (organisation_profile.contact_email, already resolved
|
|
9
|
-
* into pageData.brand.contactEmail); the proposal creator's user email only as
|
|
10
|
-
* a fallback when the org has none — so a cloned proposal can never carry a
|
|
11
|
-
* stale hard-coded address. The creator lookup is null-safe and adds no user
|
|
12
|
-
* exposure: it resolves only where the in-process ORPC client exposes `user`
|
|
13
|
-
* (super-admin), degrading to the org email elsewhere. Called only for proposal
|
|
14
|
-
* pages; other registry pages are untouched.
|
|
15
|
-
*/
|
|
16
5
|
|
|
17
6
|
import { getServerClient } from "../page-server-orpc/server-client.ts";
|
|
18
7
|
|
|
@@ -3,20 +3,6 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Stash a proposal's connected partner logo/colour on `pageData` from the
|
|
8
|
-
* `partnerOrg` loader source (`crm_organisations` loaded by the proposal's
|
|
9
|
-
* `partner_id`, via the SAME proven ORPC loader path that loads the proposal
|
|
10
|
-
* itself). resolve-page-brand (service-runtime) prefers `pageData.partnerLogo`
|
|
11
|
-
* over any inline body-Hero prop and fans it into the shared `/header` so a
|
|
12
|
-
* `source: "partner"` OrgLogo fills automatically — the partner logo lives on
|
|
13
|
-
* the CRM record, never duplicated per proposal. No partner → partnerOrg is
|
|
14
|
-
* null → nothing set → the partner logo block renders blank. Reading the
|
|
15
|
-
* already-resolved loader value (not a second hand-rolled getServerClient call)
|
|
16
|
-
* is what makes this reliable: the earlier direct `crm_organisations.findFirst`
|
|
17
|
-
* with a Prisma-style object `select` resolved intermittently/never against the
|
|
18
|
-
* generated ORPC, leaving the header partner blank (2026-06-12).
|
|
19
|
-
*/
|
|
20
6
|
export function applyPartnerBrand(
|
|
21
7
|
pageData: Record<string, unknown>,
|
|
22
8
|
partnerOrg:
|
|
@@ -30,12 +16,6 @@ export function applyPartnerBrand(
|
|
|
30
16
|
pageData.partnerBrandColor = partnerOrg.brand_primary_color;
|
|
31
17
|
}
|
|
32
18
|
|
|
33
|
-
/**
|
|
34
|
-
* Auto-resolve the client's company logo from the `clientOrg` loader source
|
|
35
|
-
* (contact_id → company_id → crm_organisations.logo_url). Injects into the
|
|
36
|
-
* Hero block's clientLogo prop when it's empty — no manual logo URL needed
|
|
37
|
-
* per proposal when the contact's company profile has a logo.
|
|
38
|
-
*/
|
|
39
19
|
export function applyClientLogo(
|
|
40
20
|
pageData: Record<string, unknown>,
|
|
41
21
|
clientOrg:
|
|
@@ -88,12 +88,6 @@ export function publicShellPageData(
|
|
|
88
88
|
const overrideHeading = pickRootProp<string>(regions, "headingFont");
|
|
89
89
|
const overrideBody = pickRootProp<string>(regions, "bodyFont");
|
|
90
90
|
|
|
91
|
-
// The page's own screenMode, threaded the same way Branch 1 threads it
|
|
92
|
-
// (single-page-data.ts). Dropping it here is not neutral: build-page-tree
|
|
93
|
-
// falls back to `fullScreen: renderMode === "public" || "gpts"`, and for a
|
|
94
|
-
// shell page that resolves false — which wraps the body in a centred card
|
|
95
|
-
// and puts the site header INSIDE that card instead of across the top of
|
|
96
|
-
// the page. A page that declares "full-screen" was rendering as a form.
|
|
97
91
|
const screenMode = pickRootProp<string>(regions, "screenMode");
|
|
98
92
|
|
|
99
93
|
const pageName = (page.name as string) || null;
|
|
@@ -108,22 +102,11 @@ export function publicShellPageData(
|
|
|
108
102
|
return {
|
|
109
103
|
puckData: { root: { props: {} }, content: [] } as PuckData,
|
|
110
104
|
regions,
|
|
111
|
-
// Region-shell blocks (e.g. the header Navigation deriving the body's
|
|
112
|
-
// document sections) read the resolved entity graph from
|
|
113
|
-
// pageData.loaderData — the same surface Branch 1 exposes. Without it a
|
|
114
|
-
// header/footer block serverLoader gets ctx.loaderData=undefined and
|
|
115
|
-
// cannot see the body page, so a server-derived nav produced no items
|
|
116
|
-
// and the menu was client-only (vanished on stale tabs / narrow views).
|
|
117
105
|
loaderData: resolved,
|
|
118
106
|
website: {
|
|
119
107
|
slug: website.slug as string,
|
|
120
108
|
name: website.name as string,
|
|
121
109
|
organisation_id: website.organisation_id as string | null,
|
|
122
|
-
// header_settings carries the DB-driven header config (e.g.
|
|
123
|
-
// backdropBlur) the public shell applies — PublicTemplate reads
|
|
124
|
-
// pageData.website.header_settings.backdropBlur. Dropping it here
|
|
125
|
-
// silently kills the header blur even though the DB switch is set
|
|
126
|
-
// (the 2026-06-17 regression). Pass it through verbatim.
|
|
127
110
|
header_settings:
|
|
128
111
|
(website.header_settings as Record<string, unknown> | null) ?? null,
|
|
129
112
|
},
|
|
@@ -132,10 +115,6 @@ export function publicShellPageData(
|
|
|
132
115
|
null,
|
|
133
116
|
brand: {
|
|
134
117
|
primaryColor: overrideColor ?? primaryColor,
|
|
135
|
-
// Thread the org brand's dark surface (brand_background_color /
|
|
136
|
-
// brand_dark_color) when the page sets no explicit override — so
|
|
137
|
-
// dark-mode pages use the brand's real dark colour, not a
|
|
138
|
-
// primary-derived tint. Uses the org brand's real dark colour.
|
|
139
118
|
backgroundColor:
|
|
140
119
|
overrideBg ?? (brand.backgroundColor as string | null) ?? null,
|
|
141
120
|
lightSurface: (brand.lightSurface as string | null) ?? null,
|
|
@@ -159,11 +138,6 @@ export function publicShellPageData(
|
|
|
159
138
|
pageMetaTitle: bodyMetaTitle,
|
|
160
139
|
templateTitle: (meta.title as string) || undefined,
|
|
161
140
|
websiteName: (website.name as string) || undefined,
|
|
162
|
-
// The FRIENDLY name is what the public sees — the trading brand,
|
|
163
|
-
// not the legal entity. Reading companyName here put "GMG APAC"
|
|
164
|
-
// in the title of every launcha page, which the operator has
|
|
165
|
-
// corrected repeatedly; the friendly name is "launcha". Falls
|
|
166
|
-
// back to companyName only where no friendly name is set.
|
|
167
141
|
organisationName:
|
|
168
142
|
(brand.companyFriendlyName as string) ||
|
|
169
143
|
(brand.companyName as string) ||
|
|
@@ -61,8 +61,6 @@ function cloneForCache(
|
|
|
61
61
|
`[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}`,
|
|
62
62
|
);
|
|
63
63
|
try {
|
|
64
|
-
// JSON-clone preserves the data graph, dropping the functions and
|
|
65
|
-
// undefined a loader graph should not be carrying anyway.
|
|
66
64
|
return JSON.parse(JSON.stringify(graph)) as unknown;
|
|
67
65
|
} catch (err2) {
|
|
68
66
|
log.warn(
|
|
@@ -78,19 +76,7 @@ export async function resolveRequestGraph(
|
|
|
78
76
|
): Promise<Record<string, unknown>> {
|
|
79
77
|
const { pattern, routeSlug, routeConfig, loaderConfig, params, isStaticShell, serviceSlug, configFingerprint } = args;
|
|
80
78
|
|
|
81
|
-
// A bare root ("/") that declares a resource_type is a UUID-driven
|
|
82
|
-
// public site (proposals/forms/quiz) with no resource selected — it
|
|
83
|
-
// must render the branded holding/not-found page ("no <resource> at
|
|
84
|
-
// this link"), never fall through the shared public loader to the
|
|
85
|
-
// marketing site. PrimaryResourceMissingError is the existing signal
|
|
86
|
-
// the page binder maps to buildResourceNotFoundResponse. A "/" route
|
|
87
|
-
// WITHOUT a resource_type (e.g. scala-public's marketing root) is
|
|
88
|
-
// unaffected and still renders its content.
|
|
89
79
|
if (pattern === "/" && routeConfig.resource_type) {
|
|
90
|
-
// Status 200: the bare root is a valid "no <resource> selected"
|
|
91
|
-
// landing, not an error — and kind:ui services must answer GET /
|
|
92
|
-
// with 200 (verify-prod-persistent-slot). An invalid /:id keeps
|
|
93
|
-
// the default 404 (genuinely not found).
|
|
94
80
|
throw new PrimaryResourceMissingError(routeConfig.resource_type, 200);
|
|
95
81
|
}
|
|
96
82
|
const graphKey = `graph:${routeSlug}:${params.host}:${params.path}${configFingerprint ? `:${configFingerprint}` : ""}`;
|
|
@@ -111,13 +97,6 @@ export async function resolveRequestGraph(
|
|
|
111
97
|
>;
|
|
112
98
|
};
|
|
113
99
|
let resolved: Record<string, unknown>;
|
|
114
|
-
// SWR read over @teamscala/cache: fresh hit serves the cached graph, stale
|
|
115
|
-
// hit serves it AND fires ONE background refresh (refreshInBackground,
|
|
116
|
-
// single-flight via the cache's in-flight map), miss returns undefined.
|
|
117
|
-
// The refresh callback re-resolves + stores a clone; a primary-missing
|
|
118
|
-
// (dead-link) graph THROWS so the cache is not updated — a dead link must
|
|
119
|
-
// stay per-request so a just-created record appears immediately
|
|
120
|
-
// (refreshInBackground catches, leaves the stale entry, logs one line).
|
|
121
100
|
const cachedGraph = readCachedGraph(
|
|
122
101
|
graphCache,
|
|
123
102
|
graphKey,
|
|
@@ -133,16 +112,6 @@ export async function resolveRequestGraph(
|
|
|
133
112
|
`primary resource "${primaryType}" missing — skip cache update for ${graphKey}`,
|
|
134
113
|
);
|
|
135
114
|
}
|
|
136
|
-
// SAME DEFENSIVE CLONE AS THE COLD PATH. This used to call
|
|
137
|
-
// structuredClone bare, so a non-cloneable value in the loader
|
|
138
|
-
// graph threw on EVERY background refresh: refreshInBackground
|
|
139
|
-
// caught it, kept the stale entry, and logged one line — so the
|
|
140
|
-
// cache could never update, expired at staleTtlMs, and the next
|
|
141
|
-
// request re-resolved the whole graph in-request. Measured
|
|
142
|
-
// 2026-09-03 on the live proposal page: "The object can not be
|
|
143
|
-
// cloned" every ~13 minutes on the same graphKey, indefinitely.
|
|
144
|
-
// The cold path was hardened for exactly this and the refresh
|
|
145
|
-
// path was not — one defect, two clone sites, one protected.
|
|
146
115
|
const refreshed = cloneForCache(freshGraph, graphKey, serviceSlug);
|
|
147
116
|
if (refreshed === undefined) {
|
|
148
117
|
throw new Error(
|
|
@@ -154,47 +123,21 @@ export async function resolveRequestGraph(
|
|
|
154
123
|
),
|
|
155
124
|
);
|
|
156
125
|
if (cachedGraph !== undefined) {
|
|
157
|
-
// Clone-on-get: gateAgreementContent + block resolution MUTATE the
|
|
158
|
-
// graph in place, so each request gets its own copy.
|
|
159
126
|
resolved = structuredClone(cachedGraph) as Record<string, unknown>;
|
|
160
127
|
} else {
|
|
161
128
|
resolved = await resolveFromLoaderConfig(typedLoaderConfig, params);
|
|
162
129
|
const primaryMissing =
|
|
163
130
|
hasPrimarySource && primaryType && resolved[primaryType] == null;
|
|
164
131
|
if (!primaryMissing) {
|
|
165
|
-
// Defensive clone: structuredClone throws on non-cloneable loader
|
|
166
|
-
// graphs (functions/Proxies/Prisma instances). A swallowed throw
|
|
167
|
-
// here used to SILENTLY disable the graph cache for this key
|
|
168
|
-
// (fail-open-silent — forbidden by protective-layers-fail-explicitly),
|
|
169
|
-
// which left affected routes re-resolving the loader graph every
|
|
170
|
-
// request (measured ~1.2s on the projects utility shell). JSON-clone
|
|
171
|
-
// preserves the data graph (dropping functions/undefined a loader
|
|
172
|
-
// graph shouldn't carry) so the cache stays effective; both fallbacks
|
|
173
|
-
// are LOGGED so the offending non-cloneable source is observable +
|
|
174
|
-
// fixable, never silently disabling the cache again.
|
|
175
132
|
const cloned = cloneForCache(resolved, graphKey, serviceSlug);
|
|
176
133
|
if (cloned !== undefined) {
|
|
177
134
|
graphCache.set(graphKey, cloned);
|
|
178
135
|
}
|
|
179
136
|
}
|
|
180
137
|
}
|
|
181
|
-
// A route that declares resource_type is meaningless without that
|
|
182
|
-
// entity: a dead link (wrong id, deleted row) must 404 with the
|
|
183
|
-
// branded not-found page — never fall through to default content
|
|
184
|
-
// (the "Vision Toolbar" leak, operator-observed 2026-06-05).
|
|
185
138
|
if (hasPrimarySource && primaryType && resolved[primaryType] == null) {
|
|
186
139
|
throw new PrimaryResourceMissingError(primaryType);
|
|
187
140
|
}
|
|
188
|
-
// A CONTENT route (no resource_type) whose loader resolves a `page`
|
|
189
|
-
// body source must not synthesize an empty page for an unmatched path
|
|
190
|
-
// and serve a cacheable 200 — the generalized "Vision Toolbar" leak
|
|
191
|
-
// (operator-reported 5×: a deleted page, e.g. /roadmap, returned 200
|
|
192
|
-
// with an empty body instead of not-found). No page row → dead link →
|
|
193
|
-
// branded 404 floor. A page row that exists but has zero blocks → a
|
|
194
|
-
// content gap → the floor's empty-page surface (200). Both carry the
|
|
195
|
-
// "Take me home" action. The pure decision lives in content-page-floor.ts
|
|
196
|
-
// (unit-tested without the Hono/DB/graph machinery). Region-slot
|
|
197
|
-
// (utility/portal) shells without a `page` source are untouched.
|
|
198
141
|
const contentFloorError = decideContentPageFloor({
|
|
199
142
|
hasResourceType: !!primaryType,
|
|
200
143
|
hasPageContentSource: !!(loaderConfig as { sources?: { key: string }[] }).sources?.some(
|
|
@@ -3,67 +3,15 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { PageRouteConfig } from "../register-registry-page-route.ts";
|
|
7
6
|
import { runContentGate } from "../content-gate/gate.ts";
|
|
8
|
-
import
|
|
9
|
-
import { CONTENT_GATES, withholdsContent } from "../content-gate/catalog.ts";
|
|
10
|
-
import { injectResourceChatLauncher } from "../resource-chat-inject.ts";
|
|
7
|
+
import { CONTENT_GATES } from "../content-gate/catalog.ts";
|
|
11
8
|
|
|
12
9
|
export async function runContentGatesForPage(
|
|
13
10
|
resolved: Record<string, unknown>,
|
|
14
11
|
gateCookie: string | null,
|
|
15
12
|
gateNow: number,
|
|
16
|
-
assistant: PageRouteConfig["assistant"],
|
|
17
|
-
regionSlots: string[] | undefined,
|
|
18
13
|
): Promise<void> {
|
|
19
|
-
// EVERY REGISTERED GATE, IN CATALOG ORDER (content-gate/catalog.ts).
|
|
20
|
-
// This used to name proposalGateConfig, agreementGateConfig and
|
|
21
|
-
// gptGateConfig by hand, so a fourth gated resource type meant editing
|
|
22
|
-
// this file AND the second hand-written list in two-phase-boot.ts.
|
|
23
|
-
//
|
|
24
|
-
// SEQUENTIAL, NEVER PARALLEL. An authorized proposal viewer has their
|
|
25
|
-
// gate block removed from the resolved graph, and that removal is what
|
|
26
|
-
// makes the agreement gate find no resource and no-op on a page that
|
|
27
|
-
// carries both. Awaiting these together would race that dependency.
|
|
28
|
-
const gateOutcomes: ContentGateOutcome[] = [];
|
|
29
14
|
for (const entry of CONTENT_GATES) {
|
|
30
|
-
|
|
31
|
-
resolved,
|
|
32
|
-
gateCookie,
|
|
33
|
-
gateNow,
|
|
34
|
-
entry.gate,
|
|
35
|
-
);
|
|
36
|
-
// A seed-only gate (open capture — the public GPT identity gate)
|
|
37
|
-
// withholds nothing, so its verdict is not evidence about whether
|
|
38
|
-
// this viewer may see the page and must not reach the assistant
|
|
39
|
-
// decision below. See withholdsContent, which derives that from the
|
|
40
|
-
// access config rather than restating it here.
|
|
41
|
-
if (withholdsContent(entry)) gateOutcomes.push(outcome);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// THE ASSISTANT IS A ROW, AND THIS IS THE ONE PLACE IT IS APPLIED. It used to be a call inside each gate's onAuthorized plus an entry
|
|
45
|
-
// in the hardcoded gate sequence above, so a third resource type meant editing this file. Now any page declaring `assistant` gets
|
|
46
|
-
// one and no page without the key does.
|
|
47
|
-
//
|
|
48
|
-
// GATED PAGES ONLY WHEN AUTHORIZED. The launcher's runtime context is
|
|
49
|
-
// the whole document serialised, so injecting past a closed gate
|
|
50
|
-
// would hand withheld bytes to the chat. A page no gate governs is
|
|
51
|
-
// public and gets the assistant unconditionally -- that is a
|
|
52
|
-
// different state from an unauthorized viewer, which is why the gate
|
|
53
|
-
// reports `applies` separately from `authorized`.
|
|
54
|
-
if (assistant) {
|
|
55
|
-
const governing = gateOutcomes.filter((outcome) => outcome.applies);
|
|
56
|
-
const authorized = governing.find((outcome) => outcome.authorized);
|
|
57
|
-
if (governing.length === 0 || authorized) {
|
|
58
|
-
await injectResourceChatLauncher(resolved, authorized?.email ?? "", {
|
|
59
|
-
resourceKey: assistant.resource_key,
|
|
60
|
-
agentConfigSlug: assistant.agent_config_slug as never,
|
|
61
|
-
launchLabel: assistant.launch_label,
|
|
62
|
-
placeholder: assistant.placeholder,
|
|
63
|
-
// From the loader row, so the injector never has to guess
|
|
64
|
-
// which resolved keys hold rendered content.
|
|
65
|
-
regionSlots,
|
|
66
|
-
});
|
|
67
|
-
}
|
|
15
|
+
await runContentGate(resolved, gateCookie, gateNow, entry.gate);
|
|
68
16
|
}
|
|
69
17
|
}
|
|
@@ -6,16 +6,6 @@
|
|
|
6
6
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
7
7
|
import type { RendererComponentEntry } from "../types.ts";
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* A page that renders a block whose component is NOT registered — because
|
|
11
|
-
* its package is missing from service_install.config.block_packages —
|
|
12
|
-
* silently renders BLANK. That is the exact failure behind the 2026-06-04
|
|
13
|
-
* scala-sign regression (sign-in-block dropped from block_packages → the
|
|
14
|
-
* gate + signature fields vanished) which left only a benign boot WARN.
|
|
15
|
-
* This turns that silent blank into a loud, GlitchTip-visible ERROR naming
|
|
16
|
-
* the page + block, throttled to once per (service, block type) so a hot
|
|
17
|
-
* path can never spam it.
|
|
18
|
-
*/
|
|
19
9
|
const uncoveredBlocksLogged = new Set<string>();
|
|
20
10
|
|
|
21
11
|
function collectBlockTypes(node: unknown, out: Set<string>): void {
|
|
@@ -122,22 +122,11 @@ export async function registerRegistryPageRoute(
|
|
|
122
122
|
for (const routeRow of serviceRoutes) {
|
|
123
123
|
const routeConfig = routeRow.config;
|
|
124
124
|
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
125
|
const loaderSlug = routeConfig.loader_slug as ResourceLoaderKey;
|
|
131
126
|
const routeProviders = routeConfig.providers ?? baseProviders;
|
|
132
127
|
|
|
133
128
|
let loaderConfig: LoaderConfig | null = null;
|
|
134
129
|
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
130
|
loaderConfig = await loadRegistryConfig(
|
|
142
131
|
"resource_loader",
|
|
143
132
|
loaderSlug,
|
|
@@ -156,22 +145,12 @@ export async function registerRegistryPageRoute(
|
|
|
156
145
|
throw err;
|
|
157
146
|
}
|
|
158
147
|
|
|
159
|
-
// Gate for the static-shell graph cache (see module header).
|
|
160
148
|
const isStaticShell =
|
|
161
149
|
!routeConfig.resource_type &&
|
|
162
150
|
!pattern.includes(":") &&
|
|
163
151
|
!pattern.includes("*");
|
|
164
152
|
|
|
165
153
|
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
154
|
const liveLoaderConfig = await readLiveLoaderConfig(loaderSlug);
|
|
176
155
|
const url = new URL(request.url);
|
|
177
156
|
const params = extractUrlParams(pattern, url.pathname);
|
|
@@ -185,10 +164,6 @@ export async function registerRegistryPageRoute(
|
|
|
185
164
|
params,
|
|
186
165
|
isStaticShell,
|
|
187
166
|
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
167
|
configFingerprint: fingerprintLoaderConfig(liveLoaderConfig),
|
|
193
168
|
});
|
|
194
169
|
const { sessionUserId, sessionOrgId } = await stampSessionContext(
|
|
@@ -203,8 +178,6 @@ export async function registerRegistryPageRoute(
|
|
|
203
178
|
resolved,
|
|
204
179
|
gateCookie,
|
|
205
180
|
Date.now(),
|
|
206
|
-
routeConfig.assistant,
|
|
207
|
-
regionSlots,
|
|
208
181
|
);
|
|
209
182
|
const meta = buildMetaFromContext(routeConfig.meta_context, resolved);
|
|
210
183
|
await applySignInBrandOverride(resolved, request);
|
|
@@ -216,13 +189,6 @@ export async function registerRegistryPageRoute(
|
|
|
216
189
|
) {
|
|
217
190
|
meta.title = pageMetaTitle.trim();
|
|
218
191
|
}
|
|
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
192
|
let orgTitlePrefix = false;
|
|
227
193
|
try {
|
|
228
194
|
orgTitlePrefix = (
|
|
@@ -233,14 +199,8 @@ export async function registerRegistryPageRoute(
|
|
|
233
199
|
)
|
|
234
200
|
).enabled;
|
|
235
201
|
} catch {
|
|
236
|
-
// Flag row absent — legacy derivation.
|
|
237
202
|
}
|
|
238
203
|
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
204
|
const isFramedEmbed =
|
|
245
205
|
request.headers.get("sec-fetch-dest") === "iframe";
|
|
246
206
|
const themeOverride =
|
|
@@ -257,12 +217,6 @@ export async function registerRegistryPageRoute(
|
|
|
257
217
|
themeOverride,
|
|
258
218
|
orgTitlePrefix,
|
|
259
219
|
);
|
|
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
220
|
(pageData as Record<string, unknown>).pathParams = params;
|
|
267
221
|
const panelSlug = new URL(request.url).searchParams.get("panel");
|
|
268
222
|
await attachPanelPage(pageData as Record<string, unknown>, panelSlug);
|
|
@@ -284,39 +238,22 @@ export async function registerRegistryPageRoute(
|
|
|
284
238
|
await applyBrandOrgPath(
|
|
285
239
|
pageData as Record<string, unknown>,
|
|
286
240
|
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
241
|
routeConfig.brand_org_path,
|
|
290
242
|
params,
|
|
291
243
|
serviceSlug,
|
|
292
244
|
);
|
|
293
245
|
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
246
|
const aiOrgId = resolveAiOrgId(new URL(request.url).pathname);
|
|
305
247
|
void prefetchAiChatSession(resolved, sessionUserId, aiOrgId).catch((err) =>
|
|
306
248
|
log.warn(
|
|
307
249
|
`[scala-install:${serviceSlug}] background ai-chat prefetch failed: ${err instanceof Error ? err.message : err}`,
|
|
308
250
|
),
|
|
309
251
|
);
|
|
310
|
-
// Proposal pages: resolve the {{approvalEmail}} button token to the
|
|
311
|
-
// org's registered company email (creator email as fallback).
|
|
312
252
|
if (resolved.proposal) {
|
|
313
253
|
await applyApprovalEmailToken(
|
|
314
254
|
pageData as Record<string, unknown>,
|
|
315
255
|
resolved.proposal as { created_by?: string } | undefined,
|
|
316
256
|
);
|
|
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
257
|
applyPartnerBrand(
|
|
321
258
|
pageData as Record<string, unknown>,
|
|
322
259
|
resolved.partnerOrg as
|
|
@@ -324,10 +261,6 @@ export async function registerRegistryPageRoute(
|
|
|
324
261
|
| undefined
|
|
325
262
|
| null,
|
|
326
263
|
);
|
|
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
264
|
applyClientLogo(
|
|
332
265
|
pageData as Record<string, unknown>,
|
|
333
266
|
resolved.clientOrg as
|
|
@@ -353,10 +286,6 @@ export async function registerRegistryPageRoute(
|
|
|
353
286
|
providers: routeProviders,
|
|
354
287
|
renderMode: routeRenderMode,
|
|
355
288
|
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
289
|
portalHomeRedirect: portalScoped,
|
|
361
290
|
serviceSlug,
|
|
362
291
|
});
|
|
@@ -374,10 +303,6 @@ export async function registerRegistryPageRoute(
|
|
|
374
303
|
});
|
|
375
304
|
|
|
376
305
|
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
306
|
log.info(
|
|
382
307
|
`[scala-install:${serviceSlug}] registered registry page route "${pattern}" → resource_loader "${loaderSlug}" (boot-validated: ${describeCapturedLoaderConfig(loaderConfig)}; re-read live per request)`,
|
|
383
308
|
);
|
|
@@ -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);
|