@teamscala/service-boot 0.23.431 → 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/orpc-client.ts +38 -18
- 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
|
@@ -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,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
|
-
* @indivisible-unit
|
|
5
|
+
* @indivisible-unit owner=unassigned reason=299 lines; pre-2026-08-27 exemption, unreviewed
|
|
6
6
|
*
|
|
7
7
|
* One cohesive unit — the branded resource-not-found / holding page: the
|
|
8
8
|
* PrimaryResourceMissingError trigger, the org-logo resolver the page
|
|
@@ -28,6 +28,28 @@
|
|
|
28
28
|
* floor that must always exist.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The host org's LOGO (wordmark) for the dark error page — the favicon is a
|
|
33
|
+
* tab glyph, the wrong asset at page scale (operator-observed: a lone "h"
|
|
34
|
+
* where the launcha wordmark belonged). Resolution: the service's own
|
|
35
|
+
* install row names its owner (brand_org); the org's dark-theme logo (a
|
|
36
|
+
* light-coloured mark, correct on the dark page) is preferred, light-theme
|
|
37
|
+
* second. Every step skew-tolerant: any miss → null → the builder renders a
|
|
38
|
+
* logo-less dark page.
|
|
39
|
+
*
|
|
40
|
+
* Returns the logo as an INLINE data URI (not a `/api/assets/` proxy URL) so
|
|
41
|
+
* the branded holding/error page ships the logo IN the initial HTML bytes —
|
|
42
|
+
* no second fetch, no post-paint pop-in, no dependency on the asset proxy the
|
|
43
|
+
* degraded service is failing to serve (incident 2026-07-27: the logo on a
|
|
44
|
+
* DEGRADED render-layer holding page appeared after the page painted). The
|
|
45
|
+
* logo sibling of icons-render-ssr-inline. Size-capped: a pathological
|
|
46
|
+
* (>MAX_INLINE_LOGO_BYTES) logo falls back to null rather than bloating the
|
|
47
|
+
* HTML; the page renders logo-less, never broken.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
// Why: The host org's LOGO (wordmark) for the dark error page — the…
|
|
51
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
52
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
31
53
|
import { getClient } from "@teamscala/object-storage/create/client/get-client";
|
|
32
54
|
import { createCache } from "@teamscala/cache/create-cache";
|
|
33
55
|
import { registerCacheInvalidation } from "@teamscala/db/notify-listener/index";
|
|
@@ -50,24 +72,6 @@ const logoCache = createCache<string | null>("service-boot:error-page-logo", {
|
|
|
50
72
|
maxSize: 8,
|
|
51
73
|
});
|
|
52
74
|
|
|
53
|
-
/**
|
|
54
|
-
* The host org's LOGO (wordmark) for the dark error page — the favicon is a
|
|
55
|
-
* tab glyph, the wrong asset at page scale (operator-observed: a lone "h"
|
|
56
|
-
* where the launcha wordmark belonged). Resolution: the service's own
|
|
57
|
-
* install row names its owner (brand_org); the org's dark-theme logo (a
|
|
58
|
-
* light-coloured mark, correct on the dark page) is preferred, light-theme
|
|
59
|
-
* second. Every step skew-tolerant: any miss → null → the builder renders a
|
|
60
|
-
* logo-less dark page.
|
|
61
|
-
*
|
|
62
|
-
* Returns the logo as an INLINE data URI (not a `/api/assets/` proxy URL) so
|
|
63
|
-
* the branded holding/error page ships the logo IN the initial HTML bytes —
|
|
64
|
-
* no second fetch, no post-paint pop-in, no dependency on the asset proxy the
|
|
65
|
-
* degraded service is failing to serve (incident 2026-07-27: the logo on a
|
|
66
|
-
* DEGRADED render-layer holding page appeared after the page painted). The
|
|
67
|
-
* logo sibling of icons-render-ssr-inline. Size-capped: a pathological
|
|
68
|
-
* (>MAX_INLINE_LOGO_BYTES) logo falls back to null rather than bloating the
|
|
69
|
-
* HTML; the page renders logo-less, never broken.
|
|
70
|
-
*/
|
|
71
75
|
export async function resolveErrorPageLogo(
|
|
72
76
|
serviceSlug: string,
|
|
73
77
|
): Promise<string | null> {
|
|
@@ -18,10 +18,10 @@ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
|
18
18
|
import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
|
|
19
19
|
import { getGithubPackagesLatestVersion } from "@teamscala/publish-registry";
|
|
20
20
|
import * as v from "valibot";
|
|
21
|
+
import { createCache } from "@teamscala/cache/create-cache";
|
|
21
22
|
|
|
22
23
|
const PACKAGE_NAME = "@teamscala/scala-tools";
|
|
23
24
|
const PACKAGE_PATH = "node_modules/@teamscala/scala-tools/package.json";
|
|
24
|
-
const PACKAGE_URL = "https://npm.pkg.github.com/@teamgbg%2Fscala-tools";
|
|
25
25
|
const POLL_INTERVAL_MS = 120_000;
|
|
26
26
|
|
|
27
27
|
const DEFAULT_COMMAND_TIMEOUT_MS = 600_000;
|
|
@@ -30,28 +30,43 @@ const CurrencyConfigSchema = v.looseObject({
|
|
|
30
30
|
command_timeout_ms: v.optional(v.number(), DEFAULT_COMMAND_TIMEOUT_MS),
|
|
31
31
|
fetch_timeout_ms: v.optional(v.number(), DEFAULT_FETCH_TIMEOUT_MS),
|
|
32
32
|
});
|
|
33
|
-
|
|
33
|
+
const currencyConfigCache = createCache<{
|
|
34
|
+
command_timeout_ms: number;
|
|
35
|
+
fetch_timeout_ms: number;
|
|
36
|
+
}>("service-boot:scala-tools-currency-config", {
|
|
37
|
+
ttlMs: Number.POSITIVE_INFINITY,
|
|
38
|
+
maxSize: 1,
|
|
39
|
+
});
|
|
40
|
+
|
|
34
41
|
async function getCurrencyConfig(): Promise<{ command_timeout_ms: number; fetch_timeout_ms: number }> {
|
|
35
|
-
|
|
42
|
+
const hit = currencyConfigCache.get("default");
|
|
43
|
+
if (hit) return hit;
|
|
36
44
|
const cfg = await loadRegistryConfig("config", "scala-tools-currency", CurrencyConfigSchema);
|
|
37
|
-
|
|
45
|
+
const config = {
|
|
38
46
|
command_timeout_ms: cfg?.command_timeout_ms ?? DEFAULT_COMMAND_TIMEOUT_MS,
|
|
39
47
|
fetch_timeout_ms: cfg?.fetch_timeout_ms ?? DEFAULT_FETCH_TIMEOUT_MS,
|
|
40
48
|
};
|
|
41
|
-
|
|
49
|
+
currencyConfigCache.set("default", config);
|
|
50
|
+
return config;
|
|
42
51
|
}
|
|
43
52
|
|
|
44
|
-
|
|
53
|
+
const githubTokenCache = createCache<string | null>(
|
|
54
|
+
"service-boot:github-packages-token",
|
|
55
|
+
{ ttlMs: Number.POSITIVE_INFINITY, maxSize: 1 },
|
|
56
|
+
);
|
|
57
|
+
|
|
45
58
|
async function getGithubPackagesToken(): Promise<string | null> {
|
|
46
|
-
|
|
59
|
+
const hit = githubTokenCache.get("default");
|
|
60
|
+
if (hit !== undefined) return hit;
|
|
47
61
|
const raw = await loadRegistryConfig("secret", "github-packages-token", v.unknown());
|
|
48
62
|
if (!raw) {
|
|
49
|
-
|
|
63
|
+
githubTokenCache.set("default", null);
|
|
50
64
|
return null;
|
|
51
65
|
}
|
|
52
66
|
const decrypted = decryptSecretConfig(raw as never) as Record<string, unknown>;
|
|
53
|
-
|
|
54
|
-
|
|
67
|
+
const token = typeof decrypted.token === "string" ? decrypted.token : null;
|
|
68
|
+
githubTokenCache.set("default", token);
|
|
69
|
+
return token;
|
|
55
70
|
}
|
|
56
71
|
|
|
57
72
|
interface PackageManifest {
|
|
@@ -86,8 +101,22 @@ function registryFromPackageUrl(pkgUrl: string): { registry: string; name: strin
|
|
|
86
101
|
async function readLatestVersion(_fetchTimeoutMs: number): Promise<string | null> {
|
|
87
102
|
const token = await getGithubPackagesToken();
|
|
88
103
|
if (!token) return null;
|
|
89
|
-
const
|
|
90
|
-
|
|
104
|
+
const registryHost = await getPublishRegistryHost();
|
|
105
|
+
if (!registryHost) return null;
|
|
106
|
+
return getGithubPackagesLatestVersion(PACKAGE_NAME, token, registryHost);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function getPublishRegistryHost(): Promise<string | null> {
|
|
110
|
+
try {
|
|
111
|
+
const row = await loadRegistryConfig(
|
|
112
|
+
"secret",
|
|
113
|
+
"publish-registry-config",
|
|
114
|
+
v.looseObject({ registry: v.string() }),
|
|
115
|
+
);
|
|
116
|
+
return row?.registry ?? null;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
91
120
|
}
|
|
92
121
|
|
|
93
122
|
async function checkCurrency(): Promise<{ healthy: boolean; details?: Record<string, unknown> }> {
|
|
@@ -9,22 +9,6 @@
|
|
|
9
9
|
* in atomically once complete.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
13
|
-
import type { Hono } from "hono";
|
|
14
|
-
import { buildClientAssets } from "./build-client-assets.ts";
|
|
15
|
-
import { mountPgBoss } from "./pg-boss-mount.ts";
|
|
16
|
-
import { swapApp } from "./mutable-app-ref.ts";
|
|
17
|
-
import type { MinimalAppResult } from "./two-phase-minimal-app.ts";
|
|
18
|
-
import { markFullAppReady, setPageHandler } from "./two-phase-readiness.ts";
|
|
19
|
-
import type { PageServerDeps } from "./ui-service.ts";
|
|
20
|
-
|
|
21
|
-
export interface FullAppResult {
|
|
22
|
-
app: Hono;
|
|
23
|
-
blocks: { slug: string; packagePath: string }[];
|
|
24
|
-
routes: string[];
|
|
25
|
-
pgBossJobs: Array<{ handlerSlug: string }>;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
12
|
/**
|
|
29
13
|
* Build the full app with hydration. This adds the expensive operations that
|
|
30
14
|
* were deferred from the minimal app:
|
|
@@ -37,13 +21,10 @@ export interface FullAppResult {
|
|
|
37
21
|
* NOTE: All routes (including page routes) are already registered in Phase 1,
|
|
38
22
|
* so we don't need to modify the app here. The app reference stays the same.
|
|
39
23
|
*/
|
|
40
|
-
export async function buildFullApp(
|
|
41
|
-
minimalResult: MinimalAppResult,
|
|
42
|
-
deps: PageServerDeps,
|
|
43
|
-
): Promise<FullAppResult> {
|
|
44
|
-
const log = getAppLogger();
|
|
45
|
-
const { config, serviceSlug, mountRegistry, components, loaded, app: minimalApp } = minimalResult;
|
|
46
24
|
|
|
25
|
+
// Why: Build the full app with hydration. This adds the expensive o…
|
|
26
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
27
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
47
28
|
// Client asset building (expensive: Vite build).
|
|
48
29
|
//
|
|
49
30
|
// ISOLATED, because a PRESENTATION failure must not take out BACKGROUND WORK.
|
|
@@ -63,6 +44,50 @@ export async function buildFullApp(
|
|
|
63
44
|
// The steps after this one do not depend on client assets: pg-boss needs the
|
|
64
45
|
// registry and the DB, not a bundle. Ordering it behind a Vite build was
|
|
65
46
|
// incidental, so the coupling was never intended.
|
|
47
|
+
|
|
48
|
+
// Why: Client asset building (expensive: Vite build).
|
|
49
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
50
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
51
|
+
// pg-boss (mount for UI services - wasn't mounted in minimal app)
|
|
52
|
+
//
|
|
53
|
+
// Wrapped for the SAME reason buildClientAssets above is, in the other
|
|
54
|
+
// direction: a background-work failure must not cost the page. Unwrapped,
|
|
55
|
+
// anything mountPgBoss throws propagates out of the full-app build, the
|
|
56
|
+
// caller falls back to the MINIMAL app, and the service serves 0 api routes
|
|
57
|
+
// with /health 503 — so the tunnel health check fails and the site is
|
|
58
|
+
// unreachable from outside while localhost answers in 0.1s.
|
|
59
|
+
//
|
|
60
|
+
// Measured 2026-08-31 on scala-projects: a deps refresh moved pg-boss to a
|
|
61
|
+
// build expecting a newer shared pgboss schema than the database carried, its
|
|
62
|
+
// migration threw, and an outage of every page followed from a scheduled-job
|
|
63
|
+
// dependency. Jobs are the degradable half; pages are not.
|
|
64
|
+
|
|
65
|
+
// Why: pg-boss (mount for UI services - wasn't mounted in minimal a…
|
|
66
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
67
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
68
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
69
|
+
import type { Hono } from "hono";
|
|
70
|
+
import { buildClientAssets } from "./build-client-assets.ts";
|
|
71
|
+
import { mountPgBoss } from "./pg-boss-mount.ts";
|
|
72
|
+
import { swapApp } from "./mutable-app-ref.ts";
|
|
73
|
+
import type { MinimalAppResult } from "./two-phase-minimal-app.ts";
|
|
74
|
+
import { markFullAppReady, setPageHandler } from "./two-phase-readiness.ts";
|
|
75
|
+
import type { PageServerDeps } from "./ui-service.ts";
|
|
76
|
+
|
|
77
|
+
export interface FullAppResult {
|
|
78
|
+
app: Hono;
|
|
79
|
+
blocks: { slug: string; packagePath: string }[];
|
|
80
|
+
routes: string[];
|
|
81
|
+
pgBossJobs: Array<{ handlerSlug: string }>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function buildFullApp(
|
|
85
|
+
minimalResult: MinimalAppResult,
|
|
86
|
+
deps: PageServerDeps,
|
|
87
|
+
): Promise<FullAppResult> {
|
|
88
|
+
const log = getAppLogger();
|
|
89
|
+
const { config, serviceSlug, mountRegistry, components, loaded, app: minimalApp } = minimalResult;
|
|
90
|
+
|
|
66
91
|
try {
|
|
67
92
|
await buildClientAssets(serviceSlug);
|
|
68
93
|
log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets DONE`);
|
|
@@ -76,13 +101,20 @@ export async function buildFullApp(
|
|
|
76
101
|
);
|
|
77
102
|
}
|
|
78
103
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
104
|
+
let pgBossJobs: Awaited<ReturnType<typeof mountPgBoss>>["jobs"] = [];
|
|
105
|
+
try {
|
|
106
|
+
({ jobs: pgBossJobs } = await mountPgBoss({
|
|
107
|
+
databaseUrl: minimalResult.databaseUrl,
|
|
108
|
+
serviceSlug,
|
|
109
|
+
packages: config.server_packages ?? [],
|
|
110
|
+
}));
|
|
111
|
+
log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: mountPgBoss DONE (${pgBossJobs.length} jobs)`);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
log.error(
|
|
114
|
+
`[two-phase-boot:${serviceSlug}] phase-2 step: mountPgBoss FAILED — background jobs are DOWN for this service, pages continue`,
|
|
115
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
116
|
+
);
|
|
117
|
+
}
|
|
86
118
|
if (pgBossJobs.length > 0) {
|
|
87
119
|
log.info(
|
|
88
120
|
`[two-phase-boot:${serviceSlug}] pg-boss mounted ${pgBossJobs.length} job(s): ${pgBossJobs.map((j) => j.handlerSlug).join(", ")}`,
|
|
@@ -9,6 +9,45 @@
|
|
|
9
9
|
* it in atomically — eliminating the ~25s boot outage window.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Build the minimal tool-only app. This excludes the expensive operations:
|
|
14
|
+
* - Client asset building (Vite build)
|
|
15
|
+
* - Boot hooks (may have expensive initialization)
|
|
16
|
+
* - Scheduled jobs (can start later)
|
|
17
|
+
* - pg-boss (can start later)
|
|
18
|
+
*
|
|
19
|
+
* CRITICAL: All route registration MUST happen here before serving, because
|
|
20
|
+
* Hono's RegExpRouter compiles on the first request and becomes immutable.
|
|
21
|
+
* Once compiled, no new routes can be added. So we include:
|
|
22
|
+
* - Block discovery and page routes (registers all page paths)
|
|
23
|
+
* - All other routes (API, WS, data, static assets)
|
|
24
|
+
*
|
|
25
|
+
* The minimal app includes everything needed for MCP tools and ORPC:
|
|
26
|
+
* - Error handling, readiness gate, health check
|
|
27
|
+
* - Security headers, CORS, compression, timing
|
|
28
|
+
* - Content gate access, auth gates, static assets
|
|
29
|
+
* - ORPC router, API routes, WS routes, data routes, page routes
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
// Why: Build the minimal tool-only app. This excludes the expensive…
|
|
33
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
34
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
35
|
+
// Fire independent I/O + compilation NOW, overlapping with the gate chain
|
|
36
|
+
// below. These three touch NO app route and have no ordering dependency on
|
|
37
|
+
// the gate/mount sequence, so they run concurrently with it:
|
|
38
|
+
// - preloadServerPackages: warms Bun's module cache (CPU-bound compilation
|
|
39
|
+
// that overlaps the gate chain's cross-internet DB I/O — same speculative
|
|
40
|
+
// pattern as create-boot-pipeline's preloads). mountServerPackages later
|
|
41
|
+
// hits the cache (instant imports), paying only the ~33ms of mount work.
|
|
42
|
+
// - initGateTtls: loads config rows into module-level vars. The TTL getters
|
|
43
|
+
// are read at REQUEST time (inside route-handler closures), not at boot,
|
|
44
|
+
// so this only needs to finish before serving — safe to run in parallel.
|
|
45
|
+
// - discoverBlocks: loads the page-manifest (no app mutation, no gate
|
|
46
|
+
// dependency). Its result is consumed by registerPageRoutes much later.
|
|
47
|
+
|
|
48
|
+
// Why: Fire independent I/O + compilation NOW, overlapping with the…
|
|
49
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
50
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
12
51
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
13
52
|
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
14
53
|
import { createTimer } from "@teamscala/timing/create-timer";
|
|
@@ -63,25 +102,6 @@ export interface MinimalAppResult {
|
|
|
63
102
|
skipped: string[]; // Skipped block names
|
|
64
103
|
}
|
|
65
104
|
|
|
66
|
-
/**
|
|
67
|
-
* Build the minimal tool-only app. This excludes the expensive operations:
|
|
68
|
-
* - Client asset building (Vite build)
|
|
69
|
-
* - Boot hooks (may have expensive initialization)
|
|
70
|
-
* - Scheduled jobs (can start later)
|
|
71
|
-
* - pg-boss (can start later)
|
|
72
|
-
*
|
|
73
|
-
* CRITICAL: All route registration MUST happen here before serving, because
|
|
74
|
-
* Hono's RegExpRouter compiles on the first request and becomes immutable.
|
|
75
|
-
* Once compiled, no new routes can be added. So we include:
|
|
76
|
-
* - Block discovery and page routes (registers all page paths)
|
|
77
|
-
* - All other routes (API, WS, data, static assets)
|
|
78
|
-
*
|
|
79
|
-
* The minimal app includes everything needed for MCP tools and ORPC:
|
|
80
|
-
* - Error handling, readiness gate, health check
|
|
81
|
-
* - Security headers, CORS, compression, timing
|
|
82
|
-
* - Content gate access, auth gates, static assets
|
|
83
|
-
* - ORPC router, API routes, WS routes, data routes, page routes
|
|
84
|
-
*/
|
|
85
105
|
export async function buildMinimalApp(
|
|
86
106
|
config: ServiceInstallConfig,
|
|
87
107
|
serviceSlug: string,
|
|
@@ -167,18 +187,6 @@ export async function buildMinimalApp(
|
|
|
167
187
|
);
|
|
168
188
|
attachRequestLogger(app, serviceSlug);
|
|
169
189
|
|
|
170
|
-
// Fire independent I/O + compilation NOW, overlapping with the gate chain
|
|
171
|
-
// below. These three touch NO app route and have no ordering dependency on
|
|
172
|
-
// the gate/mount sequence, so they run concurrently with it:
|
|
173
|
-
// - preloadServerPackages: warms Bun's module cache (CPU-bound compilation
|
|
174
|
-
// that overlaps the gate chain's cross-internet DB I/O — same speculative
|
|
175
|
-
// pattern as create-boot-pipeline's preloads). mountServerPackages later
|
|
176
|
-
// hits the cache (instant imports), paying only the ~33ms of mount work.
|
|
177
|
-
// - initGateTtls: loads config rows into module-level vars. The TTL getters
|
|
178
|
-
// are read at REQUEST time (inside route-handler closures), not at boot,
|
|
179
|
-
// so this only needs to finish before serving — safe to run in parallel.
|
|
180
|
-
// - discoverBlocks: loads the page-manifest (no app mutation, no gate
|
|
181
|
-
// dependency). Its result is consumed by registerPageRoutes much later.
|
|
182
190
|
const serverPackages = ((config as { server_packages?: string[] })
|
|
183
191
|
.server_packages ?? []) as string[];
|
|
184
192
|
const preloadPromise = runStep("preload-server-packages", () =>
|