@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.
Files changed (78) hide show
  1. package/package.json +12 -19
  2. package/src/agreement-gate/signing-shell.ts +19 -15
  3. package/src/attach-proposal-accept.ts +5 -2
  4. package/src/auth-gate/public-paths.ts +7 -16
  5. package/src/auth-gate.ts +45 -42
  6. package/src/bind-page-handler.ts +17 -32
  7. package/src/build-client-assets/build-lock.ts +15 -6
  8. package/src/build-client-assets/hashed-output.ts +3 -1
  9. package/src/build-client-assets/hydrate-bundle.ts +50 -57
  10. package/src/build-client-assets/input-fingerprint.ts +41 -57
  11. package/src/build-client-assets/input-hashing.ts +2 -4
  12. package/src/build-client-assets/manifest.ts +8 -11
  13. package/src/build-client-assets/source-candidates.ts +8 -16
  14. package/src/build-client-assets/sourcemaps-upload.ts +14 -10
  15. package/src/build-client-assets/styles-build.ts +20 -19
  16. package/src/build-client-assets.test.ts +2 -2
  17. package/src/build-client-assets.ts +13 -22
  18. package/src/content-gate/access.ts +5 -2
  19. package/src/content-gate/catalog.ts +20 -18
  20. package/src/content-gate/gate.ts +4 -3
  21. package/src/discover-blocks.ts +5 -7
  22. package/src/fn-routers/agreement-certificate.ts +1 -1
  23. package/src/fn-routers/agreement-send-router.ts +5 -4
  24. package/src/fn-routers/agreement-sign-side-effects.ts +24 -22
  25. package/src/fn-routers/assessment-report-email.ts +3 -2
  26. package/src/fn-routers/auth-router.ts +20 -21
  27. package/src/fn-routers/chat-session-router-shared.ts +12 -3
  28. package/src/fn-routers/chat-session-router.ts +8 -6
  29. package/src/fn-routers/conversation-ownership.ts +3 -2
  30. package/src/fn-routers/messaging-whatsapp-router.test.ts +3 -1
  31. package/src/fn-routers/messaging-whatsapp-router.ts +17 -3
  32. package/src/fn-routers/persist-chat-messages.ts +6 -4
  33. package/src/fn-routers/proposal-send-router.ts +4 -3
  34. package/src/fn-routers/public-agreement-router.ts +5 -4
  35. package/src/fn-routers/public-assessment-router.ts +5 -4
  36. package/src/fn-routers/public-gpt-session-router.ts +17 -12
  37. package/src/fn-routers/user-preferences-router.ts +6 -15
  38. package/src/fn-routers/work-items-router.ts +18 -17
  39. package/src/handlers/platform-fault-escalation/orpc-client.ts +35 -18
  40. package/src/handlers/projects-digest.ts +1 -1
  41. package/src/handlers/scala-tools-currency-watch.test.ts +1 -5
  42. package/src/handlers/scala-tools-currency-watch.ts +20 -32
  43. package/src/handlers/singleton-tools-currency-watch.test.ts +1 -5
  44. package/src/handlers/singleton-tools-currency-watch.ts +21 -19
  45. package/src/handlers/upstream-issue-sync.ts +11 -4
  46. package/src/handlers/whatsapp-liveness.ts +14 -4
  47. package/src/install-harness.ts +1 -1
  48. package/src/install-logging.ts +1 -1
  49. package/src/mount-orpc-router.test.ts +56 -10
  50. package/src/mount-orpc-router.ts +23 -9
  51. package/src/mount-server-packages.ts +14 -15
  52. package/src/pg-boss-mount.ts +5 -12
  53. package/src/proposal-gate.ts +7 -2
  54. package/src/read-html-template.test.ts +12 -15
  55. package/src/read-html-template.ts +2 -1
  56. package/src/readiness-gate.ts +2 -1
  57. package/src/register-crash-handlers.ts +4 -8
  58. package/src/register-page-server-client.ts +12 -19
  59. package/src/register-registry-page-route/ai-chat-prefetch.ts +13 -12
  60. package/src/register-registry-page-route/apply-page-branding.ts +59 -56
  61. package/src/register-registry-page-route/graph-cache.ts +19 -18
  62. package/src/register-registry-page-route/meta-title.ts +21 -20
  63. package/src/register-registry-page-route/prefetch-org-id.ts +3 -2
  64. package/src/register-registry-page-route/proposal-approval-email-token.ts +2 -3
  65. package/src/register-registry-page-route/run-content-gates.ts +2 -4
  66. package/src/register-registry-page-route.ts +1 -2
  67. package/src/register-static-assets.ts +6 -5
  68. package/src/resolve-app-base-url.ts +12 -5
  69. package/src/resolvers/portal-route-paths.ts +12 -11
  70. package/src/resource-chat-inject.ts +75 -72
  71. package/src/resource-not-found.ts +20 -19
  72. package/src/scala-tools-currency-watchdog.ts +41 -12
  73. package/src/two-phase-full-app.ts +52 -29
  74. package/src/two-phase-minimal-app.ts +33 -31
  75. package/src/two-phase-readiness.ts +41 -38
  76. package/src/ui-service.ts +41 -38
  77. package/src/handlers/pane-outbox-drain.ts +0 -39
  78. 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
- writeFileSync(
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 = mkdtempSync(join(tmpdir(), "read-html-template-"));
33
- mkdirSync(join(dir, "dist", "client"), { recursive: true });
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
- rmSync(dir, { recursive: true, force: true });
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
  }
@@ -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
- console.error("[readiness-gate] gate threw — failing open to handler", err);
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
- console.error(
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
- console.error(
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
- // It used to be a call inside each gate's onAuthorized plus an entry
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
- // 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
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
- let cachedAppUrl: string | null = null;
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
- if (cachedAppUrl) return cachedAppUrl;
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
- cachedAppUrl = `https://${config.public_hostname}`;
28
+ const url = `https://${config.public_hostname}`;
29
+ appUrlCache.set("default", url);
30
+ return url;
24
31
  } catch {
25
- cachedAppUrl = "https://projects.scala.business";
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];