@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.
Files changed (83) hide show
  1. package/package.json +1 -11
  2. package/src/agreement-gate/authorized-emails.ts +0 -7
  3. package/src/agreement-gate/placeholder-strip.ts +0 -2
  4. package/src/agreement-gate/settings.ts +0 -15
  5. package/src/agreement-gate.ts +0 -34
  6. package/src/attach-proposal-accept.ts +1 -1
  7. package/src/attach-proposal-magic.ts +1 -1
  8. package/src/auth/sign-out.ts +0 -2
  9. package/src/auth-gate/public-paths.ts +0 -86
  10. package/src/auth-gate.ts +0 -19
  11. package/src/bind-page-handler.ts +0 -76
  12. package/src/build-client-assets/hydrate-bundle.ts +0 -126
  13. package/src/build-client-assets/manifest.ts +0 -18
  14. package/src/build-client-assets/source-candidates.ts +0 -34
  15. package/src/build-client-assets/sourcemaps-upload.ts +0 -42
  16. package/src/build-client-assets/styles-build.ts +0 -50
  17. package/src/capability-slug.ts +0 -10
  18. package/src/compose-page-title.ts +0 -9
  19. package/src/content-gate/access.ts +0 -26
  20. package/src/content-gate/catalog.ts +0 -29
  21. package/src/content-gate/config-types.ts +3 -84
  22. package/src/content-gate/gate.ts +0 -20
  23. package/src/content-page-floor.ts +0 -11
  24. package/src/error-page-logo.ts +2 -12
  25. package/src/expand-roster.ts +0 -9
  26. package/src/gpt-gate.ts +1 -1
  27. package/src/hono-error-handler.ts +0 -4
  28. package/src/install-logging.ts +0 -32
  29. package/src/load-page-manifest.ts +0 -6
  30. package/src/load-service-install-row.ts +41 -4
  31. package/src/module-not-found.ts +0 -11
  32. package/src/mount-orpc-router.ts +1 -88
  33. package/src/mount-server-packages.ts +0 -29
  34. package/src/mutable-app-ref.ts +0 -11
  35. package/src/orpc-contract-rows.json +4102 -61
  36. package/src/orpc-openapi-document.ts +0 -6
  37. package/src/orpc-openapi-gate.ts +0 -10
  38. package/src/pg-boss-handler-registry.ts +0 -12
  39. package/src/pg-boss-mount.ts +0 -101
  40. package/src/proposal-gate.ts +1 -41
  41. package/src/quiz-gate.ts +1 -9
  42. package/src/register-page-server-client.ts +0 -53
  43. package/src/register-registry-page-route/ai-chat-prefetch.ts +0 -18
  44. package/src/register-registry-page-route/apply-page-branding.ts +0 -90
  45. package/src/register-registry-page-route/captured-loader-config.ts +0 -1
  46. package/src/register-registry-page-route/live-loader-config.ts +0 -16
  47. package/src/register-registry-page-route/meta-title.ts +0 -24
  48. package/src/register-registry-page-route/page-data-shared.ts +0 -6
  49. package/src/register-registry-page-route/prefetch-org-id.ts +0 -17
  50. package/src/register-registry-page-route/proposal-approval-email-token.ts +0 -11
  51. package/src/register-registry-page-route/proposal-brand-stamping.ts +0 -20
  52. package/src/register-registry-page-route/public-shell-page-data.ts +0 -26
  53. package/src/register-registry-page-route/resolve-request-graph.ts +2 -155
  54. package/src/register-registry-page-route/run-content-gates.ts +2 -54
  55. package/src/register-registry-page-route/warn-uncovered-blocks.ts +0 -10
  56. package/src/register-registry-page-route.ts +0 -85
  57. package/src/register-static-assets.ts +0 -45
  58. package/src/resolve-brand-favicon.ts +0 -9
  59. package/src/resolve-service-port.ts +0 -11
  60. package/src/resolve-theme-mode.ts +0 -8
  61. package/src/resolvers/app-header.ts +0 -1
  62. package/src/resolvers/fetch-org-brand.ts +0 -1
  63. package/src/resolvers/portal-route-paths.ts +0 -17
  64. package/src/resource-not-found.ts +0 -24
  65. package/src/select-service-routes.ts +0 -14
  66. package/src/theme-fonts-link.ts +0 -5
  67. package/src/two-phase-full-app.ts +0 -76
  68. package/src/two-phase-minimal-app.ts +1 -105
  69. package/src/types.ts +0 -14
  70. package/src/ui-service.ts +1 -42
  71. package/src/content-gate/gate-entry-marker.test.ts +0 -111
  72. package/src/content-gate/token.segmentation.test.ts +0 -76
  73. package/src/content-gate/token.test.ts +0 -65
  74. package/src/content-gate/token.ts +0 -197
  75. package/src/is-react-element-type.test.ts +0 -45
  76. package/src/is-react-element-type.ts +0 -10
  77. package/src/page-server-orpc/hub-mcp-client.ts +0 -14
  78. package/src/pg-boss-mount.test.ts +0 -101
  79. package/src/register-registry-page-route/graph-cache.test.ts +0 -81
  80. package/src/register-registry-page-route/graph-cache.ts +0 -71
  81. package/src/resource-chat-inject.page-assistant.test.ts +0 -145
  82. package/src/resource-chat-inject.test.ts +0 -240
  83. 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, routeSlug, routeConfig, loaderConfig, params, isStaticShell, serviceSlug, configFingerprint } = args;
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
- 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
- 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 type { ContentGateOutcome } from "../content-gate/gate.ts";
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
- const outcome = await runContentGate(
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 {