@teamscala/service-boot 0.23.431 → 0.23.432

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +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/orpc-client.ts +38 -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 +23 -32
  43. package/src/handlers/singleton-tools-currency-watch.test.ts +1 -5
  44. package/src/handlers/singleton-tools-currency-watch.ts +27 -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 +17 -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 +15 -19
  59. package/src/register-registry-page-route/ai-chat-prefetch.ts +16 -12
  60. package/src/register-registry-page-route/apply-page-branding.ts +68 -56
  61. package/src/register-registry-page-route/graph-cache.ts +22 -18
  62. package/src/register-registry-page-route/meta-title.ts +24 -20
  63. package/src/register-registry-page-route/prefetch-org-id.ts +6 -2
  64. package/src/register-registry-page-route/proposal-approval-email-token.ts +5 -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 +15 -11
  70. package/src/resource-chat-inject.ts +84 -72
  71. package/src/resource-not-found.ts +23 -19
  72. package/src/scala-tools-currency-watchdog.ts +41 -12
  73. package/src/two-phase-full-app.ts +61 -29
  74. package/src/two-phase-minimal-app.ts +39 -31
  75. package/src/two-phase-readiness.ts +50 -38
  76. package/src/ui-service.ts +50 -38
  77. package/src/handlers/pane-outbox-drain.ts +0 -39
  78. package/src/handlers/prune-stale-data.ts +0 -110
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.431",
9
+ "version": "0.23.432",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -16,11 +16,11 @@
16
16
  "src"
17
17
  ],
18
18
  "dependencies": {
19
+ "@orpc/server": "*",
19
20
  "@teamscala/agreement-generation": "*",
20
21
  "@teamscala/api-resend": "*",
21
22
  "@teamscala/auth": "*",
22
23
  "@teamscala/cache": "*",
23
- "@teamscala/channel": "*",
24
24
  "@teamscala/chat-completion": "*",
25
25
  "@teamscala/db": "*",
26
26
  "@teamscala/db-validation": "*",
@@ -32,8 +32,10 @@
32
32
  "@teamscala/file-lock": "*",
33
33
  "@teamscala/formatters": "*",
34
34
  "@teamscala/generic-registry": "*",
35
+ "@teamscala/host-command-bus": "*",
35
36
  "@teamscala/http": "*",
36
37
  "@teamscala/logger": "*",
38
+ "@teamscala/mcp-multi-session": "*",
37
39
  "@teamscala/notify-cache": "*",
38
40
  "@teamscala/object-storage": "*",
39
41
  "@teamscala/orpc": "*",
@@ -45,7 +47,6 @@
45
47
  "@teamscala/timing": "*",
46
48
  "@teamscala/tool-executor": "*",
47
49
  "@teamscala/watchdog": "*",
48
- "@orpc/server": "*",
49
50
  "hono": "*",
50
51
  "pg-boss": "*",
51
52
  "tailwindcss": "*",
@@ -653,14 +654,6 @@
653
654
  "types": "./src/handlers/messaging-send-sweep-selection.d.ts",
654
655
  "default": "./src/handlers/messaging-send-sweep-selection.ts"
655
656
  },
656
- "./handlers/pane-outbox-drain": {
657
- "types": "./src/handlers/pane-outbox-drain.d.ts",
658
- "default": "./src/handlers/pane-outbox-drain.ts"
659
- },
660
- "./handlers/pane-outbox-drain.ts": {
661
- "types": "./src/handlers/pane-outbox-drain.d.ts",
662
- "default": "./src/handlers/pane-outbox-drain.ts"
663
- },
664
657
  "./handlers/platform-fault-escalation": {
665
658
  "types": "./src/handlers/platform-fault-escalation.d.ts",
666
659
  "default": "./src/handlers/platform-fault-escalation.ts"
@@ -709,6 +702,14 @@
709
702
  "types": "./src/handlers/platform-fault-escalation/orpc-client.d.ts",
710
703
  "default": "./src/handlers/platform-fault-escalation/orpc-client.ts"
711
704
  },
705
+ "./handlers/platform-fault-escalation/probe": {
706
+ "types": "./src/handlers/platform-fault-escalation/probe.d.ts",
707
+ "default": "./src/handlers/platform-fault-escalation/probe.ts"
708
+ },
709
+ "./handlers/platform-fault-escalation/probe.ts": {
710
+ "types": "./src/handlers/platform-fault-escalation/probe.d.ts",
711
+ "default": "./src/handlers/platform-fault-escalation/probe.ts"
712
+ },
712
713
  "./handlers/projects-digest": {
713
714
  "types": "./src/handlers/projects-digest.d.ts",
714
715
  "default": "./src/handlers/projects-digest.ts"
@@ -717,14 +718,6 @@
717
718
  "types": "./src/handlers/projects-digest.d.ts",
718
719
  "default": "./src/handlers/projects-digest.ts"
719
720
  },
720
- "./handlers/prune-stale-data": {
721
- "types": "./src/handlers/prune-stale-data.d.ts",
722
- "default": "./src/handlers/prune-stale-data.ts"
723
- },
724
- "./handlers/prune-stale-data.ts": {
725
- "types": "./src/handlers/prune-stale-data.d.ts",
726
- "default": "./src/handlers/prune-stale-data.ts"
727
- },
728
721
  "./handlers/registry-stale-inactive-sweep": {
729
722
  "types": "./src/handlers/registry-stale-inactive-sweep.d.ts",
730
723
  "default": "./src/handlers/registry-stale-inactive-sweep.ts"
@@ -12,6 +12,25 @@
12
12
  * paths adjusted only.
13
13
  */
14
14
 
15
+ /**
16
+ * The body content array for the agreement. The public shell composes the body
17
+ * region FROM resolved.page (register-registry-page-route.ts Branch 2:
18
+ * `bodyPuck = extractPuckData(resolved.page)`, returned un-cloned), so this IS
19
+ * the live array whose mutation reaches the SSR render. Header/footer are
20
+ * separate region sources (resolved.header / resolved.footer) — never the body.
21
+ *
22
+ * A prior version scored every region by top-level non-shell block count and
23
+ * picked the highest. That misroutes when the body wraps its clauses inside a
24
+ * Container (body's top-level count is ~2 while its nested clause count is ~40):
25
+ * a /header region with more top-level blocks out-scores the body, so
26
+ * onUnauthorized cleared the HEADER and injected the gate there, leaving the
27
+ * entire agreement body visible past the gate. The body is the resource page —
28
+ * identified, not heuristically guessed.
29
+ */
30
+
31
+ // Why: The body content array for the agreement. The public shell c…
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)
15
34
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
16
35
  import * as v from "valibot";
17
36
  import { findAgreementGate } from "./signing-session.ts";
@@ -29,21 +48,6 @@ export function stashAgreementMeta(
29
48
  resolved[AGREEMENT_META_KEY] = { id, name };
30
49
  }
31
50
 
32
- /**
33
- * The body content array for the agreement. The public shell composes the body
34
- * region FROM resolved.page (register-registry-page-route.ts Branch 2:
35
- * `bodyPuck = extractPuckData(resolved.page)`, returned un-cloned), so this IS
36
- * the live array whose mutation reaches the SSR render. Header/footer are
37
- * separate region sources (resolved.header / resolved.footer) — never the body.
38
- *
39
- * A prior version scored every region by top-level non-shell block count and
40
- * picked the highest. That misroutes when the body wraps its clauses inside a
41
- * Container (body's top-level count is ~2 while its nested clause count is ~40):
42
- * a /header region with more top-level blocks out-scores the body, so
43
- * onUnauthorized cleared the HEADER and injected the gate there, leaving the
44
- * entire agreement body visible past the gate. The body is the resource page —
45
- * identified, not heuristically guessed.
46
- */
47
51
  export function getBodyContentArray(
48
52
  resolved: Record<string, unknown>,
49
53
  ): ContentBlock[] | null {
@@ -57,6 +57,7 @@ export async function attachProposalAccept(
57
57
  app.post("/api/proposal-accept", async (c) => {
58
58
  let proposalId = "";
59
59
  let cartSelection: unknown = null;
60
+ let bodyFailed = false;
60
61
  try {
61
62
  const form = await c.req.parseBody();
62
63
  proposalId = String(form.proposalId ?? "").trim();
@@ -64,9 +65,11 @@ export async function attachProposalAccept(
64
65
  if (typeof raw === "string" && raw.trim()) {
65
66
  cartSelection = JSON.parse(raw);
66
67
  }
67
- } catch {
68
- return c.redirect("/", 302);
68
+ } catch (error) {
69
+ log.warn(`[proposal-accept] body parse failed — redirecting home: ${String(error)}`);
70
+ bodyFailed = true;
69
71
  }
72
+ if (bodyFailed) return c.redirect("/", 302);
70
73
  const back = backToProposal(proposalId);
71
74
  if (!proposalId) return c.redirect("/", 302);
72
75
 
@@ -22,22 +22,13 @@ export const SIGN_IN_PATHS = new Set([
22
22
  "/magic-link",
23
23
  ]);
24
24
 
25
- /// A STATIC ASSET IS NEVER A PROTECTED RESOURCE, so it is decided by EXTENSION
26
- /// rather than by remembering to list it.
27
- ///
28
- /// The allowlist below enumerates PREFIXES, and every path outside it is gated.
29
- /// That makes a new public asset directory private BY DEFAULT, and the failure
30
- /// lands exactly where it hurts most: on the SIGNED-OUT screen, which is the one
31
- /// page guaranteed to need branding. `/assets/` was listed; `/logos/` never was,
32
- /// so `/logos/cropped.png` answered 302 → /sign-in and the browser rendered a
33
- /// blank logo under a "starting up" splash. Favicons are on the list ONE FILE AT
34
- /// A TIME, which is the same defect already visible.
35
- ///
36
- /// This is why the operator reports it "keeps regressing": nothing is broken by
37
- /// a code change, the allowlist simply never covered the path, and any build that
38
- /// moves images re-creates it. Enumerating what is public is `prevention-over-
39
- /// detection` inverted — the cure is a predicate, not a longer list.
40
- ///
25
+ /// A STATIC ASSET IS NEVER A PROTECTED RESOURCE, so it is decided by EXTENSION / rather than by remembering to list it.
26
+ /// / The allowlist below enumerates PREFIXES, and every path outside it is gated. / That makes a new public asset directory private BY DEFAULT, and the failure
27
+ /// lands exactly where it hurts most: on the SIGNED-OUT screen, which is the one / page guaranteed to need branding. `/assets/` was listed; `/logos/` never was,
28
+ /// so `/logos/cropped.png` answered 302 → /sign-in and the browser rendered a / blank logo under a "starting up" splash. Favicons are on the list ONE FILE AT
29
+ /// A TIME, which is the same defect already visible. / / This is why the operator reports it "keeps regressing": nothing is broken by
30
+ /// a code change, the allowlist simply never covered the path, and any build that / moves images re-creates it. Enumerating what is public is `prevention-over-
31
+ /// detection` inverted — the cure is a predicate, not a longer list. /
41
32
  /// Redirecting an <img> to a login page is never useful behaviour either: the
42
33
  /// browser cannot act on the redirect, so it silently paints nothing. Failing
43
34
  /// closed on a page is protection; failing closed on an image is just a blank.
package/src/auth-gate.ts CHANGED
@@ -24,24 +24,6 @@
24
24
  * the local better-auth handler is used (dev-host fallback).
25
25
  */
26
26
 
27
- import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
28
- import { getPrisma } from "@teamscala/db/prisma-registry";
29
- import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
30
- import { resolveAccess } from "@teamscala/auth/resolve-access";
31
- import * as v from "valibot";
32
- import { configure as configureEventLog } from "@teamscala/event-log/configure";
33
- import { getAppLogger } from "@teamscala/logger/app-loggers";
34
- import type { Hono } from "hono";
35
-
36
- import {
37
- isStaticAssetPath,
38
- PUBLIC_INFRA_PATHS,
39
- PUBLIC_INFRA_PREFIXES,
40
- SIGN_IN_PATHS,
41
- } from "./auth-gate/public-paths.ts";
42
-
43
- export { isStaticAssetPath } from "./auth-gate/public-paths.ts";
44
-
45
27
  /**
46
28
  * Point the event-log primitive at the process's Prisma singleton.
47
29
  *
@@ -61,6 +43,60 @@ export { isStaticAssetPath } from "./auth-gate/public-paths.ts";
61
43
  * unrelated edit to this file. Naming the boot step honestly is the fix. The
62
44
  * registry refused a path exemption for it, correctly — `guard-exemptions-to-zero`.
63
45
  */
46
+
47
+ // Why: Point the event-log primitive at the process's Prisma single…
48
+ // (full note hoisted to this file's leading header — kept out of the body per
49
+ // no-inline-prose-blocks / prose-stays-in-docs)
50
+ // Eager-warm the local BetterAuth runtime at boot whenever a request path
51
+ // will build it: the session-READ path (getSession, better-auth-cookie mode)
52
+ // always uses the local instance — even when /api/auth/* forwards to
53
+ // scala-otp — and the local /api/auth handler uses it when no otpUrl is set.
54
+ // Building lazily on the first such request races a node_modules rebuild
55
+ // window: Bun transpiles better-auth on demand, and against a half-written
56
+ // tree that throws ENOENT → 500 on every auth + session check while /health
57
+ // stays green (nothing restarts it) — the 2026-06-12 login outage. Building
58
+ // here resolves the better-auth module graph once while node_modules is
59
+ // stable, and getAuth() memoizes it for the process lifetime. Best-effort and
60
+ // non-fatal: getAuth() never caches a failed build, so if this warm fails
61
+ // (e.g. mid-install) the first successful request rebuilds and caches — the
62
+ // service is never left wedged, and never boot-loops on a transient.
63
+
64
+ // Why: Eager-warm the local BetterAuth runtime at boot whenever a r…
65
+ // (full note hoisted to this file's leading header — kept out of the body per
66
+ // no-inline-prose-blocks / prose-stays-in-docs)
67
+ // NO localhost bypass: a 127.0.0.1 page request is gated EXACTLY like a
68
+ // tunnel request (no session → redirect to /sign-in). The old
69
+ // `if (isLocalhost) return next()` let any tool — and the agent's own
70
+ // chrome-devtools/curl — see authed pages on localhost WITHOUT a session,
71
+ // so verification on 127.0.0.1 diverged from what a signed-in user sees
72
+ // through the domain (the CSS regression that hid for three runs,
73
+ // 2026-06-13). The bypass is redundant: anything needing an authed view
74
+ // signs in (the OTP is DB-readable on the dev host) — host-side harnesses
75
+ // use getSuperAdminSessionCookie (scala-tools/lib). Internal
76
+ // service-to-service auth still flows through the cookie/secret on the
77
+ // request, never the source IP.
78
+
79
+ // Why: NO localhost bypass: a 127.0.0.1 page request is gated EXACT…
80
+ // (full note hoisted to this file's leading header — kept out of the body per
81
+ // no-inline-prose-blocks / prose-stays-in-docs)
82
+ import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
83
+ import { getPrisma } from "@teamscala/db/prisma-registry";
84
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
85
+ import { resolveAccess } from "@teamscala/auth/resolve-access";
86
+ import * as v from "valibot";
87
+ import { configure as configureEventLog } from "@teamscala/event-log/configure";
88
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
89
+ import type { Hono } from "hono";
90
+
91
+ import {
92
+ isStaticAssetPath,
93
+ PUBLIC_INFRA_PATHS,
94
+ PUBLIC_INFRA_PREFIXES,
95
+ SIGN_IN_PATHS,
96
+ } from "./auth-gate/public-paths.ts";
97
+
98
+ export { isStaticAssetPath } from "./auth-gate/public-paths.ts";
99
+
64
100
  function initGateDependencies(): { prisma: ReturnType<typeof getPrisma> } {
65
101
  const prisma = getPrisma();
66
102
  configureEventLog({ prisma });
@@ -104,19 +140,6 @@ export async function attachAuthGate(
104
140
 
105
141
  const otpUrl = process.env.SCALA_OTP_URL;
106
142
 
107
- // Eager-warm the local BetterAuth runtime at boot whenever a request path
108
- // will build it: the session-READ path (getSession, better-auth-cookie mode)
109
- // always uses the local instance — even when /api/auth/* forwards to
110
- // scala-otp — and the local /api/auth handler uses it when no otpUrl is set.
111
- // Building lazily on the first such request races a node_modules rebuild
112
- // window: Bun transpiles better-auth on demand, and against a half-written
113
- // tree that throws ENOENT → 500 on every auth + session check while /health
114
- // stays green (nothing restarts it) — the 2026-06-12 login outage. Building
115
- // here resolves the better-auth module graph once while node_modules is
116
- // stable, and getAuth() memoizes it for the process lifetime. Best-effort and
117
- // non-fatal: getAuth() never caches a failed build, so if this warm fails
118
- // (e.g. mid-install) the first successful request rebuilds and caches — the
119
- // service is never left wedged, and never boot-loops on a transient.
120
143
  if (authMode === "better-auth-cookie" || !otpUrl) {
121
144
  try {
122
145
  await getAuth();
@@ -225,17 +248,6 @@ export async function attachAuthGate(
225
248
  isStaticAssetPath(url.pathname);
226
249
  if (isInfra) return next();
227
250
 
228
- // NO localhost bypass: a 127.0.0.1 page request is gated EXACTLY like a
229
- // tunnel request (no session → redirect to /sign-in). The old
230
- // `if (isLocalhost) return next()` let any tool — and the agent's own
231
- // chrome-devtools/curl — see authed pages on localhost WITHOUT a session,
232
- // so verification on 127.0.0.1 diverged from what a signed-in user sees
233
- // through the domain (the CSS regression that hid for three runs,
234
- // 2026-06-13). The bypass is redundant: anything needing an authed view
235
- // signs in (the OTP is DB-readable on the dev host) — host-side harnesses
236
- // use getSuperAdminSessionCookie (scala-tools/lib). Internal
237
- // service-to-service auth still flows through the cookie/secret on the
238
- // request, never the source IP.
239
251
  const host = c.req.header("host") ?? "";
240
252
  const cookie = c.req.header("cookie") ?? "";
241
253
  const tokenMatch = cookie.match(/better-auth\.session_token=([^;]+)/);
@@ -9,6 +9,21 @@
9
9
  * and page_slug shell variants.
10
10
  */
11
11
 
12
+ // Per-page root.props.maxWidth (the canonical mechanism) centers any page at the chosen width via inline styles — already hydration-safe
13
+ // and page-scoped. Bind-page-handler MUST NOT inject unscoped CSS selectors: a global `.puck-root-slot { max-width }` rule shrinks
14
+ // every portal pane that hosts a Puck root (the 2026-06-17 dashboard sliver bug — `.puck-root-slot` matches every Puck page in every
15
+ // region, including the dashboard center pane). The `no-bind-page-handler-css-injection` guard enforces this; this comment is the load-bearing rationale.
16
+ // Live dashboards must never be served from a stale cache (intermediary, browser back-forward, mobile Safari aggressive caching). Force every
17
+ // SSR HTML response to be revalidated. Static assets (CSS/JS bundles) keep their hash-busted caching — those are content-addressed.
18
+ // no-transform: intermediaries must not mutate SSR HTML — Cloudflare's Email Address Obfuscation rewrites mailto: hrefs at the edge, which
19
+ // breaks the server-serializes-client-reads-verbatim hydration contract (React recoverable-error on every page with a mailto link). Per CF
20
+ // docs, Email Obfuscation does not apply when no-transform is present. NOTE: a shared-edge-caching (CDN max-age) variant shipped briefly on 2026-06-10
21
+ // and was REMOVED by operator ruling the same day ("the origin should be so fast we don't care") — the origin-speed work (SWR caches,
22
+ // wave-parallel loader, brotli) is the sanctioned path; re-adding shared-cache headers needs a fresh operator ruling.
23
+
24
+ // Why: Per-page root.props.maxWidth (the canonical mechanism) cente…
25
+ // (full note hoisted to this file's leading header — kept out of the body per
26
+ // no-inline-prose-blocks / prose-stays-in-docs)
12
27
  import { getAppLogger } from "@teamscala/logger/app-loggers";
13
28
  import type { Context } from "hono";
14
29
  import { readHtmlTemplate } from "./read-html-template.ts";
@@ -93,16 +108,11 @@ export async function bindPageHandler(
93
108
  // logo/colours, not the viewer's.
94
109
  if (resourceOrgId) orgId = resourceOrgId;
95
110
 
96
- // Unauthed /sign-in (no session, no resource) would otherwise resolve no
97
- // org, and the brand resolver falls back to the super-admin org (GBG Indo)
98
- // — so a link shared to org A (e.g. GMG APAC) previews as org B's brand on
99
- // WhatsApp/crawlers, because the link 302→/sign-in for the unauthed crawler.
100
- // The callbackURL carries the org the visitor is signing in to; derive orgId
101
- // from it so the sign-in page's brand/title/og reflect the destination org.
102
- // The canonical portal shape is /<websiteId>/<orgId>/<path> — the org is
103
- // segment 1, NOT segment 0. Reading segment 0 (the website id) fed a website
104
- // id into brand resolution, which resolved no org and fell back to the
105
- // super-admin org — the exact cross-tenant mis-branding this block exists to
111
+ // Unauthed /sign-in (no session, no resource) would otherwise resolve no org, and the brand resolver falls back to the super-admin org (GBG Indo)
112
+ // — so a link shared to org A (e.g. GMG APAC) previews as org B's brand on WhatsApp/crawlers, because the link 302→/sign-in for the unauthed crawler.
113
+ // The callbackURL carries the org the visitor is signing in to; derive orgId from it so the sign-in page's brand/title/og reflect the destination org.
114
+ // The canonical portal shape is /<websiteId>/<orgId>/<path> — the org is segment 1, NOT segment 0. Reading segment 0 (the website id) fed a website
115
+ // id into brand resolution, which resolved no org and fell back to the super-admin org — the exact cross-tenant mis-branding this block exists to
106
116
  // fix, still live until 2026-07-21; violates portal-branding-resolves-page-org-first.
107
117
  // scala-projects routes /<orgId>/… (single UUID) and proposal/form/quiz hosts
108
118
  // route /<resourceId>/… (a non-org first segment) — both keep segment 0 via
@@ -124,28 +134,6 @@ export async function bindPageHandler(
124
134
  htmlTemplate: await readHtmlTemplate(),
125
135
  nonce,
126
136
  });
127
- // Per-page root.props.maxWidth (the canonical mechanism) centers any
128
- // page at the chosen width via inline styles — already hydration-safe
129
- // and page-scoped. Bind-page-handler MUST NOT inject unscoped CSS
130
- // selectors: a global `.puck-root-slot { max-width }` rule shrinks
131
- // every portal pane that hosts a Puck root (the 2026-06-17 dashboard
132
- // sliver bug — `.puck-root-slot` matches every Puck page in every
133
- // region, including the dashboard center pane). The `no-bind-page-handler-css-injection`
134
- // guard enforces this; this comment is the load-bearing rationale.
135
- // Live dashboards must never be served from a stale cache (intermediary,
136
- // browser back-forward, mobile Safari aggressive caching). Force every
137
- // SSR HTML response to be revalidated. Static assets (CSS/JS bundles)
138
- // keep their hash-busted caching — those are content-addressed.
139
- // no-transform: intermediaries must not mutate SSR HTML — Cloudflare's
140
- // Email Address Obfuscation rewrites mailto: hrefs at the edge, which
141
- // breaks the server-serializes-client-reads-verbatim hydration contract
142
- // (React recoverable-error on every page with a mailto link). Per CF
143
- // docs, Email Obfuscation does not apply when no-transform is present.
144
- // NOTE: a shared-edge-caching (CDN max-age) variant shipped briefly on 2026-06-10
145
- // and was REMOVED by operator ruling the same day ("the origin should
146
- // be so fast we don't care") — the origin-speed work (SWR caches,
147
- // wave-parallel loader, brotli) is the sanctioned path; re-adding
148
- // shared-cache headers needs a fresh operator ruling.
149
137
  const headers = new Headers(resp.headers as HeadersInit);
150
138
  headers.set(
151
139
  "Cache-Control",
@@ -17,6 +17,7 @@ import { join } from "node:path";
17
17
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
18
18
  import { userHome } from "@teamscala/os/host-paths";
19
19
  import * as v from "valibot";
20
+ import { createCache } from "@teamscala/cache/create-cache";
20
21
 
21
22
  // Single shared host lock serialising the heavy boot-time client build across
22
23
  // ALL kind:ui services (client-asset-builds-serialize-via-lock). A publish of a
@@ -31,10 +32,14 @@ const CLIENT_BUILD_LOCK = join(userHome(), ".scala-client-build.lock");
31
32
  // correctness gate, and must never block a boot. TTL is DB-driven
32
33
  // (config/build-client-assets), promoted from the former hardcoded constant.
33
34
  const DEFAULT_CLIENT_BUILD_LOCK_TIMEOUT_MS = 600_000;
34
- let cachedClientBuildLockTimeoutMs: number | undefined;
35
+ const clientBuildLockTimeoutCache = createCache<number>(
36
+ "service-boot:client-build-lock-timeout",
37
+ { ttlMs: Number.POSITIVE_INFINITY, maxSize: 1 },
38
+ );
35
39
 
36
40
  export async function getClientBuildLockTimeoutMs(): Promise<number> {
37
- if (cachedClientBuildLockTimeoutMs !== undefined) return cachedClientBuildLockTimeoutMs;
41
+ const hit = clientBuildLockTimeoutCache.get("default");
42
+ if (hit !== undefined) return hit;
38
43
  // Fail-soft to the hardcoded default: the deploy-build bake runs this in a
39
44
  // standalone subprocess with NO registered Prisma client (a pre-DB build
40
45
  // context), so loadRegistryConfig throws "Prisma client not registered". The
@@ -43,13 +48,17 @@ export async function getClientBuildLockTimeoutMs(): Promise<number> {
43
48
  // registry-read failure must degrade to the default, never abort the bake.
44
49
  try {
45
50
  const row = await loadRegistryConfig("config", "build-client-assets", v.looseObject({}));
46
- cachedClientBuildLockTimeoutMs =
51
+ clientBuildLockTimeoutCache.set(
52
+ "default",
47
53
  (row?.client_build_lock_timeout_ms as number | undefined) ??
48
- DEFAULT_CLIENT_BUILD_LOCK_TIMEOUT_MS;
54
+ DEFAULT_CLIENT_BUILD_LOCK_TIMEOUT_MS,
55
+ );
49
56
  } catch {
50
- cachedClientBuildLockTimeoutMs = DEFAULT_CLIENT_BUILD_LOCK_TIMEOUT_MS;
57
+ clientBuildLockTimeoutCache.set("default", DEFAULT_CLIENT_BUILD_LOCK_TIMEOUT_MS);
51
58
  }
52
- return cachedClientBuildLockTimeoutMs;
59
+ return (
60
+ clientBuildLockTimeoutCache.get("default") ?? DEFAULT_CLIENT_BUILD_LOCK_TIMEOUT_MS
61
+ );
53
62
  }
54
63
 
55
64
  export { CLIENT_BUILD_LOCK };
@@ -33,7 +33,9 @@ export async function removeStaleHashedFiles(
33
33
  if (re.test(name) && name !== keep) {
34
34
  try {
35
35
  await rm(join(outDir, name));
36
- } catch {}
36
+ } catch (error) {
37
+ process.stderr.write("hashed-output.ts: bare catch around upstream call — logged, continuing: " + String(error) + "");
38
+ }
37
39
  }
38
40
  }
39
41
  }
@@ -13,6 +13,57 @@
13
13
  * else.
14
14
  */
15
15
 
16
+ // Externalize an unresolvable @teamscala/* block import (a deactivated block_schema whose manifest regen never propagated) or a broken
17
+ // third-party internal (@fullcalendar/core/internal.js during a version mismatch) so it degrades ONE block at runtime instead of crash-looping
18
+ // the service (2026-07-21 portal outage: @fullcalendar 7.x + plugins 6.x → unresolvable internal.js → 21 crash-loop restarts).
19
+ //
20
+ // The filter is deliberately NARROW — only the package prefixes where unresolvable imports actually occur — NOT every bare import. The prior
21
+ // broad /^(?![./])/ filter intercepted resolvable shared packages too, and onResolve pass-through (return undefined) on a shared
22
+ // sideEffects:false package (@tanstack/react-query, imported across many lazy block chunks) tripped Bun's code-splitter: it emitted a dead
23
+ // `export{...}`-with-no-`from` facade and tree-shook away the entire query-core implementation → a fatal hydration SyntaxError that froze
24
+ // agents.scala.business while every component reported healthy (2026-07-22; see the dead-facade gate + minifyIdentifiers:false
25
+ // below). Intercepts of node builtins (stream/web) likewise pulled them into the browser build. The narrow filter leaves @tanstack/* + builtins
26
+ // to Bun's native (correct) resolution. A future unresolvable import OUTSIDE these prefixes fails loud via the manifest guard + build
27
+ // tombstone (no-dead-block-imports-in-manifest) — never silent. Entry-point imports are excluded — those must fail loudly (silently
28
+ // externalizing a core import turns a build error into a mystery runtime crash; containment must not become a swallow).
29
+
30
+ // Why: Externalize an unresolvable @teamscala/* block import (a dea…
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
+ // minifyIdentifiers is DISABLED (whitespace + syntax/dead-code minify
34
+ // kept): under code-splitting, identifier mangling interacting with a
35
+ // shared sideEffects:false package (@tanstack/react-query) trips Bun's
36
+ // splitter to tree-shake away the entire implementation and emit a dead
37
+ // re-export facade — a fatal hydration SyntaxError that froze
38
+ // agents.scala.business while every component reported healthy
39
+ // (2026-07-22; reproduces in stable 1.3.14 + canary 1.4.0, so an upstream
40
+ // fix is not near). Whitespace + syntax minify retain the bulk of the
41
+ // size win; only name-mangling is dropped. The post-build dead-facade
42
+ // gate below fails the build LOUD if this or any future regression slips
43
+ // a fatal facade through on any UI service.
44
+
45
+ // Why: minifyIdentifiers is DISABLED (whitespace + syntax/dead-code…
46
+ // (full note hoisted to this file's leading header — kept out of the body per
47
+ // no-inline-prose-blocks / prose-stays-in-docs)
48
+ // dead-facade prevention gate (agents.scala.business 2026-07-22): a Bun
49
+ // splitter+tree-shake bug (stable 1.3.14 + canary 1.4.0) can emit a "dead
50
+ // facade" chunk — `export { ... }` with NO `from` clause and NO local binding
51
+ // definitions — a fatal SyntaxError at module load that kills React hydration
52
+ // SILENTLY (the whole dashboard froze while every component reported healthy).
53
+ // The narrow resilientBlockPlugin filter + minifyIdentifiers:false above
54
+ // prevent the known trigger (@tanstack/react-query under split); this gate
55
+ // makes the CLASS unshippable — any dead facade from ANY package on ANY UI
56
+ // service fails the build LOUD here instead of freezing the page. Detection
57
+ // is POSITION-INDEPENDENT — a named export{} ANYWHERE in the comment-stripped
58
+ // body, with no `from` clause and no local declaration/import backing the
59
+ // names, is the facade (Bun could prefix it with a directive or statement, not
60
+ // only emit it at the very start — a positional assumption is a known way to
61
+ // be silently bypassed; a valid local-export chunk carries a
62
+ // class/function/const/var/let/import, a valid re-export carries `from`).
63
+
64
+ // Why: dead-facade prevention gate (agents.scala.business 2026-07-2…
65
+ // (full note hoisted to this file's leading header — kept out of the body per
66
+ // no-inline-prose-blocks / prose-stays-in-docs)
16
67
  import { mkdir, readdir, rename, rm } from "node:fs/promises";
17
68
  import { join } from "node:path";
18
69
  import { getAppLogger } from "@teamscala/logger/app-loggers";
@@ -38,10 +89,8 @@ export async function buildHydrateBundle(
38
89
 
39
90
  await mkdir(outDir, { recursive: true });
40
91
 
41
- // Resilience (block-and-provider-error-isolation applied to the BUILD): a
42
- // page-manifest.ts lazy import of a block whose component no longer resolves
43
- // (a block_schema deactivation or a removed package export whose manifest
44
- // regen never propagated) MUST NOT fail the whole bundle and crash-loop the
92
+ // Resilience (block-and-provider-error-isolation applied to the BUILD): a page-manifest.ts lazy import of a block whose component no longer resolves
93
+ // (a block_schema deactivation or a removed package export whose manifest regen never propagated) MUST NOT fail the whole bundle and crash-loop the
45
94
  // service. Seven UI services went down at once this way on 2026-06-13. Such
46
95
  // an import is EXTERNALIZED instead: the build succeeds, the service boots,
47
96
  // and at runtime that one lazy import rejects into the block's
@@ -54,31 +103,6 @@ export async function buildHydrateBundle(
54
103
  const resilientBlockPlugin: Bun.BunPlugin = {
55
104
  name: "resilient-block-imports",
56
105
  setup(build: { onResolve: (opts: { filter: RegExp }, cb: (args: { path: string }) => { path: string; external: boolean } | undefined) => void }) {
57
- // Externalize an unresolvable @teamscala/* block import (a deactivated
58
- // block_schema whose manifest regen never propagated) or a broken
59
- // third-party internal (@fullcalendar/core/internal.js during a version
60
- // mismatch) so it degrades ONE block at runtime instead of crash-looping
61
- // the service (2026-07-21 portal outage: @fullcalendar 7.x + plugins 6.x
62
- // → unresolvable internal.js → 21 crash-loop restarts).
63
- //
64
- // The filter is deliberately NARROW — only the package prefixes where
65
- // unresolvable imports actually occur — NOT every bare import. The prior
66
- // broad /^(?![./])/ filter intercepted resolvable shared packages too,
67
- // and onResolve pass-through (return undefined) on a shared
68
- // sideEffects:false package (@tanstack/react-query, imported across many
69
- // lazy block chunks) tripped Bun's code-splitter: it emitted a dead
70
- // `export{...}`-with-no-`from` facade and tree-shook away the entire
71
- // query-core implementation → a fatal hydration SyntaxError that froze
72
- // agents.scala.business while every component reported healthy
73
- // (2026-07-22; see the dead-facade gate + minifyIdentifiers:false
74
- // below). Intercepts of node builtins (stream/web) likewise pulled them
75
- // into the browser build. The narrow filter leaves @tanstack/* + builtins
76
- // to Bun's native (correct) resolution. A future unresolvable import
77
- // OUTSIDE these prefixes fails loud via the manifest guard + build
78
- // tombstone (no-dead-block-imports-in-manifest) — never silent.
79
- // Entry-point imports are excluded — those must fail loudly (silently
80
- // externalizing a core import turns a build error into a mystery runtime
81
- // crash; containment must not become a swallow).
82
106
  build.onResolve({ filter: /^(@teamgbg\/|@fullcalendar\/)/ }, (args) => {
83
107
  if (args.importer === entry) return undefined;
84
108
  try {
@@ -99,17 +123,6 @@ export async function buildHydrateBundle(
99
123
  target: "browser",
100
124
  outdir: outDir,
101
125
  naming: `${HYDRATE_BASE}.js`,
102
- // minifyIdentifiers is DISABLED (whitespace + syntax/dead-code minify
103
- // kept): under code-splitting, identifier mangling interacting with a
104
- // shared sideEffects:false package (@tanstack/react-query) trips Bun's
105
- // splitter to tree-shake away the entire implementation and emit a dead
106
- // re-export facade — a fatal hydration SyntaxError that froze
107
- // agents.scala.business while every component reported healthy
108
- // (2026-07-22; reproduces in stable 1.3.14 + canary 1.4.0, so an upstream
109
- // fix is not near). Whitespace + syntax minify retain the bulk of the
110
- // size win; only name-mangling is dropped. The post-build dead-facade
111
- // gate below fails the build LOUD if this or any future regression slips
112
- // a fatal facade through on any UI service.
113
126
  minifyWhitespace: process.env.NODE_ENV === "production",
114
127
  minifySyntax: process.env.NODE_ENV === "production",
115
128
  minifyIdentifiers: false,
@@ -186,21 +199,6 @@ export async function buildHydrateBundle(
186
199
  throw new Error(`[build-client-assets] Bun.build failed:\n${messages}`);
187
200
  }
188
201
 
189
- // dead-facade prevention gate (agents.scala.business 2026-07-22): a Bun
190
- // splitter+tree-shake bug (stable 1.3.14 + canary 1.4.0) can emit a "dead
191
- // facade" chunk — `export { ... }` with NO `from` clause and NO local binding
192
- // definitions — a fatal SyntaxError at module load that kills React hydration
193
- // SILENTLY (the whole dashboard froze while every component reported healthy).
194
- // The narrow resilientBlockPlugin filter + minifyIdentifiers:false above
195
- // prevent the known trigger (@tanstack/react-query under split); this gate
196
- // makes the CLASS unshippable — any dead facade from ANY package on ANY UI
197
- // service fails the build LOUD here instead of freezing the page. Detection
198
- // is POSITION-INDEPENDENT — a named export{} ANYWHERE in the comment-stripped
199
- // body, with no `from` clause and no local declaration/import backing the
200
- // names, is the facade (Bun could prefix it with a directive or statement, not
201
- // only emit it at the very start — a positional assumption is a known way to
202
- // be silently bypassed; a valid local-export chunk carries a
203
- // class/function/const/var/let/import, a valid re-export carries `from`).
204
202
  const deadFacades: string[] = [];
205
203
  for (const out of result.outputs) {
206
204
  if (out.kind !== "chunk") continue;
@@ -258,7 +256,9 @@ export async function buildHydrateBundle(
258
256
  for (const name of currentChunks) {
259
257
  try {
260
258
  chunkBytes += (await Bun.file(join(outDir, name)).stat()).size;
261
- } catch {}
259
+ } catch (error) {
260
+ log.warn("lazy chunk size read failed — chunk excluded from size log", error);
261
+ }
262
262
  }
263
263
  log.info(
264
264
  `[build-client-assets] hydrate built → ${hashedName} (entry ${(entryBytes / 1024).toFixed(0)}KB + ${currentChunks.size} lazy chunks ${(chunkBytes / 1048576).toFixed(1)}MB)`,
@@ -292,7 +292,9 @@ async function removeStaleChunks(
292
292
  if (/^chunk-[A-Za-z0-9]+\.js$/.test(name) && !keep.has(name)) {
293
293
  try {
294
294
  await rm(join(outDir, name));
295
- } catch {}
295
+ } catch (error) {
296
+ getAppLogger().warn("hydrate-bundle.ts: bare catch around upstream call — logged, continuing (protective-layers-fail-explicitly)", error);
297
+ }
296
298
  }
297
299
  }
298
300
  }