@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
|
@@ -19,19 +19,3 @@ export async function readLiveLoaderConfig(
|
|
|
19
19
|
ResourceLoaderConfigSchema,
|
|
20
20
|
);
|
|
21
21
|
}
|
|
22
|
-
|
|
23
|
-
/** djb2 over the config's stable JSON — short, deterministic, SSR-safe. */
|
|
24
|
-
export function fingerprintLoaderConfig(config: unknown): string {
|
|
25
|
-
const json = JSON.stringify(config, sortedTopLevelKeys(config));
|
|
26
|
-
let hash = 5381;
|
|
27
|
-
for (let i = 0; i < json.length; i++) {
|
|
28
|
-
hash = ((hash << 5) + hash + json.charCodeAt(i)) | 0;
|
|
29
|
-
}
|
|
30
|
-
return (hash >>> 0).toString(36);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function sortedTopLevelKeys(config: unknown): string[] {
|
|
34
|
-
return config != null && typeof config === "object"
|
|
35
|
-
? Object.keys(config).sort()
|
|
36
|
-
: [];
|
|
37
|
-
}
|
|
@@ -2,25 +2,6 @@
|
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
|
-
/**
|
|
6
|
-
* THE ONE SUBJECT DERIVATION. What a page is ABOUT — the entity whose name the
|
|
7
|
-
* browser tab should carry.
|
|
8
|
-
*
|
|
9
|
-
* This existed as THREE hand-written `||` chains, one per render branch in
|
|
10
|
-
* resolve-page-data, and they had already diverged: two ordered form before
|
|
11
|
-
* proposal, the third ordered proposal first. So which resource named the page
|
|
12
|
-
* depended on which branch happened to render it, silently.
|
|
13
|
-
*
|
|
14
|
-
* The divergence is the cheap half of the cost. The expensive half is that a
|
|
15
|
-
* correction has to be applied three times to work, and applying it twice looks
|
|
16
|
-
* exactly like applying it correctly — measured 2026-08-17, when a fix to the
|
|
17
|
-
* quiz subject was shipped, installed and verified present in the bundle while
|
|
18
|
-
* the page kept its old title, because the branch that rendered it held the
|
|
19
|
-
* second copy.
|
|
20
|
-
*
|
|
21
|
-
* One chain, one precedence, one place to change. Adding a resource type is an
|
|
22
|
-
* edit here rather than a fourth branch in three files.
|
|
23
|
-
*/
|
|
24
5
|
|
|
25
6
|
import { interpolateMeta } from "@teamscala/resolver-engine/loader-config/resolve-from-loader-config";
|
|
26
7
|
|
|
@@ -32,11 +13,6 @@ export function buildMetaFromContext(
|
|
|
32
13
|
return interpolateMeta(metaContext as Record<string, unknown>, resolved);
|
|
33
14
|
}
|
|
34
15
|
|
|
35
|
-
/**
|
|
36
|
-
* The page's own meta_title row (loaderData.page first, resolved.page second)
|
|
37
|
-
* — used only on /sign-in, where the org's sign-in page title overrides the
|
|
38
|
-
* derived one. Moved from register-registry-page-route.ts.
|
|
39
|
-
*/
|
|
40
16
|
export function resolvePageMetaTitle(
|
|
41
17
|
resolved: Record<string, unknown>,
|
|
42
18
|
): unknown {
|
|
@@ -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) ||
|
|
@@ -5,104 +5,32 @@
|
|
|
5
5
|
|
|
6
6
|
import type { PageRouteConfig } from "../register-registry-page-route.ts";
|
|
7
7
|
import * as v from "valibot";
|
|
8
|
-
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
9
8
|
import type { ResourceLoaderConfigSchema } from "@teamscala/db-validation/registry-schemas/resource-loader";
|
|
10
9
|
import { resolveFromLoaderConfig } from "../page-server-loader-graph.ts";
|
|
11
10
|
import { decideContentPageFloor } from "../content-page-floor.ts";
|
|
12
11
|
import { PrimaryResourceMissingError } from "../resource-not-found.ts";
|
|
13
|
-
import {
|
|
14
|
-
staticShellGraphCache,
|
|
15
|
-
paramRouteGraphCache,
|
|
16
|
-
readCachedGraph,
|
|
17
|
-
} from "./graph-cache.ts";
|
|
18
12
|
|
|
19
13
|
export interface ResolveRequestGraphArgs {
|
|
20
14
|
pattern: string;
|
|
21
|
-
routeSlug: string;
|
|
22
15
|
routeConfig: PageRouteConfig;
|
|
23
16
|
loaderConfig: v.InferOutput<typeof ResourceLoaderConfigSchema>;
|
|
24
17
|
params: Record<string, string>;
|
|
25
|
-
isStaticShell: boolean;
|
|
26
|
-
serviceSlug: string;
|
|
27
|
-
/** Stable fingerprint of the LIVE loader config (fingerprintLoaderConfig).
|
|
28
|
-
* Appended to the graphKey so a registry row edit invalidates cached
|
|
29
|
-
* graphs for the route on the very next request — the graph is a
|
|
30
|
-
* function of the config, so the config belongs in the cache key. */
|
|
31
|
-
configFingerprint?: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Clone a loader graph for the cache, degrading loudly rather than silently.
|
|
36
|
-
*
|
|
37
|
-
* ONE CLONE, TWO CALLERS. The cold path and the background-refresh path both
|
|
38
|
-
* store a graph and both must survive a non-cloneable value in it — a function,
|
|
39
|
-
* a Proxy, a live client handle. They previously had SEPARATE implementations:
|
|
40
|
-
* the cold path carried this fallback ladder, the refresh path called
|
|
41
|
-
* structuredClone bare. So a graph that the cold path stored happily threw on
|
|
42
|
-
* every subsequent refresh, the cache never updated, and the entry aged out into
|
|
43
|
-
* a full in-request resolve. Two copies of one decision, and the copy nobody
|
|
44
|
-
* hardened is the one that fired.
|
|
45
|
-
*
|
|
46
|
-
* Returns undefined when the graph cannot be cloned at all; the caller then
|
|
47
|
-
* declines to cache that key. Both fallbacks log, so the offending source stays
|
|
48
|
-
* observable and fixable — never a silent fail-open
|
|
49
|
-
* (`protective-layers-fail-explicitly`).
|
|
50
|
-
*/
|
|
51
|
-
function cloneForCache(
|
|
52
|
-
graph: unknown,
|
|
53
|
-
graphKey: string,
|
|
54
|
-
serviceSlug: string,
|
|
55
|
-
): unknown | undefined {
|
|
56
|
-
const log = getAppLogger();
|
|
57
|
-
try {
|
|
58
|
-
return structuredClone(graph);
|
|
59
|
-
} catch (err) {
|
|
60
|
-
log.warn(
|
|
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
|
-
);
|
|
63
|
-
try {
|
|
64
|
-
// JSON-clone preserves the data graph, dropping the functions and
|
|
65
|
-
// undefined a loader graph should not be carrying anyway.
|
|
66
|
-
return JSON.parse(JSON.stringify(graph)) as unknown;
|
|
67
|
-
} catch (err2) {
|
|
68
|
-
log.warn(
|
|
69
|
-
`[scala-install:${serviceSlug}] graph-cache JSON-clone also failed for ${graphKey} — cache disabled for this key (observable): ${err2 instanceof Error ? err2.message : err2}`,
|
|
70
|
-
);
|
|
71
|
-
return undefined;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
18
|
}
|
|
75
19
|
|
|
76
20
|
export async function resolveRequestGraph(
|
|
77
21
|
args: ResolveRequestGraphArgs,
|
|
78
22
|
): Promise<Record<string, unknown>> {
|
|
79
|
-
const { pattern,
|
|
23
|
+
const { pattern, routeConfig, loaderConfig, params } = args;
|
|
80
24
|
|
|
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
25
|
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
26
|
throw new PrimaryResourceMissingError(routeConfig.resource_type, 200);
|
|
95
27
|
}
|
|
96
|
-
const graphKey = `graph:${routeSlug}:${params.host}:${params.path}${configFingerprint ? `:${configFingerprint}` : ""}`;
|
|
97
28
|
const primaryType = routeConfig.resource_type;
|
|
98
29
|
const hasPrimarySource =
|
|
99
30
|
!!primaryType &&
|
|
100
31
|
!!(loaderConfig as { sources?: { key: string }[] }).sources?.some(
|
|
101
32
|
(s) => s.key === primaryType,
|
|
102
33
|
);
|
|
103
|
-
const graphCache = isStaticShell
|
|
104
|
-
? staticShellGraphCache
|
|
105
|
-
: paramRouteGraphCache;
|
|
106
34
|
const typedLoaderConfig = loaderConfig as v.InferOutput<
|
|
107
35
|
typeof ResourceLoaderConfigSchema
|
|
108
36
|
> & {
|
|
@@ -110,91 +38,10 @@ export async function resolveRequestGraph(
|
|
|
110
38
|
v.InferOutput<typeof ResourceLoaderConfigSchema>["sources"]
|
|
111
39
|
>;
|
|
112
40
|
};
|
|
113
|
-
|
|
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
|
-
const cachedGraph = readCachedGraph(
|
|
122
|
-
graphCache,
|
|
123
|
-
graphKey,
|
|
124
|
-
() =>
|
|
125
|
-
resolveFromLoaderConfig(typedLoaderConfig, params).then(
|
|
126
|
-
(freshGraph) => {
|
|
127
|
-
const freshPrimaryMissing =
|
|
128
|
-
hasPrimarySource &&
|
|
129
|
-
primaryType &&
|
|
130
|
-
freshGraph[primaryType] == null;
|
|
131
|
-
if (freshPrimaryMissing) {
|
|
132
|
-
throw new Error(
|
|
133
|
-
`primary resource "${primaryType}" missing — skip cache update for ${graphKey}`,
|
|
134
|
-
);
|
|
135
|
-
}
|
|
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
|
-
const refreshed = cloneForCache(freshGraph, graphKey, serviceSlug);
|
|
147
|
-
if (refreshed === undefined) {
|
|
148
|
-
throw new Error(
|
|
149
|
-
`graph for ${graphKey} is not cloneable — skip cache update`,
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
return refreshed;
|
|
153
|
-
},
|
|
154
|
-
),
|
|
155
|
-
);
|
|
156
|
-
if (cachedGraph !== undefined) {
|
|
157
|
-
// Clone-on-get: gateAgreementContent + block resolution MUTATE the
|
|
158
|
-
// graph in place, so each request gets its own copy.
|
|
159
|
-
resolved = structuredClone(cachedGraph) as Record<string, unknown>;
|
|
160
|
-
} else {
|
|
161
|
-
resolved = await resolveFromLoaderConfig(typedLoaderConfig, params);
|
|
162
|
-
const primaryMissing =
|
|
163
|
-
hasPrimarySource && primaryType && resolved[primaryType] == null;
|
|
164
|
-
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
|
-
const cloned = cloneForCache(resolved, graphKey, serviceSlug);
|
|
176
|
-
if (cloned !== undefined) {
|
|
177
|
-
graphCache.set(graphKey, cloned);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
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).
|
|
41
|
+
const resolved = await resolveFromLoaderConfig(typedLoaderConfig, params);
|
|
185
42
|
if (hasPrimarySource && primaryType && resolved[primaryType] == null) {
|
|
186
43
|
throw new PrimaryResourceMissingError(primaryType);
|
|
187
44
|
}
|
|
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
45
|
const contentFloorError = decideContentPageFloor({
|
|
199
46
|
hasResourceType: !!primaryType,
|
|
200
47
|
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 {
|