@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
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.433",
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,51 @@ 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
+ // Eager-warm the local BetterAuth runtime at boot whenever a request path
48
+ // will build it: the session-READ path (getSession, better-auth-cookie mode)
49
+ // always uses the local instance — even when /api/auth/* forwards to
50
+ // scala-otp — and the local /api/auth handler uses it when no otpUrl is set.
51
+ // Building lazily on the first such request races a node_modules rebuild
52
+ // window: Bun transpiles better-auth on demand, and against a half-written
53
+ // tree that throws ENOENT → 500 on every auth + session check while /health
54
+ // stays green (nothing restarts it) — the 2026-06-12 login outage. Building
55
+ // here resolves the better-auth module graph once while node_modules is
56
+ // stable, and getAuth() memoizes it for the process lifetime. Best-effort and
57
+ // non-fatal: getAuth() never caches a failed build, so if this warm fails
58
+ // (e.g. mid-install) the first successful request rebuilds and caches — the
59
+ // service is never left wedged, and never boot-loops on a transient.
60
+
61
+ // NO localhost bypass: a 127.0.0.1 page request is gated EXACTLY like a
62
+ // tunnel request (no session → redirect to /sign-in). The old
63
+ // `if (isLocalhost) return next()` let any tool — and the agent's own
64
+ // chrome-devtools/curl — see authed pages on localhost WITHOUT a session,
65
+ // so verification on 127.0.0.1 diverged from what a signed-in user sees
66
+ // through the domain (the CSS regression that hid for three runs,
67
+ // 2026-06-13). The bypass is redundant: anything needing an authed view
68
+ // signs in (the OTP is DB-readable on the dev host) — host-side harnesses
69
+ // use getSuperAdminSessionCookie (scala-tools/lib). Internal
70
+ // service-to-service auth still flows through the cookie/secret on the
71
+ // request, never the source IP.
72
+
73
+ import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
74
+ import { getPrisma } from "@teamscala/db/prisma-registry";
75
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
76
+ import { resolveAccess } from "@teamscala/auth/resolve-access";
77
+ import * as v from "valibot";
78
+ import { configure as configureEventLog } from "@teamscala/event-log/configure";
79
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
80
+ import type { Hono } from "hono";
81
+
82
+ import {
83
+ isStaticAssetPath,
84
+ PUBLIC_INFRA_PATHS,
85
+ PUBLIC_INFRA_PREFIXES,
86
+ SIGN_IN_PATHS,
87
+ } from "./auth-gate/public-paths.ts";
88
+
89
+ export { isStaticAssetPath } from "./auth-gate/public-paths.ts";
90
+
64
91
  function initGateDependencies(): { prisma: ReturnType<typeof getPrisma> } {
65
92
  const prisma = getPrisma();
66
93
  configureEventLog({ prisma });
@@ -104,19 +131,6 @@ export async function attachAuthGate(
104
131
 
105
132
  const otpUrl = process.env.SCALA_OTP_URL;
106
133
 
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
134
  if (authMode === "better-auth-cookie" || !otpUrl) {
121
135
  try {
122
136
  await getAuth();
@@ -225,17 +239,6 @@ export async function attachAuthGate(
225
239
  isStaticAssetPath(url.pathname);
226
240
  if (isInfra) return next();
227
241
 
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
242
  const host = c.req.header("host") ?? "";
240
243
  const cookie = c.req.header("cookie") ?? "";
241
244
  const tokenMatch = cookie.match(/better-auth\.session_token=([^;]+)/);
@@ -9,6 +9,18 @@
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
+
12
24
  import { getAppLogger } from "@teamscala/logger/app-loggers";
13
25
  import type { Context } from "hono";
14
26
  import { readHtmlTemplate } from "./read-html-template.ts";
@@ -93,16 +105,11 @@ export async function bindPageHandler(
93
105
  // logo/colours, not the viewer's.
94
106
  if (resourceOrgId) orgId = resourceOrgId;
95
107
 
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
108
+ // 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)
109
+ // — 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.
110
+ // 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.
111
+ // 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
112
+ // 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
113
  // fix, still live until 2026-07-21; violates portal-branding-resolves-page-org-first.
107
114
  // scala-projects routes /<orgId>/… (single UUID) and proposal/form/quiz hosts
108
115
  // route /<resourceId>/… (a non-org first segment) — both keep segment 0 via
@@ -124,28 +131,6 @@ export async function bindPageHandler(
124
131
  htmlTemplate: await readHtmlTemplate(),
125
132
  nonce,
126
133
  });
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
134
  const headers = new Headers(resp.headers as HeadersInit);
150
135
  headers.set(
151
136
  "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,48 @@
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
+ // minifyIdentifiers is DISABLED (whitespace + syntax/dead-code minify
31
+ // kept): under code-splitting, identifier mangling interacting with a
32
+ // shared sideEffects:false package (@tanstack/react-query) trips Bun's
33
+ // splitter to tree-shake away the entire implementation and emit a dead
34
+ // re-export facade — a fatal hydration SyntaxError that froze
35
+ // agents.scala.business while every component reported healthy
36
+ // (2026-07-22; reproduces in stable 1.3.14 + canary 1.4.0, so an upstream
37
+ // fix is not near). Whitespace + syntax minify retain the bulk of the
38
+ // size win; only name-mangling is dropped. The post-build dead-facade
39
+ // gate below fails the build LOUD if this or any future regression slips
40
+ // a fatal facade through on any UI service.
41
+
42
+ // dead-facade prevention gate (agents.scala.business 2026-07-22): a Bun
43
+ // splitter+tree-shake bug (stable 1.3.14 + canary 1.4.0) can emit a "dead
44
+ // facade" chunk — `export { ... }` with NO `from` clause and NO local binding
45
+ // definitions — a fatal SyntaxError at module load that kills React hydration
46
+ // SILENTLY (the whole dashboard froze while every component reported healthy).
47
+ // The narrow resilientBlockPlugin filter + minifyIdentifiers:false above
48
+ // prevent the known trigger (@tanstack/react-query under split); this gate
49
+ // makes the CLASS unshippable — any dead facade from ANY package on ANY UI
50
+ // service fails the build LOUD here instead of freezing the page. Detection
51
+ // is POSITION-INDEPENDENT — a named export{} ANYWHERE in the comment-stripped
52
+ // body, with no `from` clause and no local declaration/import backing the
53
+ // names, is the facade (Bun could prefix it with a directive or statement, not
54
+ // only emit it at the very start — a positional assumption is a known way to
55
+ // be silently bypassed; a valid local-export chunk carries a
56
+ // class/function/const/var/let/import, a valid re-export carries `from`).
57
+
16
58
  import { mkdir, readdir, rename, rm } from "node:fs/promises";
17
59
  import { join } from "node:path";
18
60
  import { getAppLogger } from "@teamscala/logger/app-loggers";
@@ -38,10 +80,8 @@ export async function buildHydrateBundle(
38
80
 
39
81
  await mkdir(outDir, { recursive: true });
40
82
 
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
83
+ // Resilience (block-and-provider-error-isolation applied to the BUILD): a page-manifest.ts lazy import of a block whose component no longer resolves
84
+ // (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
85
  // service. Seven UI services went down at once this way on 2026-06-13. Such
46
86
  // an import is EXTERNALIZED instead: the build succeeds, the service boots,
47
87
  // and at runtime that one lazy import rejects into the block's
@@ -54,31 +94,6 @@ export async function buildHydrateBundle(
54
94
  const resilientBlockPlugin: Bun.BunPlugin = {
55
95
  name: "resilient-block-imports",
56
96
  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
97
  build.onResolve({ filter: /^(@teamgbg\/|@fullcalendar\/)/ }, (args) => {
83
98
  if (args.importer === entry) return undefined;
84
99
  try {
@@ -99,17 +114,6 @@ export async function buildHydrateBundle(
99
114
  target: "browser",
100
115
  outdir: outDir,
101
116
  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
117
  minifyWhitespace: process.env.NODE_ENV === "production",
114
118
  minifySyntax: process.env.NODE_ENV === "production",
115
119
  minifyIdentifiers: false,
@@ -186,21 +190,6 @@ export async function buildHydrateBundle(
186
190
  throw new Error(`[build-client-assets] Bun.build failed:\n${messages}`);
187
191
  }
188
192
 
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
193
  const deadFacades: string[] = [];
205
194
  for (const out of result.outputs) {
206
195
  if (out.kind !== "chunk") continue;
@@ -258,7 +247,9 @@ export async function buildHydrateBundle(
258
247
  for (const name of currentChunks) {
259
248
  try {
260
249
  chunkBytes += (await Bun.file(join(outDir, name)).stat()).size;
261
- } catch {}
250
+ } catch (error) {
251
+ log.warn("lazy chunk size read failed — chunk excluded from size log", error);
252
+ }
262
253
  }
263
254
  log.info(
264
255
  `[build-client-assets] hydrate built → ${hashedName} (entry ${(entryBytes / 1024).toFixed(0)}KB + ${currentChunks.size} lazy chunks ${(chunkBytes / 1048576).toFixed(1)}MB)`,
@@ -292,7 +283,9 @@ async function removeStaleChunks(
292
283
  if (/^chunk-[A-Za-z0-9]+\.js$/.test(name) && !keep.has(name)) {
293
284
  try {
294
285
  await rm(join(outDir, name));
295
- } catch {}
286
+ } catch (error) {
287
+ getAppLogger().warn("hydrate-bundle.ts: bare catch around upstream call — logged, continuing (protective-layers-fail-explicitly)", error);
288
+ }
296
289
  }
297
290
  }
298
291
  }