@teamscala/service-boot 0.23.430 → 0.23.432

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) 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 +54 -42
  6. package/src/bind-page-handler.ts +20 -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 +59 -57
  10. package/src/build-client-assets/input-fingerprint.ts +50 -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 +17 -10
  15. package/src/build-client-assets/styles-build.ts +17 -19
  16. package/src/build-client-assets.test.ts +2 -2
  17. package/src/build-client-assets.ts +16 -22
  18. package/src/content-gate/access.ts +5 -2
  19. package/src/content-gate/catalog.ts +26 -18
  20. package/src/content-gate/gate.ts +7 -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 +27 -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 +21 -17
  39. package/src/handlers/platform-fault-escalation/delivery.ts +36 -0
  40. package/src/handlers/platform-fault-escalation/orpc-client.ts +38 -18
  41. package/src/handlers/platform-fault-escalation-logic.ts +26 -5
  42. package/src/handlers/platform-fault-escalation.test.ts +67 -15
  43. package/src/handlers/platform-fault-escalation.ts +16 -0
  44. package/src/handlers/projects-digest.ts +1 -1
  45. package/src/handlers/scala-tools-currency-watch.test.ts +1 -5
  46. package/src/handlers/scala-tools-currency-watch.ts +23 -32
  47. package/src/handlers/singleton-tools-currency-watch.test.ts +1 -5
  48. package/src/handlers/singleton-tools-currency-watch.ts +27 -19
  49. package/src/handlers/upstream-issue-sync.ts +11 -4
  50. package/src/handlers/whatsapp-liveness.ts +14 -4
  51. package/src/install-harness.ts +1 -1
  52. package/src/install-logging.ts +1 -1
  53. package/src/mount-orpc-router.test.ts +56 -10
  54. package/src/mount-orpc-router.ts +23 -9
  55. package/src/mount-server-packages.ts +17 -15
  56. package/src/pg-boss-mount.ts +5 -12
  57. package/src/proposal-gate.ts +7 -2
  58. package/src/read-html-template.test.ts +12 -15
  59. package/src/read-html-template.ts +2 -1
  60. package/src/readiness-gate.ts +2 -1
  61. package/src/register-crash-handlers.ts +4 -8
  62. package/src/register-page-server-client.ts +15 -19
  63. package/src/register-registry-page-route/ai-chat-prefetch.ts +16 -12
  64. package/src/register-registry-page-route/apply-page-branding.ts +68 -56
  65. package/src/register-registry-page-route/graph-cache.ts +22 -18
  66. package/src/register-registry-page-route/meta-title.ts +24 -20
  67. package/src/register-registry-page-route/prefetch-org-id.ts +6 -2
  68. package/src/register-registry-page-route/proposal-approval-email-token.ts +5 -3
  69. package/src/register-registry-page-route/run-content-gates.ts +2 -4
  70. package/src/register-registry-page-route.ts +1 -2
  71. package/src/register-static-assets.ts +6 -5
  72. package/src/resolve-app-base-url.ts +12 -5
  73. package/src/resolvers/portal-route-paths.ts +15 -11
  74. package/src/resource-chat-inject.ts +84 -72
  75. package/src/resource-not-found.ts +23 -19
  76. package/src/scala-tools-currency-watchdog.ts +41 -12
  77. package/src/two-phase-full-app.ts +61 -29
  78. package/src/two-phase-minimal-app.ts +39 -31
  79. package/src/two-phase-readiness.ts +50 -38
  80. package/src/ui-service.ts +50 -38
  81. package/src/handlers/pane-outbox-drain.ts +0 -39
  82. package/src/handlers/prune-stale-data.ts +0 -110
@@ -12,6 +12,74 @@
12
12
  * explicit parameters.
13
13
  */
14
14
 
15
+ /**
16
+ * /sign-in brand override: a shared org-scoped link 302→/sign-in for the
17
+ * unauthed crawler (WhatsApp), which saw the service's default website
18
+ * brand (the platform owner). The callbackURL carries the destination org
19
+ * — override resolved.brand (before the title/meta/og are built from it)
20
+ * so the page reflects the org the visitor is signing in to. Scoped to
21
+ * /sign-in; resource pages keep their own brand.
22
+ *
23
+ * The org is segment 1 of the canonical portal shape /<websiteId>/<orgId>/
24
+ * <path>, NOT segment 0 (the website id): reading segment 0 fed a website id
25
+ * into fetchOrganisationPageBranding, which resolved no org and left the
26
+ * default (super-admin) brand — the cross-tenant mis-branding this override
27
+ * exists to prevent (portal-branding-resolves-page-org-first, 2026-07-21).
28
+ * Shared helper so this and bind-page-handler cannot drift on the answer.
29
+ */
30
+
31
+ // Why: /sign-in brand override: a shared org-scoped link 302→/sign-…
32
+ // (full note hoisted to this file's leading header — kept out of the body per
33
+ // no-inline-prose-blocks / prose-stays-in-docs)
34
+ /**
35
+ * brand-is-org-owned-full-scheme, reconciled with page-org-first
36
+ * (portal-branding-resolves-page-org-first): the session override now
37
+ * personalizes ONLY platform pages (page org === website owning-org).
38
+ * Tenant pages keep their resolved page-org brand — the page is
39
+ * authoritative, the menu switcher changes which tenant's pages you
40
+ * view. Anonymous/public (no session) keeps the resolved brand. Same
41
+ * fetchOrganisationPageBranding pattern as the /sign-in brand override.
42
+ *
43
+ * A PUBLIC PAGE IS NEVER PERSONALISED TO THE READER'S ORG. Personalising
44
+ * the brand is right for a portal — you are looking at your own
45
+ * workspace. It is wrong for a public document, which belongs to the org
46
+ * that PUBLISHED it and must look identical to every reader.
47
+ *
48
+ * Measured 2026-08-17: the Launcha Authority Blueprint, owned by GMG
49
+ * APAC, rendered correctly for an anonymous customer and in GBG Indo's
50
+ * navy with the scala logo for any signed-in staff member — so the two
51
+ * people checking it before it went to clients were the only two who
52
+ * could not see what a client sees. Silent, and inverted: the reviewers
53
+ * got the wrong page.
54
+ */
55
+
56
+ // Why: brand-is-org-owned-full-scheme, reconciled with page-org-fir…
57
+ // (full note hoisted to this file's leading header — kept out of the body per
58
+ // no-inline-prose-blocks / prose-stays-in-docs)
59
+ /**
60
+ * BRAND FROM THE ORG THAT OWNS THE RESOURCE, not the website's owner.
61
+ * A public resource page (an assessment report, a shared document) is
62
+ * hosted on a shared website but BELONGS to a tenant. resolvePageBrand
63
+ * reads pageData.orgId, which is the WEBSITE's org, so every tenant's
64
+ * report rendered the host org's logo and palette.
65
+ *
66
+ * Measured 2026-08-17: the Launcha Authority Blueprint is owned by GMG
67
+ * APAC (#FF4500, launcha logo) and rendered with GBG Indo's navy and
68
+ * scala mark, because both pages live on the shared "Public Pages"
69
+ * site. The loader had already resolved the right org — nothing
70
+ * consumed it.
71
+ *
72
+ * `brand_org_path` names where the owning org id lives in the resolved
73
+ * loader data (e.g. "quiz.organisation_id"), so which resource owns the
74
+ * brand is a ROW, never a per-service branch
75
+ * (`business-logic-is-rows-plus-executors`). Absent or unresolvable,
76
+ * the website's org stands — this can only ever narrow the brand to a
77
+ * more specific owner, never blank it.
78
+ */
79
+
80
+ // Why: BRAND FROM THE ORG THAT OWNS THE RESOURCE, not the website's…
81
+ // (full note hoisted to this file's leading header — kept out of the body per
82
+ // no-inline-prose-blocks / prose-stays-in-docs)
15
83
  import { fetchOrganisationPageBranding } from "@teamscala/db/fetch-organisation-page-branding";
16
84
  import { getPlatformOrgId } from "@teamscala/db/registry/platform";
17
85
  import { getAppLogger } from "@teamscala/logger/app-loggers";
@@ -43,21 +111,6 @@ function spreadBranding(
43
111
  };
44
112
  }
45
113
 
46
- /**
47
- * /sign-in brand override: a shared org-scoped link 302→/sign-in for the
48
- * unauthed crawler (WhatsApp), which saw the service's default website
49
- * brand (the platform owner). The callbackURL carries the destination org
50
- * — override resolved.brand (before the title/meta/og are built from it)
51
- * so the page reflects the org the visitor is signing in to. Scoped to
52
- * /sign-in; resource pages keep their own brand.
53
- *
54
- * The org is segment 1 of the canonical portal shape /<websiteId>/<orgId>/
55
- * <path>, NOT segment 0 (the website id): reading segment 0 fed a website id
56
- * into fetchOrganisationPageBranding, which resolved no org and left the
57
- * default (super-admin) brand — the cross-tenant mis-branding this override
58
- * exists to prevent (portal-branding-resolves-page-org-first, 2026-07-21).
59
- * Shared helper so this and bind-page-handler cannot drift on the answer.
60
- */
61
114
  export async function applySignInBrandOverride(
62
115
  resolved: Record<string, unknown>,
63
116
  request: Request,
@@ -86,27 +139,6 @@ export async function applySignInBrandOverride(
86
139
  }
87
140
  }
88
141
 
89
- /**
90
- * brand-is-org-owned-full-scheme, reconciled with page-org-first
91
- * (portal-branding-resolves-page-org-first): the session override now
92
- * personalizes ONLY platform pages (page org === website owning-org).
93
- * Tenant pages keep their resolved page-org brand — the page is
94
- * authoritative, the menu switcher changes which tenant's pages you
95
- * view. Anonymous/public (no session) keeps the resolved brand. Same
96
- * fetchOrganisationPageBranding pattern as the /sign-in brand override.
97
- *
98
- * A PUBLIC PAGE IS NEVER PERSONALISED TO THE READER'S ORG. Personalising
99
- * the brand is right for a portal — you are looking at your own
100
- * workspace. It is wrong for a public document, which belongs to the org
101
- * that PUBLISHED it and must look identical to every reader.
102
- *
103
- * Measured 2026-08-17: the Launcha Authority Blueprint, owned by GMG
104
- * APAC, rendered correctly for an anonymous customer and in GBG Indo's
105
- * navy with the scala logo for any signed-in staff member — so the two
106
- * people checking it before it went to clients were the only two who
107
- * could not see what a client sees. Silent, and inverted: the reviewers
108
- * got the wrong page.
109
- */
110
142
  export async function applySessionOrgBranding(
111
143
  pageData: Record<string, unknown>,
112
144
  renderMode: string | undefined,
@@ -141,26 +173,6 @@ export async function applySessionOrgBranding(
141
173
  }
142
174
  }
143
175
 
144
- /**
145
- * BRAND FROM THE ORG THAT OWNS THE RESOURCE, not the website's owner.
146
- * A public resource page (an assessment report, a shared document) is
147
- * hosted on a shared website but BELONGS to a tenant. resolvePageBrand
148
- * reads pageData.orgId, which is the WEBSITE's org, so every tenant's
149
- * report rendered the host org's logo and palette.
150
- *
151
- * Measured 2026-08-17: the Launcha Authority Blueprint is owned by GMG
152
- * APAC (#FF4500, launcha logo) and rendered with GBG Indo's navy and
153
- * scala mark, because both pages live on the shared "Public Pages"
154
- * site. The loader had already resolved the right org — nothing
155
- * consumed it.
156
- *
157
- * `brand_org_path` names where the owning org id lives in the resolved
158
- * loader data (e.g. "quiz.organisation_id"), so which resource owns the
159
- * brand is a ROW, never a per-service branch
160
- * (`business-logic-is-rows-plus-executors`). Absent or unresolvable,
161
- * the website's org stands — this can only ever narrow the brand to a
162
- * more specific owner, never blank it.
163
- */
164
176
  export async function applyBrandOrgPath(
165
177
  pageData: Record<string, unknown>,
166
178
  resolved: Record<string, unknown>,
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @system ui-service-boot
3
- * @status handwritten @indivisible-unit
3
+ * @status handwritten @indivisible-unit owner=unassigned reason=91 lines; pre-2026-08-27 exemption, unreviewed
4
4
  * @edit edit directly
5
5
  *
6
6
  * The two SWR loader-graph caches (static-shell + parameterised routes) for the
@@ -22,6 +22,27 @@
22
22
  * so handing a cached tree out twice would ship request A's resolved data to
23
23
  * request B (ssr-first-tick-parity). Clone-on-get stays local to the caller.
24
24
  */
25
+ /**
26
+ * Stale-while-revalidate read of a cached loader graph.
27
+ *
28
+ * - Fresh hit (within ttlMs): return the cached graph, no refresh.
29
+ * - Stale hit (within ttlMs + staleTtlMs): return the cached graph AND fire ONE
30
+ * background refresh via cache.refreshInBackground — single-flight (deduped
31
+ * against the cache's in-flight map), never rejects, leaves the stale entry in
32
+ * place on failure. The caller deep-clones the returned value before use.
33
+ * - Miss (past staleTtlMs, or never cached): return undefined — the caller
34
+ * resolves synchronously and cache.set()s the clone.
35
+ *
36
+ * The refresh callback must return a graph safe to store. To SKIP caching a
37
+ * refresh result (e.g. a primary-missing / dead-link graph that must stay
38
+ * per-request so a just-created record appears immediately), the callback THROWS
39
+ * — refreshInBackground catches it, leaves the stale entry, and logs one line
40
+ * (an observable dead-link signal, not a hard error).
41
+ */
42
+
43
+ // Why: Stale-while-revalidate read of a cached loader graph.
44
+ // (full note hoisted to this file's leading header — kept out of the body per
45
+ // no-inline-prose-blocks / prose-stays-in-docs)
25
46
  import { createCache } from "@teamscala/cache/create-cache";
26
47
 
27
48
  // The structural surface readCachedGraph touches (getWithMeta + refreshInBackground).
@@ -61,23 +82,6 @@ export const paramRouteGraphCache = createCache<unknown>(
61
82
  { maxSize: 256, ttlMs: 15_000, staleTtlMs: 285_000 },
62
83
  );
63
84
 
64
- /**
65
- * Stale-while-revalidate read of a cached loader graph.
66
- *
67
- * - Fresh hit (within ttlMs): return the cached graph, no refresh.
68
- * - Stale hit (within ttlMs + staleTtlMs): return the cached graph AND fire ONE
69
- * background refresh via cache.refreshInBackground — single-flight (deduped
70
- * against the cache's in-flight map), never rejects, leaves the stale entry in
71
- * place on failure. The caller deep-clones the returned value before use.
72
- * - Miss (past staleTtlMs, or never cached): return undefined — the caller
73
- * resolves synchronously and cache.set()s the clone.
74
- *
75
- * The refresh callback must return a graph safe to store. To SKIP caching a
76
- * refresh result (e.g. a primary-missing / dead-link graph that must stay
77
- * per-request so a just-created record appears immediately), the callback THROWS
78
- * — refreshInBackground catches it, leaves the stale entry, and logs one line
79
- * (an observable dead-link signal, not a hard error).
80
- */
81
85
  export function readCachedGraph(
82
86
  cache: GraphCache,
83
87
  key: string,
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @system ui-service-boot
3
- * @status handwritten @indivisible-unit
3
+ * @status handwritten @indivisible-unit owner=unassigned reason=82 lines; pre-2026-08-27 exemption, unreviewed
4
4
  * @edit edit directly
5
5
  *
6
6
  * Meta/title helpers for the route registrar: interpolate the route's
@@ -13,6 +13,29 @@
13
13
  * by construction, dead on exactly the pages the type exists for (Valencia's
14
14
  * finding). composePageTitle (+ the org-prefix flag) owns titles end to end.
15
15
  */
16
+ /**
17
+ * THE ONE SUBJECT DERIVATION. What a page is ABOUT — the entity whose name the
18
+ * browser tab should carry.
19
+ *
20
+ * This existed as THREE hand-written `||` chains, one per render branch in
21
+ * resolve-page-data, and they had already diverged: two ordered form before
22
+ * proposal, the third ordered proposal first. So which resource named the page
23
+ * depended on which branch happened to render it, silently.
24
+ *
25
+ * The divergence is the cheap half of the cost. The expensive half is that a
26
+ * correction has to be applied three times to work, and applying it twice looks
27
+ * exactly like applying it correctly — measured 2026-08-17, when a fix to the
28
+ * quiz subject was shipped, installed and verified present in the bundle while
29
+ * the page kept its old title, because the branch that rendered it held the
30
+ * second copy.
31
+ *
32
+ * One chain, one precedence, one place to change. Adding a resource type is an
33
+ * edit here rather than a fourth branch in three files.
34
+ */
35
+
36
+ // Why: THE ONE SUBJECT DERIVATION. What a page is ABOUT — the entit…
37
+ // (full note hoisted to this file's leading header — kept out of the body per
38
+ // no-inline-prose-blocks / prose-stays-in-docs)
16
39
  import { interpolateMeta } from "../resolve-from-loader-config.ts";
17
40
 
18
41
  export function buildMetaFromContext(
@@ -38,25 +61,6 @@ export function resolvePageMetaTitle(
38
61
  );
39
62
  }
40
63
 
41
- /**
42
- * THE ONE SUBJECT DERIVATION. What a page is ABOUT — the entity whose name the
43
- * browser tab should carry.
44
- *
45
- * This existed as THREE hand-written `||` chains, one per render branch in
46
- * resolve-page-data, and they had already diverged: two ordered form before
47
- * proposal, the third ordered proposal first. So which resource named the page
48
- * depended on which branch happened to render it, silently.
49
- *
50
- * The divergence is the cheap half of the cost. The expensive half is that a
51
- * correction has to be applied three times to work, and applying it twice looks
52
- * exactly like applying it correctly — measured 2026-08-17, when a fix to the
53
- * quiz subject was shipped, installed and verified present in the bundle while
54
- * the page kept its old title, because the branch that rendered it held the
55
- * second copy.
56
- *
57
- * One chain, one precedence, one place to change. Adding a resource type is an
58
- * edit here rather than a fourth branch in three files.
59
- */
60
64
  export function resolveEntityTitle(resolved: Record<string, unknown>): string | undefined {
61
65
  const pick = (key: string, ...fields: string[]): string | undefined => {
62
66
  const row = resolved[key] as Record<string, unknown> | undefined;
@@ -9,8 +9,6 @@
9
9
  * register-registry-page-route.ts.
10
10
  */
11
11
 
12
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
-
14
12
  /**
15
13
  * Prefer segment 1; fall back to segment 0 only when segment 1 is not a
16
14
  * UUID (the single-UUID shape, which is live and supported — NOT
@@ -28,6 +26,12 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
28
26
  * UUIDs: the prefetch simply warmed a session against the wrong tenant
29
27
  * (found 2026-07-21).
30
28
  */
29
+
30
+ // Why: Prefer segment 1; fall back to segment 0 only when segment 1…
31
+ // (full note hoisted to this file's leading header — kept out of the body per
32
+ // no-inline-prose-blocks / prose-stays-in-docs)
33
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
34
+
31
35
  export function resolveAiOrgId(pathname: string): string | undefined {
32
36
  const pathSegments = pathname.split("/").filter(Boolean);
33
37
  return UUID_RE.test(pathSegments[1] ?? "")
@@ -12,9 +12,6 @@
12
12
  * stamping that touch no network. Sharing a file made an I/O-bound resolver
13
13
  * look like a pure formatter.
14
14
  */
15
- import { getServerClient } from "../page-server-orpc/server-client.ts";
16
-
17
-
18
15
  /**
19
16
  * Resolve the `{{approvalEmail}}` placeholder in a proposal page's button props
20
17
  * (e.g. the Approve CTA) in place. Routing: the organisation's registered
@@ -27,6 +24,11 @@ import { getServerClient } from "../page-server-orpc/server-client.ts";
27
24
  * pages; other registry pages are untouched.
28
25
  */
29
26
 
27
+ // Why: Resolve the `{{approvalEmail}}` placeholder in a proposal pa…
28
+ // (full note hoisted to this file's leading header — kept out of the body per
29
+ // no-inline-prose-blocks / prose-stays-in-docs)
30
+ import { getServerClient } from "../page-server-orpc/server-client.ts";
31
+
30
32
  export async function applyApprovalEmailToken(
31
33
  pageData: Record<string, unknown>,
32
34
  proposal: { created_by?: string } | undefined,
@@ -50,10 +50,8 @@ export async function runContentGatesForPage(
50
50
  if (withholdsContent(entry)) gateOutcomes.push(outcome);
51
51
  }
52
52
 
53
- // THE ASSISTANT IS A ROW, AND THIS IS THE ONE PLACE IT IS APPLIED.
54
- // 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,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,