@teamscala/service-boot 0.23.430 → 0.23.432
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 +12 -19
- package/src/agreement-gate/signing-shell.ts +19 -15
- package/src/attach-proposal-accept.ts +5 -2
- package/src/auth-gate/public-paths.ts +7 -16
- package/src/auth-gate.ts +54 -42
- package/src/bind-page-handler.ts +20 -32
- package/src/build-client-assets/build-lock.ts +15 -6
- package/src/build-client-assets/hashed-output.ts +3 -1
- package/src/build-client-assets/hydrate-bundle.ts +59 -57
- package/src/build-client-assets/input-fingerprint.ts +50 -57
- package/src/build-client-assets/input-hashing.ts +2 -4
- package/src/build-client-assets/manifest.ts +8 -11
- package/src/build-client-assets/source-candidates.ts +8 -16
- package/src/build-client-assets/sourcemaps-upload.ts +17 -10
- package/src/build-client-assets/styles-build.ts +17 -19
- package/src/build-client-assets.test.ts +2 -2
- package/src/build-client-assets.ts +16 -22
- package/src/content-gate/access.ts +5 -2
- package/src/content-gate/catalog.ts +26 -18
- package/src/content-gate/gate.ts +7 -3
- package/src/discover-blocks.ts +5 -7
- package/src/fn-routers/agreement-certificate.ts +1 -1
- package/src/fn-routers/agreement-send-router.ts +5 -4
- package/src/fn-routers/agreement-sign-side-effects.ts +27 -22
- package/src/fn-routers/assessment-report-email.ts +3 -2
- package/src/fn-routers/auth-router.ts +20 -21
- package/src/fn-routers/chat-session-router-shared.ts +12 -3
- package/src/fn-routers/chat-session-router.ts +8 -6
- package/src/fn-routers/conversation-ownership.ts +3 -2
- package/src/fn-routers/messaging-whatsapp-router.test.ts +3 -1
- package/src/fn-routers/messaging-whatsapp-router.ts +17 -3
- package/src/fn-routers/persist-chat-messages.ts +6 -4
- package/src/fn-routers/proposal-send-router.ts +4 -3
- package/src/fn-routers/public-agreement-router.ts +5 -4
- package/src/fn-routers/public-assessment-router.ts +5 -4
- package/src/fn-routers/public-gpt-session-router.ts +17 -12
- package/src/fn-routers/user-preferences-router.ts +6 -15
- package/src/fn-routers/work-items-router.ts +21 -17
- package/src/handlers/platform-fault-escalation/delivery.ts +36 -0
- package/src/handlers/platform-fault-escalation/orpc-client.ts +38 -18
- package/src/handlers/platform-fault-escalation-logic.ts +26 -5
- package/src/handlers/platform-fault-escalation.test.ts +67 -15
- package/src/handlers/platform-fault-escalation.ts +16 -0
- package/src/handlers/projects-digest.ts +1 -1
- package/src/handlers/scala-tools-currency-watch.test.ts +1 -5
- package/src/handlers/scala-tools-currency-watch.ts +23 -32
- package/src/handlers/singleton-tools-currency-watch.test.ts +1 -5
- package/src/handlers/singleton-tools-currency-watch.ts +27 -19
- package/src/handlers/upstream-issue-sync.ts +11 -4
- package/src/handlers/whatsapp-liveness.ts +14 -4
- package/src/install-harness.ts +1 -1
- package/src/install-logging.ts +1 -1
- package/src/mount-orpc-router.test.ts +56 -10
- package/src/mount-orpc-router.ts +23 -9
- package/src/mount-server-packages.ts +17 -15
- package/src/pg-boss-mount.ts +5 -12
- package/src/proposal-gate.ts +7 -2
- package/src/read-html-template.test.ts +12 -15
- package/src/read-html-template.ts +2 -1
- package/src/readiness-gate.ts +2 -1
- package/src/register-crash-handlers.ts +4 -8
- package/src/register-page-server-client.ts +15 -19
- package/src/register-registry-page-route/ai-chat-prefetch.ts +16 -12
- package/src/register-registry-page-route/apply-page-branding.ts +68 -56
- package/src/register-registry-page-route/graph-cache.ts +22 -18
- package/src/register-registry-page-route/meta-title.ts +24 -20
- package/src/register-registry-page-route/prefetch-org-id.ts +6 -2
- package/src/register-registry-page-route/proposal-approval-email-token.ts +5 -3
- package/src/register-registry-page-route/run-content-gates.ts +2 -4
- package/src/register-registry-page-route.ts +1 -2
- package/src/register-static-assets.ts +6 -5
- package/src/resolve-app-base-url.ts +12 -5
- package/src/resolvers/portal-route-paths.ts +15 -11
- package/src/resource-chat-inject.ts +84 -72
- package/src/resource-not-found.ts +23 -19
- package/src/scala-tools-currency-watchdog.ts +41 -12
- package/src/two-phase-full-app.ts +61 -29
- package/src/two-phase-minimal-app.ts +39 -31
- package/src/two-phase-readiness.ts +50 -38
- package/src/ui-service.ts +50 -38
- package/src/handlers/pane-outbox-drain.ts +0 -39
- package/src/handlers/prune-stale-data.ts +0 -110
|
@@ -12,6 +12,74 @@
|
|
|
12
12
|
* explicit parameters.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* /sign-in brand override: a shared org-scoped link 302→/sign-in for the
|
|
17
|
+
* unauthed crawler (WhatsApp), which saw the service's default website
|
|
18
|
+
* brand (the platform owner). The callbackURL carries the destination org
|
|
19
|
+
* — override resolved.brand (before the title/meta/og are built from it)
|
|
20
|
+
* so the page reflects the org the visitor is signing in to. Scoped to
|
|
21
|
+
* /sign-in; resource pages keep their own brand.
|
|
22
|
+
*
|
|
23
|
+
* The org is segment 1 of the canonical portal shape /<websiteId>/<orgId>/
|
|
24
|
+
* <path>, NOT segment 0 (the website id): reading segment 0 fed a website id
|
|
25
|
+
* into fetchOrganisationPageBranding, which resolved no org and left the
|
|
26
|
+
* default (super-admin) brand — the cross-tenant mis-branding this override
|
|
27
|
+
* exists to prevent (portal-branding-resolves-page-org-first, 2026-07-21).
|
|
28
|
+
* Shared helper so this and bind-page-handler cannot drift on the answer.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// Why: /sign-in brand override: a shared org-scoped link 302→/sign-…
|
|
32
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
33
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
34
|
+
/**
|
|
35
|
+
* brand-is-org-owned-full-scheme, reconciled with page-org-first
|
|
36
|
+
* (portal-branding-resolves-page-org-first): the session override now
|
|
37
|
+
* personalizes ONLY platform pages (page org === website owning-org).
|
|
38
|
+
* Tenant pages keep their resolved page-org brand — the page is
|
|
39
|
+
* authoritative, the menu switcher changes which tenant's pages you
|
|
40
|
+
* view. Anonymous/public (no session) keeps the resolved brand. Same
|
|
41
|
+
* fetchOrganisationPageBranding pattern as the /sign-in brand override.
|
|
42
|
+
*
|
|
43
|
+
* A PUBLIC PAGE IS NEVER PERSONALISED TO THE READER'S ORG. Personalising
|
|
44
|
+
* the brand is right for a portal — you are looking at your own
|
|
45
|
+
* workspace. It is wrong for a public document, which belongs to the org
|
|
46
|
+
* that PUBLISHED it and must look identical to every reader.
|
|
47
|
+
*
|
|
48
|
+
* Measured 2026-08-17: the Launcha Authority Blueprint, owned by GMG
|
|
49
|
+
* APAC, rendered correctly for an anonymous customer and in GBG Indo's
|
|
50
|
+
* navy with the scala logo for any signed-in staff member — so the two
|
|
51
|
+
* people checking it before it went to clients were the only two who
|
|
52
|
+
* could not see what a client sees. Silent, and inverted: the reviewers
|
|
53
|
+
* got the wrong page.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
// Why: brand-is-org-owned-full-scheme, reconciled with page-org-fir…
|
|
57
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
58
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
59
|
+
/**
|
|
60
|
+
* BRAND FROM THE ORG THAT OWNS THE RESOURCE, not the website's owner.
|
|
61
|
+
* A public resource page (an assessment report, a shared document) is
|
|
62
|
+
* hosted on a shared website but BELONGS to a tenant. resolvePageBrand
|
|
63
|
+
* reads pageData.orgId, which is the WEBSITE's org, so every tenant's
|
|
64
|
+
* report rendered the host org's logo and palette.
|
|
65
|
+
*
|
|
66
|
+
* Measured 2026-08-17: the Launcha Authority Blueprint is owned by GMG
|
|
67
|
+
* APAC (#FF4500, launcha logo) and rendered with GBG Indo's navy and
|
|
68
|
+
* scala mark, because both pages live on the shared "Public Pages"
|
|
69
|
+
* site. The loader had already resolved the right org — nothing
|
|
70
|
+
* consumed it.
|
|
71
|
+
*
|
|
72
|
+
* `brand_org_path` names where the owning org id lives in the resolved
|
|
73
|
+
* loader data (e.g. "quiz.organisation_id"), so which resource owns the
|
|
74
|
+
* brand is a ROW, never a per-service branch
|
|
75
|
+
* (`business-logic-is-rows-plus-executors`). Absent or unresolvable,
|
|
76
|
+
* the website's org stands — this can only ever narrow the brand to a
|
|
77
|
+
* more specific owner, never blank it.
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
// Why: BRAND FROM THE ORG THAT OWNS THE RESOURCE, not the website's…
|
|
81
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
82
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
15
83
|
import { fetchOrganisationPageBranding } from "@teamscala/db/fetch-organisation-page-branding";
|
|
16
84
|
import { getPlatformOrgId } from "@teamscala/db/registry/platform";
|
|
17
85
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
@@ -43,21 +111,6 @@ function spreadBranding(
|
|
|
43
111
|
};
|
|
44
112
|
}
|
|
45
113
|
|
|
46
|
-
/**
|
|
47
|
-
* /sign-in brand override: a shared org-scoped link 302→/sign-in for the
|
|
48
|
-
* unauthed crawler (WhatsApp), which saw the service's default website
|
|
49
|
-
* brand (the platform owner). The callbackURL carries the destination org
|
|
50
|
-
* — override resolved.brand (before the title/meta/og are built from it)
|
|
51
|
-
* so the page reflects the org the visitor is signing in to. Scoped to
|
|
52
|
-
* /sign-in; resource pages keep their own brand.
|
|
53
|
-
*
|
|
54
|
-
* The org is segment 1 of the canonical portal shape /<websiteId>/<orgId>/
|
|
55
|
-
* <path>, NOT segment 0 (the website id): reading segment 0 fed a website id
|
|
56
|
-
* into fetchOrganisationPageBranding, which resolved no org and left the
|
|
57
|
-
* default (super-admin) brand — the cross-tenant mis-branding this override
|
|
58
|
-
* exists to prevent (portal-branding-resolves-page-org-first, 2026-07-21).
|
|
59
|
-
* Shared helper so this and bind-page-handler cannot drift on the answer.
|
|
60
|
-
*/
|
|
61
114
|
export async function applySignInBrandOverride(
|
|
62
115
|
resolved: Record<string, unknown>,
|
|
63
116
|
request: Request,
|
|
@@ -86,27 +139,6 @@ export async function applySignInBrandOverride(
|
|
|
86
139
|
}
|
|
87
140
|
}
|
|
88
141
|
|
|
89
|
-
/**
|
|
90
|
-
* brand-is-org-owned-full-scheme, reconciled with page-org-first
|
|
91
|
-
* (portal-branding-resolves-page-org-first): the session override now
|
|
92
|
-
* personalizes ONLY platform pages (page org === website owning-org).
|
|
93
|
-
* Tenant pages keep their resolved page-org brand — the page is
|
|
94
|
-
* authoritative, the menu switcher changes which tenant's pages you
|
|
95
|
-
* view. Anonymous/public (no session) keeps the resolved brand. Same
|
|
96
|
-
* fetchOrganisationPageBranding pattern as the /sign-in brand override.
|
|
97
|
-
*
|
|
98
|
-
* A PUBLIC PAGE IS NEVER PERSONALISED TO THE READER'S ORG. Personalising
|
|
99
|
-
* the brand is right for a portal — you are looking at your own
|
|
100
|
-
* workspace. It is wrong for a public document, which belongs to the org
|
|
101
|
-
* that PUBLISHED it and must look identical to every reader.
|
|
102
|
-
*
|
|
103
|
-
* Measured 2026-08-17: the Launcha Authority Blueprint, owned by GMG
|
|
104
|
-
* APAC, rendered correctly for an anonymous customer and in GBG Indo's
|
|
105
|
-
* navy with the scala logo for any signed-in staff member — so the two
|
|
106
|
-
* people checking it before it went to clients were the only two who
|
|
107
|
-
* could not see what a client sees. Silent, and inverted: the reviewers
|
|
108
|
-
* got the wrong page.
|
|
109
|
-
*/
|
|
110
142
|
export async function applySessionOrgBranding(
|
|
111
143
|
pageData: Record<string, unknown>,
|
|
112
144
|
renderMode: string | undefined,
|
|
@@ -141,26 +173,6 @@ export async function applySessionOrgBranding(
|
|
|
141
173
|
}
|
|
142
174
|
}
|
|
143
175
|
|
|
144
|
-
/**
|
|
145
|
-
* BRAND FROM THE ORG THAT OWNS THE RESOURCE, not the website's owner.
|
|
146
|
-
* A public resource page (an assessment report, a shared document) is
|
|
147
|
-
* hosted on a shared website but BELONGS to a tenant. resolvePageBrand
|
|
148
|
-
* reads pageData.orgId, which is the WEBSITE's org, so every tenant's
|
|
149
|
-
* report rendered the host org's logo and palette.
|
|
150
|
-
*
|
|
151
|
-
* Measured 2026-08-17: the Launcha Authority Blueprint is owned by GMG
|
|
152
|
-
* APAC (#FF4500, launcha logo) and rendered with GBG Indo's navy and
|
|
153
|
-
* scala mark, because both pages live on the shared "Public Pages"
|
|
154
|
-
* site. The loader had already resolved the right org — nothing
|
|
155
|
-
* consumed it.
|
|
156
|
-
*
|
|
157
|
-
* `brand_org_path` names where the owning org id lives in the resolved
|
|
158
|
-
* loader data (e.g. "quiz.organisation_id"), so which resource owns the
|
|
159
|
-
* brand is a ROW, never a per-service branch
|
|
160
|
-
* (`business-logic-is-rows-plus-executors`). Absent or unresolvable,
|
|
161
|
-
* the website's org stands — this can only ever narrow the brand to a
|
|
162
|
-
* more specific owner, never blank it.
|
|
163
|
-
*/
|
|
164
176
|
export async function applyBrandOrgPath(
|
|
165
177
|
pageData: Record<string, unknown>,
|
|
166
178
|
resolved: Record<string, unknown>,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system ui-service-boot
|
|
3
|
-
* @status handwritten @indivisible-unit
|
|
3
|
+
* @status handwritten @indivisible-unit owner=unassigned reason=91 lines; pre-2026-08-27 exemption, unreviewed
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
6
|
* The two SWR loader-graph caches (static-shell + parameterised routes) for the
|
|
@@ -22,6 +22,27 @@
|
|
|
22
22
|
* so handing a cached tree out twice would ship request A's resolved data to
|
|
23
23
|
* request B (ssr-first-tick-parity). Clone-on-get stays local to the caller.
|
|
24
24
|
*/
|
|
25
|
+
/**
|
|
26
|
+
* Stale-while-revalidate read of a cached loader graph.
|
|
27
|
+
*
|
|
28
|
+
* - Fresh hit (within ttlMs): return the cached graph, no refresh.
|
|
29
|
+
* - Stale hit (within ttlMs + staleTtlMs): return the cached graph AND fire ONE
|
|
30
|
+
* background refresh via cache.refreshInBackground — single-flight (deduped
|
|
31
|
+
* against the cache's in-flight map), never rejects, leaves the stale entry in
|
|
32
|
+
* place on failure. The caller deep-clones the returned value before use.
|
|
33
|
+
* - Miss (past staleTtlMs, or never cached): return undefined — the caller
|
|
34
|
+
* resolves synchronously and cache.set()s the clone.
|
|
35
|
+
*
|
|
36
|
+
* The refresh callback must return a graph safe to store. To SKIP caching a
|
|
37
|
+
* refresh result (e.g. a primary-missing / dead-link graph that must stay
|
|
38
|
+
* per-request so a just-created record appears immediately), the callback THROWS
|
|
39
|
+
* — refreshInBackground catches it, leaves the stale entry, and logs one line
|
|
40
|
+
* (an observable dead-link signal, not a hard error).
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
// Why: Stale-while-revalidate read of a cached loader graph.
|
|
44
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
45
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
25
46
|
import { createCache } from "@teamscala/cache/create-cache";
|
|
26
47
|
|
|
27
48
|
// The structural surface readCachedGraph touches (getWithMeta + refreshInBackground).
|
|
@@ -61,23 +82,6 @@ export const paramRouteGraphCache = createCache<unknown>(
|
|
|
61
82
|
{ maxSize: 256, ttlMs: 15_000, staleTtlMs: 285_000 },
|
|
62
83
|
);
|
|
63
84
|
|
|
64
|
-
/**
|
|
65
|
-
* Stale-while-revalidate read of a cached loader graph.
|
|
66
|
-
*
|
|
67
|
-
* - Fresh hit (within ttlMs): return the cached graph, no refresh.
|
|
68
|
-
* - Stale hit (within ttlMs + staleTtlMs): return the cached graph AND fire ONE
|
|
69
|
-
* background refresh via cache.refreshInBackground — single-flight (deduped
|
|
70
|
-
* against the cache's in-flight map), never rejects, leaves the stale entry in
|
|
71
|
-
* place on failure. The caller deep-clones the returned value before use.
|
|
72
|
-
* - Miss (past staleTtlMs, or never cached): return undefined — the caller
|
|
73
|
-
* resolves synchronously and cache.set()s the clone.
|
|
74
|
-
*
|
|
75
|
-
* The refresh callback must return a graph safe to store. To SKIP caching a
|
|
76
|
-
* refresh result (e.g. a primary-missing / dead-link graph that must stay
|
|
77
|
-
* per-request so a just-created record appears immediately), the callback THROWS
|
|
78
|
-
* — refreshInBackground catches it, leaves the stale entry, and logs one line
|
|
79
|
-
* (an observable dead-link signal, not a hard error).
|
|
80
|
-
*/
|
|
81
85
|
export function readCachedGraph(
|
|
82
86
|
cache: GraphCache,
|
|
83
87
|
key: string,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system ui-service-boot
|
|
3
|
-
* @status handwritten @indivisible-unit
|
|
3
|
+
* @status handwritten @indivisible-unit owner=unassigned reason=82 lines; pre-2026-08-27 exemption, unreviewed
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
6
|
* Meta/title helpers for the route registrar: interpolate the route's
|
|
@@ -13,6 +13,29 @@
|
|
|
13
13
|
* by construction, dead on exactly the pages the type exists for (Valencia's
|
|
14
14
|
* finding). composePageTitle (+ the org-prefix flag) owns titles end to end.
|
|
15
15
|
*/
|
|
16
|
+
/**
|
|
17
|
+
* THE ONE SUBJECT DERIVATION. What a page is ABOUT — the entity whose name the
|
|
18
|
+
* browser tab should carry.
|
|
19
|
+
*
|
|
20
|
+
* This existed as THREE hand-written `||` chains, one per render branch in
|
|
21
|
+
* resolve-page-data, and they had already diverged: two ordered form before
|
|
22
|
+
* proposal, the third ordered proposal first. So which resource named the page
|
|
23
|
+
* depended on which branch happened to render it, silently.
|
|
24
|
+
*
|
|
25
|
+
* The divergence is the cheap half of the cost. The expensive half is that a
|
|
26
|
+
* correction has to be applied three times to work, and applying it twice looks
|
|
27
|
+
* exactly like applying it correctly — measured 2026-08-17, when a fix to the
|
|
28
|
+
* quiz subject was shipped, installed and verified present in the bundle while
|
|
29
|
+
* the page kept its old title, because the branch that rendered it held the
|
|
30
|
+
* second copy.
|
|
31
|
+
*
|
|
32
|
+
* One chain, one precedence, one place to change. Adding a resource type is an
|
|
33
|
+
* edit here rather than a fourth branch in three files.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
// Why: THE ONE SUBJECT DERIVATION. What a page is ABOUT — the entit…
|
|
37
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
38
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
16
39
|
import { interpolateMeta } from "../resolve-from-loader-config.ts";
|
|
17
40
|
|
|
18
41
|
export function buildMetaFromContext(
|
|
@@ -38,25 +61,6 @@ export function resolvePageMetaTitle(
|
|
|
38
61
|
);
|
|
39
62
|
}
|
|
40
63
|
|
|
41
|
-
/**
|
|
42
|
-
* THE ONE SUBJECT DERIVATION. What a page is ABOUT — the entity whose name the
|
|
43
|
-
* browser tab should carry.
|
|
44
|
-
*
|
|
45
|
-
* This existed as THREE hand-written `||` chains, one per render branch in
|
|
46
|
-
* resolve-page-data, and they had already diverged: two ordered form before
|
|
47
|
-
* proposal, the third ordered proposal first. So which resource named the page
|
|
48
|
-
* depended on which branch happened to render it, silently.
|
|
49
|
-
*
|
|
50
|
-
* The divergence is the cheap half of the cost. The expensive half is that a
|
|
51
|
-
* correction has to be applied three times to work, and applying it twice looks
|
|
52
|
-
* exactly like applying it correctly — measured 2026-08-17, when a fix to the
|
|
53
|
-
* quiz subject was shipped, installed and verified present in the bundle while
|
|
54
|
-
* the page kept its old title, because the branch that rendered it held the
|
|
55
|
-
* second copy.
|
|
56
|
-
*
|
|
57
|
-
* One chain, one precedence, one place to change. Adding a resource type is an
|
|
58
|
-
* edit here rather than a fourth branch in three files.
|
|
59
|
-
*/
|
|
60
64
|
export function resolveEntityTitle(resolved: Record<string, unknown>): string | undefined {
|
|
61
65
|
const pick = (key: string, ...fields: string[]): string | undefined => {
|
|
62
66
|
const row = resolved[key] as Record<string, unknown> | undefined;
|
|
@@ -9,8 +9,6 @@
|
|
|
9
9
|
* register-registry-page-route.ts.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
13
|
-
|
|
14
12
|
/**
|
|
15
13
|
* Prefer segment 1; fall back to segment 0 only when segment 1 is not a
|
|
16
14
|
* UUID (the single-UUID shape, which is live and supported — NOT
|
|
@@ -28,6 +26,12 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
|
28
26
|
* UUIDs: the prefetch simply warmed a session against the wrong tenant
|
|
29
27
|
* (found 2026-07-21).
|
|
30
28
|
*/
|
|
29
|
+
|
|
30
|
+
// Why: Prefer segment 1; fall back to segment 0 only when segment 1…
|
|
31
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
32
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
33
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
34
|
+
|
|
31
35
|
export function resolveAiOrgId(pathname: string): string | undefined {
|
|
32
36
|
const pathSegments = pathname.split("/").filter(Boolean);
|
|
33
37
|
return UUID_RE.test(pathSegments[1] ?? "")
|
|
@@ -12,9 +12,6 @@
|
|
|
12
12
|
* stamping that touch no network. Sharing a file made an I/O-bound resolver
|
|
13
13
|
* look like a pure formatter.
|
|
14
14
|
*/
|
|
15
|
-
import { getServerClient } from "../page-server-orpc/server-client.ts";
|
|
16
|
-
|
|
17
|
-
|
|
18
15
|
/**
|
|
19
16
|
* Resolve the `{{approvalEmail}}` placeholder in a proposal page's button props
|
|
20
17
|
* (e.g. the Approve CTA) in place. Routing: the organisation's registered
|
|
@@ -27,6 +24,11 @@ import { getServerClient } from "../page-server-orpc/server-client.ts";
|
|
|
27
24
|
* pages; other registry pages are untouched.
|
|
28
25
|
*/
|
|
29
26
|
|
|
27
|
+
// Why: Resolve the `{{approvalEmail}}` placeholder in a proposal pa…
|
|
28
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
29
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
30
|
+
import { getServerClient } from "../page-server-orpc/server-client.ts";
|
|
31
|
+
|
|
30
32
|
export async function applyApprovalEmailToken(
|
|
31
33
|
pageData: Record<string, unknown>,
|
|
32
34
|
proposal: { created_by?: string } | undefined,
|
|
@@ -50,10 +50,8 @@ export async function runContentGatesForPage(
|
|
|
50
50
|
if (withholdsContent(entry)) gateOutcomes.push(outcome);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
// THE ASSISTANT IS A ROW, AND THIS IS THE ONE PLACE IT IS APPLIED.
|
|
54
|
-
//
|
|
55
|
-
// in the hardcoded gate sequence above, so a third resource type
|
|
56
|
-
// meant editing this file. Now any page declaring `assistant` gets
|
|
53
|
+
// 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
|
|
54
|
+
// in the hardcoded gate sequence above, so a third resource type meant editing this file. Now any page declaring `assistant` gets
|
|
57
55
|
// one and no page without the key does.
|
|
58
56
|
//
|
|
59
57
|
// GATED PAGES ONLY WHEN AUTHORIZED. The launcher's runtime context is
|
|
@@ -292,8 +292,7 @@ export async function registerRegistryPageRoute(
|
|
|
292
292
|
serviceSlug,
|
|
293
293
|
);
|
|
294
294
|
warnUncoveredBlocks(pageData, components, serviceSlug, params.path, log);
|
|
295
|
-
// Detached (NOT awaited into the response). prefetchAiChatSession ->
|
|
296
|
-
// createOrRestore is DB-heavy (~1.1s), per-user, and uncached, and was
|
|
295
|
+
// Detached (NOT awaited into the response). prefetchAiChatSession -> createOrRestore is DB-heavy (~1.1s), per-user, and uncached, and was
|
|
297
296
|
// awaited on EVERY loadData — the dominant board loadMs cost (confirmed
|
|
298
297
|
// aiChatPrefetch=1145ms = the full loadMs). Fire it in the background so
|
|
299
298
|
// it still warms the server-side session (users who open chat find it
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
14
15
|
import type { Hono } from "hono";
|
|
15
16
|
import { etag } from "hono/etag";
|
|
16
17
|
import { buildErrorReport } from "@teamscala/os/errors/error-report";
|
|
@@ -62,10 +63,8 @@ export async function registerStaticAssets(
|
|
|
62
63
|
}
|
|
63
64
|
});
|
|
64
65
|
|
|
65
|
-
// Build-id: the content hash of the CURRENT client bundle, read FRESH from
|
|
66
|
-
//
|
|
67
|
-
// post-restart rebuild reports its new id immediately. The client self-heal
|
|
68
|
-
// (page-client/boot/build-self-heal) polls this on tab refocus; if it differs
|
|
66
|
+
// 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
|
|
67
|
+
// 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
|
|
69
68
|
// from the build the tab booted with, a new build shipped and the open tab is
|
|
70
69
|
// stale → it reloads. no-store HTML + immutable hashed assets already prevent
|
|
71
70
|
// staleness on every FRESH load; this is what closes the one remaining hole —
|
|
@@ -81,7 +80,9 @@ export async function registerStaticAssets(
|
|
|
81
80
|
const raw = await Bun.file(path).text();
|
|
82
81
|
const manifest = JSON.parse(raw) as { hydratePath?: string };
|
|
83
82
|
build = /hydrate-([a-z0-9]+)\.js/.exec(manifest.hydratePath ?? "")?.[1] ?? "dev";
|
|
84
|
-
} catch {
|
|
83
|
+
} catch (error) {
|
|
84
|
+
getAppLogger().warn("[register-static-assets] asset manifest unreadable — build label falls back to dev", error);
|
|
85
|
+
}
|
|
85
86
|
return c.json({ build }, 200, { "cache-control": "no-store" });
|
|
86
87
|
});
|
|
87
88
|
|
|
@@ -13,16 +13,23 @@
|
|
|
13
13
|
|
|
14
14
|
import { ServiceInstallConfigSchema } from "@teamscala/db-validation/registry-schemas/service-install";
|
|
15
15
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
16
|
+
import { createCache } from "@teamscala/cache/create-cache";
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
const appUrlCache = createCache<string>("service-boot:app-base-url", {
|
|
19
|
+
ttlMs: Number.POSITIVE_INFINITY,
|
|
20
|
+
maxSize: 1,
|
|
21
|
+
});
|
|
18
22
|
|
|
19
23
|
export async function resolveAppBaseUrl(): Promise<string> {
|
|
20
|
-
|
|
24
|
+
const hit = appUrlCache.get("default");
|
|
25
|
+
if (hit) return hit;
|
|
21
26
|
try {
|
|
22
27
|
const config = await loadRegistryConfig("service_install", "scala-projects", ServiceInstallConfigSchema);
|
|
23
|
-
|
|
28
|
+
const url = `https://${config.public_hostname}`;
|
|
29
|
+
appUrlCache.set("default", url);
|
|
30
|
+
return url;
|
|
24
31
|
} catch {
|
|
25
|
-
|
|
32
|
+
appUrlCache.set("default", "https://projects.scala.business");
|
|
33
|
+
return "https://projects.scala.business";
|
|
26
34
|
}
|
|
27
|
-
return cachedAppUrl;
|
|
28
35
|
}
|
|
@@ -13,17 +13,6 @@
|
|
|
13
13
|
* `/<last-segment>-toolbar`. Pages without a matching toolbar page simply
|
|
14
14
|
* resolve an empty toolbar region (the source is declared optional).
|
|
15
15
|
*/
|
|
16
|
-
export interface ResolverArgs {
|
|
17
|
-
input: Record<string, unknown>;
|
|
18
|
-
client: unknown;
|
|
19
|
-
params: Record<string, unknown>;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Canonical portal URL shape: /<websiteId>/<organisationId>/<builda path...>.
|
|
23
|
-
// Segment 0 is the portal WEBSITE id; segment 1 is the ORG id. Shared by the
|
|
24
|
-
// resolver below and by extractOrgIdFromCallbackPath (the /sign-in brand path).
|
|
25
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
26
|
-
|
|
27
16
|
/**
|
|
28
17
|
* Extract the organisation id from a portal-shaped callback/path — the single
|
|
29
18
|
* authority for "which segment is the org" so the sign-in brand resolver and the
|
|
@@ -41,6 +30,21 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
|
41
30
|
* correct-org improvement for the two-UUID portal shape). Returns undefined when
|
|
42
31
|
* neither leading segment is a UUID.
|
|
43
32
|
*/
|
|
33
|
+
|
|
34
|
+
// Why: Extract the organisation id from a portal-shaped callback/pa…
|
|
35
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
36
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
37
|
+
export interface ResolverArgs {
|
|
38
|
+
input: Record<string, unknown>;
|
|
39
|
+
client: unknown;
|
|
40
|
+
params: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Canonical portal URL shape: /<websiteId>/<organisationId>/<builda path...>.
|
|
44
|
+
// Segment 0 is the portal WEBSITE id; segment 1 is the ORG id. Shared by the
|
|
45
|
+
// resolver below and by extractOrgIdFromCallbackPath (the /sign-in brand path).
|
|
46
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
47
|
+
|
|
44
48
|
export function extractOrgIdFromCallbackPath(callbackUrl: string): string | undefined {
|
|
45
49
|
const segs = callbackUrl.split("?")[0].split("/").filter(Boolean);
|
|
46
50
|
if (UUID_RE.test(segs[1] ?? "")) return segs[1];
|
|
@@ -20,6 +20,90 @@
|
|
|
20
20
|
* (fail-soft).
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* THE ASSISTANT'S WORDS BELONG TO THE AGENT ROW, NOT TO THIS FILE.
|
|
25
|
+
*
|
|
26
|
+
* `ai_agents` already carries a column for every one of them -- name,
|
|
27
|
+
* welcome_message, conversation_starters, primary_color -- and on 2026-08-19
|
|
28
|
+
* both live agents had welcome_message NULL and conversation_starters empty
|
|
29
|
+
* while the copy sat spelled in TypeScript here and in proposal-chat-inject.
|
|
30
|
+
* The intended home existed, sat blank, and the hardcoded copy won, so nobody
|
|
31
|
+
* could change a word the assistant says without a code change and a deploy.
|
|
32
|
+
* Two copies of one fact with the stale one winning silently is exactly
|
|
33
|
+
* `information-exists-in-one-location`.
|
|
34
|
+
*
|
|
35
|
+
* `launchLabel` and `placeholder` remain caller arguments only because no
|
|
36
|
+
* column holds them yet.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// Why: THE ASSISTANT'S WORDS BELONG TO THE AGENT ROW, NOT TO THIS F…
|
|
40
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
41
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
42
|
+
/**
|
|
43
|
+
* The rendered content tree to append the launcher to.
|
|
44
|
+
*
|
|
45
|
+
* WHICH KEYS EXIST IS A ROW FACT, SO IT IS NOT SPELLED HERE. A page's resolved
|
|
46
|
+
* sources and its region slots are declared on the `resource_loader` row, and
|
|
47
|
+
* the caller has already read that row — so it passes `regionSlots` in. An
|
|
48
|
+
* earlier draft of this function named "page", "body", "header" and "footer" as
|
|
49
|
+
* literals, which is a loader row copied into TypeScript, free to drift from it
|
|
50
|
+
* the moment a loader is edited (`identity-is-imported-never-spelled`).
|
|
51
|
+
*
|
|
52
|
+
* A FLAT PAGE KEEPS ITS CONTENT UNDER `page`; A REGION-SLOT PAGE DOES NOT.
|
|
53
|
+
* Proposals and agreements resolve one `page` source, so the original injector
|
|
54
|
+
* read `resolved.page.puck_data.content` and returned early when it was absent.
|
|
55
|
+
* A generated report resolves region slots instead, so that early return fired
|
|
56
|
+
* and the assistant was silently missing — the same failure shape as the dead
|
|
57
|
+
* block name, since fail-soft injection logs nothing and the page looks fine.
|
|
58
|
+
*
|
|
59
|
+
* ANY RENDERED REGION WILL DO, SO THE FIRST DECLARED ONE IS TAKEN. The launcher
|
|
60
|
+
* renders `position: fixed` at `zIndex: 50`, anchored to the viewport and not
|
|
61
|
+
* to its container, so it appears identically whichever region carries it. That
|
|
62
|
+
* is what makes "first declared region holding a content tree" a derivation
|
|
63
|
+
* rather than a guess dressed up as one: there is no better region to prefer.
|
|
64
|
+
*
|
|
65
|
+
* `PAGE_SOURCE_KEY` is the platform's existing name for a flat page's body
|
|
66
|
+
* source — the same convention `decideContentPageFloor` already keys on in the
|
|
67
|
+
* caller. One shared convention, not a per-resource list.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
// Why: The rendered content tree to append the launcher to.
|
|
71
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
72
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
73
|
+
/**
|
|
74
|
+
* Serialise EVERYTHING THIS VIEWER MAY SEE as text for the assistant.
|
|
75
|
+
*
|
|
76
|
+
* BOTH THE RESULT AND THE RENDERED DOCUMENT, BECAUSE NEITHER IS SUFFICIENT.
|
|
77
|
+
* The original serialiser sent the resource record plus the page's content
|
|
78
|
+
* tree, which is exactly right for a proposal — the proposal's substance IS in
|
|
79
|
+
* its blocks. It is wrong for anything the platform GENERATES a document from.
|
|
80
|
+
* A report's body page is a shared template (one `builda_pages` row serves
|
|
81
|
+
* every respondent) so the content tree holds headings and layout and not one
|
|
82
|
+
* personal fact, while the substance — the answers, the category scores, the
|
|
83
|
+
* computed narrative and bands — sits in sibling resolved keys the serialiser
|
|
84
|
+
* never looked at. Sending only the page would give the assistant a form with
|
|
85
|
+
* no answers; sending only the record would leave it unable to discuss what the
|
|
86
|
+
* reader is actually looking at. An assessment result GENERATES a report, so
|
|
87
|
+
* the assistant needs both halves of that.
|
|
88
|
+
*
|
|
89
|
+
* NO KEY IS NAMED, INCLUDED OR EXCLUDED. The loader row decides what a page
|
|
90
|
+
* resolves; this walks whatever that produced. A draft carried a `CHROME_KEYS`
|
|
91
|
+
* exclusion list dropping brand, website, header and footer — every one of them
|
|
92
|
+
* a loader source key, which is to say database facts hardcoded here to save a
|
|
93
|
+
* few hundred tokens of colours and navigation markup. Not a trade worth a
|
|
94
|
+
* literal that goes stale in silence. A loader resolving a new source reaches
|
|
95
|
+
* the assistant with no edit here (`offered-is-derived`).
|
|
96
|
+
*
|
|
97
|
+
* IT CANNOT OUTRUN THE GATE, BY CONSTRUCTION. This runs AFTER every content
|
|
98
|
+
* gate, and a gate withholds by REMOVING content from this same graph. So
|
|
99
|
+
* "everything in the graph" and "everything this viewer may see" are the same
|
|
100
|
+
* set, and they cannot drift — which is what makes serialising the whole graph
|
|
101
|
+
* safe rather than reckless.
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
// Why: Serialise EVERYTHING THIS VIEWER MAY SEE as text for the ass…
|
|
105
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
106
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
23
107
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
24
108
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
25
109
|
import { getServerClient } from "./page-server-orpc/server-client.ts";
|
|
@@ -61,21 +145,6 @@ export interface ResourceChatOptions {
|
|
|
61
145
|
regionSlots?: readonly string[];
|
|
62
146
|
}
|
|
63
147
|
|
|
64
|
-
/**
|
|
65
|
-
* THE ASSISTANT'S WORDS BELONG TO THE AGENT ROW, NOT TO THIS FILE.
|
|
66
|
-
*
|
|
67
|
-
* `ai_agents` already carries a column for every one of them -- name,
|
|
68
|
-
* welcome_message, conversation_starters, primary_color -- and on 2026-08-19
|
|
69
|
-
* both live agents had welcome_message NULL and conversation_starters empty
|
|
70
|
-
* while the copy sat spelled in TypeScript here and in proposal-chat-inject.
|
|
71
|
-
* The intended home existed, sat blank, and the hardcoded copy won, so nobody
|
|
72
|
-
* could change a word the assistant says without a code change and a deploy.
|
|
73
|
-
* Two copies of one fact with the stale one winning silently is exactly
|
|
74
|
-
* `information-exists-in-one-location`.
|
|
75
|
-
*
|
|
76
|
-
* `launchLabel` and `placeholder` remain caller arguments only because no
|
|
77
|
-
* column holds them yet.
|
|
78
|
-
*/
|
|
79
148
|
export interface AgentPresentation {
|
|
80
149
|
title?: string;
|
|
81
150
|
welcomeMessage?: string;
|
|
@@ -147,33 +216,6 @@ function puckContentOf(value: unknown): unknown[] | null {
|
|
|
147
216
|
return Array.isArray(content) ? content : null;
|
|
148
217
|
}
|
|
149
218
|
|
|
150
|
-
/**
|
|
151
|
-
* The rendered content tree to append the launcher to.
|
|
152
|
-
*
|
|
153
|
-
* WHICH KEYS EXIST IS A ROW FACT, SO IT IS NOT SPELLED HERE. A page's resolved
|
|
154
|
-
* sources and its region slots are declared on the `resource_loader` row, and
|
|
155
|
-
* the caller has already read that row — so it passes `regionSlots` in. An
|
|
156
|
-
* earlier draft of this function named "page", "body", "header" and "footer" as
|
|
157
|
-
* literals, which is a loader row copied into TypeScript, free to drift from it
|
|
158
|
-
* the moment a loader is edited (`identity-is-imported-never-spelled`).
|
|
159
|
-
*
|
|
160
|
-
* A FLAT PAGE KEEPS ITS CONTENT UNDER `page`; A REGION-SLOT PAGE DOES NOT.
|
|
161
|
-
* Proposals and agreements resolve one `page` source, so the original injector
|
|
162
|
-
* read `resolved.page.puck_data.content` and returned early when it was absent.
|
|
163
|
-
* A generated report resolves region slots instead, so that early return fired
|
|
164
|
-
* and the assistant was silently missing — the same failure shape as the dead
|
|
165
|
-
* block name, since fail-soft injection logs nothing and the page looks fine.
|
|
166
|
-
*
|
|
167
|
-
* ANY RENDERED REGION WILL DO, SO THE FIRST DECLARED ONE IS TAKEN. The launcher
|
|
168
|
-
* renders `position: fixed` at `zIndex: 50`, anchored to the viewport and not
|
|
169
|
-
* to its container, so it appears identically whichever region carries it. That
|
|
170
|
-
* is what makes "first declared region holding a content tree" a derivation
|
|
171
|
-
* rather than a guess dressed up as one: there is no better region to prefer.
|
|
172
|
-
*
|
|
173
|
-
* `PAGE_SOURCE_KEY` is the platform's existing name for a flat page's body
|
|
174
|
-
* source — the same convention `decideContentPageFloor` already keys on in the
|
|
175
|
-
* caller. One shared convention, not a per-resource list.
|
|
176
|
-
*/
|
|
177
219
|
const PAGE_SOURCE_KEY = "page";
|
|
178
220
|
|
|
179
221
|
function findContentHost(
|
|
@@ -190,36 +232,6 @@ function findContentHost(
|
|
|
190
232
|
return puckContentOf(resolved[PAGE_SOURCE_KEY]);
|
|
191
233
|
}
|
|
192
234
|
|
|
193
|
-
/**
|
|
194
|
-
* Serialise EVERYTHING THIS VIEWER MAY SEE as text for the assistant.
|
|
195
|
-
*
|
|
196
|
-
* BOTH THE RESULT AND THE RENDERED DOCUMENT, BECAUSE NEITHER IS SUFFICIENT.
|
|
197
|
-
* The original serialiser sent the resource record plus the page's content
|
|
198
|
-
* tree, which is exactly right for a proposal — the proposal's substance IS in
|
|
199
|
-
* its blocks. It is wrong for anything the platform GENERATES a document from.
|
|
200
|
-
* A report's body page is a shared template (one `builda_pages` row serves
|
|
201
|
-
* every respondent) so the content tree holds headings and layout and not one
|
|
202
|
-
* personal fact, while the substance — the answers, the category scores, the
|
|
203
|
-
* computed narrative and bands — sits in sibling resolved keys the serialiser
|
|
204
|
-
* never looked at. Sending only the page would give the assistant a form with
|
|
205
|
-
* no answers; sending only the record would leave it unable to discuss what the
|
|
206
|
-
* reader is actually looking at. An assessment result GENERATES a report, so
|
|
207
|
-
* the assistant needs both halves of that.
|
|
208
|
-
*
|
|
209
|
-
* NO KEY IS NAMED, INCLUDED OR EXCLUDED. The loader row decides what a page
|
|
210
|
-
* resolves; this walks whatever that produced. A draft carried a `CHROME_KEYS`
|
|
211
|
-
* exclusion list dropping brand, website, header and footer — every one of them
|
|
212
|
-
* a loader source key, which is to say database facts hardcoded here to save a
|
|
213
|
-
* few hundred tokens of colours and navigation markup. Not a trade worth a
|
|
214
|
-
* literal that goes stale in silence. A loader resolving a new source reaches
|
|
215
|
-
* the assistant with no edit here (`offered-is-derived`).
|
|
216
|
-
*
|
|
217
|
-
* IT CANNOT OUTRUN THE GATE, BY CONSTRUCTION. This runs AFTER every content
|
|
218
|
-
* gate, and a gate withholds by REMOVING content from this same graph. So
|
|
219
|
-
* "everything in the graph" and "everything this viewer may see" are the same
|
|
220
|
-
* set, and they cannot drift — which is what makes serialising the whole graph
|
|
221
|
-
* safe rather than reckless.
|
|
222
|
-
*/
|
|
223
235
|
async function buildResourceContext(
|
|
224
236
|
resolved: Record<string, unknown>,
|
|
225
237
|
resourceKey: string,
|