@teamscala/service-boot 0.23.431 → 0.23.433
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 +45 -42
- package/src/bind-page-handler.ts +17 -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 +50 -57
- package/src/build-client-assets/input-fingerprint.ts +41 -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 +14 -10
- package/src/build-client-assets/styles-build.ts +20 -19
- package/src/build-client-assets.test.ts +2 -2
- package/src/build-client-assets.ts +13 -22
- package/src/content-gate/access.ts +5 -2
- package/src/content-gate/catalog.ts +20 -18
- package/src/content-gate/gate.ts +4 -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 +24 -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 +18 -17
- package/src/handlers/platform-fault-escalation/orpc-client.ts +35 -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 +20 -32
- package/src/handlers/singleton-tools-currency-watch.test.ts +1 -5
- package/src/handlers/singleton-tools-currency-watch.ts +21 -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 +14 -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 +12 -19
- package/src/register-registry-page-route/ai-chat-prefetch.ts +13 -12
- package/src/register-registry-page-route/apply-page-branding.ts +59 -56
- package/src/register-registry-page-route/graph-cache.ts +19 -18
- package/src/register-registry-page-route/meta-title.ts +21 -20
- package/src/register-registry-page-route/prefetch-org-id.ts +3 -2
- package/src/register-registry-page-route/proposal-approval-email-token.ts +2 -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 +12 -11
- package/src/resource-chat-inject.ts +75 -72
- package/src/resource-not-found.ts +20 -19
- package/src/scala-tools-currency-watchdog.ts +41 -12
- package/src/two-phase-full-app.ts +52 -29
- package/src/two-phase-minimal-app.ts +33 -31
- package/src/two-phase-readiness.ts +41 -38
- package/src/ui-service.ts +41 -38
- package/src/handlers/pane-outbox-drain.ts +0 -39
- package/src/handlers/prune-stale-data.ts +0 -110
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* hydration. These tests pin that a manifest change re-substitutes the HTML.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
14
15
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
15
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
16
16
|
import { tmpdir } from "node:os";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { readHtmlTemplate } from "./read-html-template.ts";
|
|
@@ -20,28 +20,25 @@ import { readHtmlTemplate } from "./read-html-template.ts";
|
|
|
20
20
|
let dir: string;
|
|
21
21
|
let origCwd: string;
|
|
22
22
|
|
|
23
|
-
function writeManifest(hydrate: string, styles: string): void {
|
|
24
|
-
|
|
25
|
-
join(dir, "dist", "client", "asset-manifest.json"),
|
|
26
|
-
`${JSON.stringify({ stylesPath: styles, hydratePath: hydrate }, null, 2)}\n`,
|
|
27
|
-
);
|
|
23
|
+
async function writeManifest(hydrate: string, styles: string): Promise<void> {
|
|
24
|
+
await Bun.file(join(dir, "dist", "client", "asset-manifest.json")).write(`${JSON.stringify({ stylesPath: styles, hydratePath: hydrate }, null, 2)}\n`);
|
|
28
25
|
}
|
|
29
26
|
|
|
30
|
-
beforeEach(() => {
|
|
27
|
+
beforeEach(async () => {
|
|
31
28
|
origCwd = process.cwd();
|
|
32
|
-
dir =
|
|
33
|
-
|
|
29
|
+
dir = await mkdtemp(join(tmpdir(), "read-html-template-"));
|
|
30
|
+
await mkdir(join(dir, "dist", "client"), { recursive: true });
|
|
34
31
|
process.chdir(dir);
|
|
35
32
|
});
|
|
36
33
|
|
|
37
|
-
afterEach(() => {
|
|
34
|
+
afterEach(async () => {
|
|
38
35
|
process.chdir(origCwd);
|
|
39
|
-
|
|
36
|
+
await rm(dir, { recursive: true, force: true });
|
|
40
37
|
});
|
|
41
38
|
|
|
42
39
|
describe("readHtmlTemplate manifest-keyed cache", () => {
|
|
43
40
|
test("renders the shared Puck document without a per-service HTML file", async () => {
|
|
44
|
-
writeManifest("/assets/hydrate-aaa.js", "/assets/styles-aaa.css");
|
|
41
|
+
await writeManifest("/assets/hydrate-aaa.js", "/assets/styles-aaa.css");
|
|
45
42
|
const html = await readHtmlTemplate();
|
|
46
43
|
expect(html).toContain('<div id="__page"><!--ssr-outlet--></div>');
|
|
47
44
|
expect(html).toContain("<!--head-outlet-->");
|
|
@@ -50,21 +47,21 @@ describe("readHtmlTemplate manifest-keyed cache", () => {
|
|
|
50
47
|
});
|
|
51
48
|
|
|
52
49
|
test("substitutes the current manifest hashes", async () => {
|
|
53
|
-
writeManifest("/assets/hydrate-aaa.js", "/assets/styles-aaa.css");
|
|
50
|
+
await writeManifest("/assets/hydrate-aaa.js", "/assets/styles-aaa.css");
|
|
54
51
|
const html = await readHtmlTemplate();
|
|
55
52
|
expect(html).toContain("/assets/hydrate-aaa.js");
|
|
56
53
|
expect(html).toContain("/assets/styles-aaa.css");
|
|
57
54
|
});
|
|
58
55
|
|
|
59
56
|
test("re-substitutes after a post-boot manifest change (the 404 regression)", async () => {
|
|
60
|
-
writeManifest("/assets/hydrate-old.js", "/assets/styles-old.css");
|
|
57
|
+
await writeManifest("/assets/hydrate-old.js", "/assets/styles-old.css");
|
|
61
58
|
const first = await readHtmlTemplate();
|
|
62
59
|
expect(first).toContain("/assets/hydrate-old.js");
|
|
63
60
|
|
|
64
61
|
// Simulate build-client-assets rebuilding: new manifest, old bundle gone.
|
|
65
62
|
// mtime must advance for the stat-keyed cache to invalidate.
|
|
66
63
|
await Bun.sleep(10);
|
|
67
|
-
writeManifest("/assets/hydrate-new.js", "/assets/styles-new.css");
|
|
64
|
+
await writeManifest("/assets/hydrate-new.js", "/assets/styles-new.css");
|
|
68
65
|
|
|
69
66
|
const second = await readHtmlTemplate();
|
|
70
67
|
expect(second).toContain("/assets/hydrate-new.js");
|
|
@@ -101,7 +101,8 @@ async function loadManifest(): Promise<AssetManifest> {
|
|
|
101
101
|
if (parsed.stylesPath && parsed.hydratePath) {
|
|
102
102
|
return { stylesPath: parsed.stylesPath, hydratePath: parsed.hydratePath };
|
|
103
103
|
}
|
|
104
|
-
} catch {}
|
|
104
|
+
} catch (error) {}
|
|
105
|
+
getAppLogger().warn("asset manifest unreadable — falling back to default template wiring", error);
|
|
105
106
|
}
|
|
106
107
|
return FALLBACK;
|
|
107
108
|
}
|
package/src/readiness-gate.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* the normal handler rather than producing a hard 500.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
14
15
|
import type { Context, Next } from "hono";
|
|
15
16
|
|
|
16
17
|
let _ready = false;
|
|
@@ -33,7 +34,7 @@ export async function readinessGate(c: Context, next: Next): Promise<Response |
|
|
|
33
34
|
// handler — but the failure must be OBSERVABLE, not silent. Practically
|
|
34
35
|
// unreachable (the body is pure reads of module flags + c.req.path); the
|
|
35
36
|
// log surfaces a regression if it ever fires.
|
|
36
|
-
|
|
37
|
+
getAppLogger().error("[readiness-gate] gate threw — failing open to handler", { error: String(err) });
|
|
37
38
|
return next();
|
|
38
39
|
}
|
|
39
40
|
}
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* identification. Per constitution crash-isolation.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
12
|
+
|
|
11
13
|
export function registerCrashHandlers(opts: { serviceSlug: string }): {
|
|
12
14
|
unhandledRejection: boolean;
|
|
13
15
|
uncaughtException: boolean;
|
|
@@ -17,20 +19,14 @@ export function registerCrashHandlers(opts: { serviceSlug: string }): {
|
|
|
17
19
|
|
|
18
20
|
if (process.listenerCount("unhandledRejection") === 0) {
|
|
19
21
|
process.on("unhandledRejection", (reason) => {
|
|
20
|
-
|
|
21
|
-
`[scala-install:${opts.serviceSlug}] unhandledRejection:`,
|
|
22
|
-
reason,
|
|
23
|
-
);
|
|
22
|
+
getAppLogger().error(`[scala-install:${opts.serviceSlug}] unhandledRejection:`, { error: String(reason) });
|
|
24
23
|
});
|
|
25
24
|
unhandledRejection = true;
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
if (process.listenerCount("uncaughtException") === 0) {
|
|
29
28
|
process.on("uncaughtException", (err) => {
|
|
30
|
-
|
|
31
|
-
`[scala-install:${opts.serviceSlug}] uncaughtException:`,
|
|
32
|
-
err,
|
|
33
|
-
);
|
|
29
|
+
getAppLogger().error(`[scala-install:${opts.serviceSlug}] uncaughtException:`, { error: String(err) });
|
|
34
30
|
});
|
|
35
31
|
uncaughtException = true;
|
|
36
32
|
}
|
|
@@ -9,6 +9,18 @@
|
|
|
9
9
|
* any page route handler fires.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
// REFUSE, DO NOT SKIP. `existsSync(routerPath)` being false used to fall straight through with appRouter=null and still register a client + log
|
|
13
|
+
// success. That client carries only the three platform routers below and NO CRUD models, which is indistinguishable from a healthy one until a
|
|
14
|
+
// page asks it for a model — at which point resolve-from-loader-config throws MissingLoaderModelCapabilityError, far from the actual fault.
|
|
15
|
+
//
|
|
16
|
+
// Measured 2026-08-20: scala-quiz and scala-forms served 500s on every data-backed page while /health returned 200 and this line logged "ORPC
|
|
17
|
+
// serverClient registered". The build had produced no generated router (its railpack.json ran registry codegen against an unreachable
|
|
18
|
+
// *.railway.internal host), and nothing anywhere said so.
|
|
19
|
+
//
|
|
20
|
+
// A service whose service_schema DECLARES models and has no generated router is not degraded, it is non-functional — so this fails the boot
|
|
21
|
+
// rather than reporting a state it has not verified (`a-component-may-not-report-a-state-it-has-not-verified`). A service
|
|
22
|
+
// that declares no models (daemon/portal shells owning no DB schema) still passes through cleanly.
|
|
23
|
+
|
|
12
24
|
import { join } from "node:path";
|
|
13
25
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
14
26
|
|
|
@@ -52,25 +64,6 @@ export async function registerPageServerClient(
|
|
|
52
64
|
);
|
|
53
65
|
}
|
|
54
66
|
}
|
|
55
|
-
// REFUSE, DO NOT SKIP. `existsSync(routerPath)` being false used to fall
|
|
56
|
-
// straight through with appRouter=null and still register a client + log
|
|
57
|
-
// success. That client carries only the three platform routers below and
|
|
58
|
-
// NO CRUD models, which is indistinguishable from a healthy one until a
|
|
59
|
-
// page asks it for a model — at which point resolve-from-loader-config
|
|
60
|
-
// throws MissingLoaderModelCapabilityError, far from the actual fault.
|
|
61
|
-
//
|
|
62
|
-
// Measured 2026-08-20: scala-quiz and scala-forms served 500s on every
|
|
63
|
-
// data-backed page while /health returned 200 and this line logged "ORPC
|
|
64
|
-
// serverClient registered". The build had produced no generated router
|
|
65
|
-
// (its railpack.json ran registry codegen against an unreachable
|
|
66
|
-
// *.railway.internal host), and nothing anywhere said so.
|
|
67
|
-
//
|
|
68
|
-
// A service whose service_schema DECLARES models and has no generated
|
|
69
|
-
// router is not degraded, it is non-functional — so this fails the boot
|
|
70
|
-
// rather than reporting a state it has not verified
|
|
71
|
-
// (`a-component-may-not-report-a-state-it-has-not-verified`). A service
|
|
72
|
-
// that declares no models (daemon/portal shells owning no DB schema)
|
|
73
|
-
// still passes through cleanly.
|
|
74
67
|
const declaredModels = await (async () => {
|
|
75
68
|
try {
|
|
76
69
|
const { loadRegistryConfig: loadCfg } = await import(
|
|
@@ -7,6 +7,19 @@
|
|
|
7
7
|
* panel renders the real conversation on first paint. Extracted from
|
|
8
8
|
* register-registry-page-route.ts (single-purpose-file split).
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* SSR-prefetch the signed-in user's AI-chat conversation so a CLICK-opened AI
|
|
12
|
+
* panel renders the real conversation on first paint — never a loading gate
|
|
13
|
+
* (ssr-then-live-handoff). The AiChat block lives on the /ai PANEL page, which
|
|
14
|
+
* isn't rendered when the panel is closed, so its block serverLoader can't run
|
|
15
|
+
* at page-load; this prefetches server-side (regardless of panel open-state) +
|
|
16
|
+
* stamps pageData.aiChatSession, which AiChat reads on mount. The agent is
|
|
17
|
+
* resolved from the /ai panel page's own AiChat block (single source of truth —
|
|
18
|
+
* no duplicate config to drift). Fired in parallel with the post-processors +
|
|
19
|
+
* bounded by their window; a failure degrades silently to the client-side
|
|
20
|
+
* createOrRestore fallback, never an error page.
|
|
21
|
+
*/
|
|
22
|
+
|
|
10
23
|
import { getServerClient } from "../page-server-orpc/server-client.ts";
|
|
11
24
|
|
|
12
25
|
/**
|
|
@@ -33,18 +46,6 @@ function findWebsiteId(root: unknown): string | undefined {
|
|
|
33
46
|
return undefined;
|
|
34
47
|
}
|
|
35
48
|
|
|
36
|
-
/**
|
|
37
|
-
* SSR-prefetch the signed-in user's AI-chat conversation so a CLICK-opened AI
|
|
38
|
-
* panel renders the real conversation on first paint — never a loading gate
|
|
39
|
-
* (ssr-then-live-handoff). The AiChat block lives on the /ai PANEL page, which
|
|
40
|
-
* isn't rendered when the panel is closed, so its block serverLoader can't run
|
|
41
|
-
* at page-load; this prefetches server-side (regardless of panel open-state) +
|
|
42
|
-
* stamps pageData.aiChatSession, which AiChat reads on mount. The agent is
|
|
43
|
-
* resolved from the /ai panel page's own AiChat block (single source of truth —
|
|
44
|
-
* no duplicate config to drift). Fired in parallel with the post-processors +
|
|
45
|
-
* bounded by their window; a failure degrades silently to the client-side
|
|
46
|
-
* createOrRestore fallback, never an error page.
|
|
47
|
-
*/
|
|
48
49
|
export async function prefetchAiChatSession(
|
|
49
50
|
resolved: Record<string, unknown>,
|
|
50
51
|
sessionUserId: string | undefined,
|
|
@@ -12,6 +12,65 @@
|
|
|
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
|
+
/**
|
|
32
|
+
* brand-is-org-owned-full-scheme, reconciled with page-org-first
|
|
33
|
+
* (portal-branding-resolves-page-org-first): the session override now
|
|
34
|
+
* personalizes ONLY platform pages (page org === website owning-org).
|
|
35
|
+
* Tenant pages keep their resolved page-org brand — the page is
|
|
36
|
+
* authoritative, the menu switcher changes which tenant's pages you
|
|
37
|
+
* view. Anonymous/public (no session) keeps the resolved brand. Same
|
|
38
|
+
* fetchOrganisationPageBranding pattern as the /sign-in brand override.
|
|
39
|
+
*
|
|
40
|
+
* A PUBLIC PAGE IS NEVER PERSONALISED TO THE READER'S ORG. Personalising
|
|
41
|
+
* the brand is right for a portal — you are looking at your own
|
|
42
|
+
* workspace. It is wrong for a public document, which belongs to the org
|
|
43
|
+
* that PUBLISHED it and must look identical to every reader.
|
|
44
|
+
*
|
|
45
|
+
* Measured 2026-08-17: the Launcha Authority Blueprint, owned by GMG
|
|
46
|
+
* APAC, rendered correctly for an anonymous customer and in GBG Indo's
|
|
47
|
+
* navy with the scala logo for any signed-in staff member — so the two
|
|
48
|
+
* people checking it before it went to clients were the only two who
|
|
49
|
+
* could not see what a client sees. Silent, and inverted: the reviewers
|
|
50
|
+
* got the wrong page.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* BRAND FROM THE ORG THAT OWNS THE RESOURCE, not the website's owner.
|
|
55
|
+
* A public resource page (an assessment report, a shared document) is
|
|
56
|
+
* hosted on a shared website but BELONGS to a tenant. resolvePageBrand
|
|
57
|
+
* reads pageData.orgId, which is the WEBSITE's org, so every tenant's
|
|
58
|
+
* report rendered the host org's logo and palette.
|
|
59
|
+
*
|
|
60
|
+
* Measured 2026-08-17: the Launcha Authority Blueprint is owned by GMG
|
|
61
|
+
* APAC (#FF4500, launcha logo) and rendered with GBG Indo's navy and
|
|
62
|
+
* scala mark, because both pages live on the shared "Public Pages"
|
|
63
|
+
* site. The loader had already resolved the right org — nothing
|
|
64
|
+
* consumed it.
|
|
65
|
+
*
|
|
66
|
+
* `brand_org_path` names where the owning org id lives in the resolved
|
|
67
|
+
* loader data (e.g. "quiz.organisation_id"), so which resource owns the
|
|
68
|
+
* brand is a ROW, never a per-service branch
|
|
69
|
+
* (`business-logic-is-rows-plus-executors`). Absent or unresolvable,
|
|
70
|
+
* the website's org stands — this can only ever narrow the brand to a
|
|
71
|
+
* more specific owner, never blank it.
|
|
72
|
+
*/
|
|
73
|
+
|
|
15
74
|
import { fetchOrganisationPageBranding } from "@teamscala/db/fetch-organisation-page-branding";
|
|
16
75
|
import { getPlatformOrgId } from "@teamscala/db/registry/platform";
|
|
17
76
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
@@ -43,21 +102,6 @@ function spreadBranding(
|
|
|
43
102
|
};
|
|
44
103
|
}
|
|
45
104
|
|
|
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
105
|
export async function applySignInBrandOverride(
|
|
62
106
|
resolved: Record<string, unknown>,
|
|
63
107
|
request: Request,
|
|
@@ -86,27 +130,6 @@ export async function applySignInBrandOverride(
|
|
|
86
130
|
}
|
|
87
131
|
}
|
|
88
132
|
|
|
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
133
|
export async function applySessionOrgBranding(
|
|
111
134
|
pageData: Record<string, unknown>,
|
|
112
135
|
renderMode: string | undefined,
|
|
@@ -141,26 +164,6 @@ export async function applySessionOrgBranding(
|
|
|
141
164
|
}
|
|
142
165
|
}
|
|
143
166
|
|
|
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
167
|
export async function applyBrandOrgPath(
|
|
165
168
|
pageData: Record<string, unknown>,
|
|
166
169
|
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,24 @@
|
|
|
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
|
+
|
|
25
43
|
import { createCache } from "@teamscala/cache/create-cache";
|
|
26
44
|
|
|
27
45
|
// The structural surface readCachedGraph touches (getWithMeta + refreshInBackground).
|
|
@@ -61,23 +79,6 @@ export const paramRouteGraphCache = createCache<unknown>(
|
|
|
61
79
|
{ maxSize: 256, ttlMs: 15_000, staleTtlMs: 285_000 },
|
|
62
80
|
);
|
|
63
81
|
|
|
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
82
|
export function readCachedGraph(
|
|
82
83
|
cache: GraphCache,
|
|
83
84
|
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,26 @@
|
|
|
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
|
+
|
|
16
36
|
import { interpolateMeta } from "../resolve-from-loader-config.ts";
|
|
17
37
|
|
|
18
38
|
export function buildMetaFromContext(
|
|
@@ -38,25 +58,6 @@ export function resolvePageMetaTitle(
|
|
|
38
58
|
);
|
|
39
59
|
}
|
|
40
60
|
|
|
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
61
|
export function resolveEntityTitle(resolved: Record<string, unknown>): string | undefined {
|
|
61
62
|
const pick = (key: string, ...fields: string[]): string | undefined => {
|
|
62
63
|
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,9 @@ 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
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
31
|
+
|
|
31
32
|
export function resolveAiOrgId(pathname: string): string | undefined {
|
|
32
33
|
const pathSegments = pathname.split("/").filter(Boolean);
|
|
33
34
|
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,8 @@ import { getServerClient } from "../page-server-orpc/server-client.ts";
|
|
|
27
24
|
* pages; other registry pages are untouched.
|
|
28
25
|
*/
|
|
29
26
|
|
|
27
|
+
import { getServerClient } from "../page-server-orpc/server-client.ts";
|
|
28
|
+
|
|
30
29
|
export async function applyApprovalEmailToken(
|
|
31
30
|
pageData: Record<string, unknown>,
|
|
32
31
|
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,18 @@ 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
|
+
export interface ResolverArgs {
|
|
35
|
+
input: Record<string, unknown>;
|
|
36
|
+
client: unknown;
|
|
37
|
+
params: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Canonical portal URL shape: /<websiteId>/<organisationId>/<builda path...>.
|
|
41
|
+
// Segment 0 is the portal WEBSITE id; segment 1 is the ORG id. Shared by the
|
|
42
|
+
// resolver below and by extractOrgIdFromCallbackPath (the /sign-in brand path).
|
|
43
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
44
|
+
|
|
44
45
|
export function extractOrgIdFromCallbackPath(callbackUrl: string): string | undefined {
|
|
45
46
|
const segs = callbackUrl.split("?")[0].split("/").filter(Boolean);
|
|
46
47
|
if (UUID_RE.test(segs[1] ?? "")) return segs[1];
|