@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,50 +12,28 @@
12
12
  * Bun.CryptoHasher (bun-native-crypto guard; identical digest output).
13
13
  */
14
14
 
15
- import { join } from "node:path";
16
- import {
17
- hashResolvedPackageStylesheets,
18
- hashTeamgbgJsGraph,
19
- } from "./input-hashing.ts";
20
-
21
- // Content fingerprint of the client-bundle dependency CLOSURE — hashed by
22
- // CONTENT, never mtime (cache-key-hashes-source-inputs-only). Rebuild iff this
23
- // differs from the fingerprint stored in the existing manifest. Scoped to the
24
- // CLOSURE (not all of node_modules) so a @teamscala/* publish that is not in this
25
- // service's client bundle cannot trigger a rebuild — the publish→rebuild storm
26
- // is architecturally impossible (`no-uncontrolled-repetition-or-cascade`).
15
+ // Content fingerprint of the client-bundle dependency CLOSURE — hashed by CONTENT, never mtime (cache-key-hashes-source-inputs-only). Rebuild iff this
16
+ // differs from the fingerprint stored in the existing manifest. Scoped to the CLOSURE (not all of node_modules) so a @teamscala/* publish that is not in this
17
+ // service's client bundle cannot trigger a rebuild — the publish→rebuild storm is architecturally impossible (`no-uncontrolled-repetition-or-cascade`).
27
18
  //
28
- // Inputs (everything that can change the emitted bytes):
29
- // src/entry-client.ts — the bundle entrypoint.
30
- // src/page-manifest.ts — the generated block→component map the bundle is built
31
- // from; a codegen regen (block added/removed) changes it,
32
- // and the hydrate bundle MUST rebuild or new blocks render
33
- // empty → hydration mismatch.
34
- // src/app.css — the Tailwind entry.
35
- // @teamscala/* JS closure (hashTeamgbjJsGraph) — the CONTENT of every @teamscala/*
36
- // module reachable from the client entrypoints. Content
37
- // (not version) so a mid-install stale bake (bun.lock
38
- // ahead of node_modules — the lock can name a new version
39
- // while the prior bytes are still on disk) is detected on
40
- // the next boot and self-heals (scala-agents 2026-07-12).
41
- // @teamscala/* CSS closure (hashResolvedPackageStylesheets) — the CONTENT of
42
- // every package stylesheet app.css @imports (transitively).
43
- // third-party closure-boundary versions (hashTeamgbgJsGraph) — the resolved
44
- // VERSION of each third-party package the client closure
45
- // imports (react, etc.). Third-party modules are not
46
- // content-walked (large; pinned by bun.lock + change only
47
- // on a version bump), but a version bump of a closure dep
48
- // MUST invalidate, so the installed package.json#version
49
- // is folded. This is the closure-scoped replacement for
50
- // hashing all of bun.lock: react bumping in the closure
51
- // still rebuilds, while a backend @teamscala/* publish
52
- // outside the closure does not.
53
- // NB: bun.lock is INTENTIONALLY NOT an input. It encodes every resolved
54
- // @teamscala/* version (backend packages included), so it invalidates on every
55
- // publish — the false-invalidation source that made every UI service rebuild
56
- // on a backend publish. The closure inputs above are a strict improvement:
57
- // they catch every change that affects THIS bundle AND nothing that doesn't.
58
- export async function computeInputFingerprint(cwd: string): Promise<string> {
19
+ // Inputs (everything that can change the emitted bytes): src/entry-client.ts — the bundle entrypoint. src/page-manifest.ts — the generated block→component map the bundle is built
20
+ // from; a codegen regen (block added/removed) changes it, and the hydrate bundle MUST rebuild or new blocks render
21
+ // empty → hydration mismatch. src/app.css — the Tailwind entry. @teamscala/* JS closure (hashTeamgbjJsGraph) — the CONTENT of every @teamscala/*
22
+ // module reachable from the client entrypoints. Content (not version) so a mid-install stale bake (bun.lock
23
+ // ahead of node_modules — the lock can name a new version while the prior bytes are still on disk) is detected on
24
+ // the next boot and self-heals (scala-agents 2026-07-12). @teamscala/* CSS closure (hashResolvedPackageStylesheets) — the CONTENT of
25
+ // every package stylesheet app.css @imports (transitively). third-party closure-boundary versions (hashTeamgbgJsGraph) — the resolved
26
+ // VERSION of each third-party package the client closure imports (react, etc.). Third-party modules are not
27
+ // content-walked (large; pinned by bun.lock + change only on a version bump), but a version bump of a closure dep
28
+ // MUST invalidate, so the installed package.json#version is folded. This is the closure-scoped replacement for
29
+ // hashing all of bun.lock: react bumping in the closure still rebuilds, while a backend @teamscala/* publish
30
+ // outside the closure does not. NB: bun.lock is INTENTIONALLY NOT an input. It encodes every resolved
31
+ // @teamscala/* version (backend packages included), so it invalidates on every publish — the false-invalidation source that made every UI service rebuild
32
+ // on a backend publish. The closure inputs above are a strict improvement: they catch every change that affects THIS bundle AND nothing that doesn't.
33
+
34
+ // Why: Content fingerprint of the client-bundle dependency CLOSURE …
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)
59
37
  // Keyed on each input's RELATIVE label + CONTENT only — never the absolute
60
38
  // path (that would make the fingerprint cwd-dependent and defeat the skip
61
39
  // across two checkouts of the same service). bun.lock is DELIBERATELY
@@ -67,6 +45,35 @@ export async function computeInputFingerprint(cwd: string): Promise<string> {
67
45
  // versions cover every dependency change that ACTUALLY affects this bundle,
68
46
  // so a non-closure publish changes nothing here → zero rebuild. Rebuild
69
47
  // iff this fingerprint differs from the one stored in the manifest.
48
+
49
+ // Why: Keyed on each input's RELATIVE label + CONTENT only — never …
50
+ // (full note hoisted to this file's leading header — kept out of the body per
51
+ // no-inline-prose-blocks / prose-stays-in-docs)
52
+ // Fold in the resolved CONTENT of every @teamscala/* JS/TS module reachable
53
+ // from the client entrypoints (hashTeamgbgJsGraph). bun.lock pins versions;
54
+ // the build reads BYTES — and a boot can build MID-INSTALL (the package-
55
+ // poller restarts the service after writing the new bun.lock but before
56
+ // node_modules extraction finishes), so Bun.build faithfully bakes the OLD
57
+ // @teamgbg JS while the lock-derived fingerprint is stored as "current".
58
+ // Every later restart then recomputes the same lock fingerprint → skip →
59
+ // STALE bundle served forever (scala-agents 2026-07-12: hydrate-4dad7478bd
60
+ // carried zero of portal-ui@2.4.41's markers across restarts). Bun.build
61
+ // itself is NOT caching — an isolated repro proves it reads fresh content
62
+ // across processes — so the fix is to make the fingerprint cover the actual
63
+ // @teamgbg JS bytes (the JS analogue of hashResolvedPackageStylesheets,
64
+ // which already closes this exact race for CSS). A stale bake then hashes
65
+ // differently from the freshly-installed bytes → rebuild → self-heals.
66
+
67
+ // Why: Fold in the resolved CONTENT of every @teamscala/* JS/TS mod…
68
+ // (full note hoisted to this file's leading header — kept out of the body per
69
+ // no-inline-prose-blocks / prose-stays-in-docs)
70
+ import { join } from "node:path";
71
+ import {
72
+ hashResolvedPackageStylesheets,
73
+ hashTeamgbgJsGraph,
74
+ } from "./input-hashing.ts";
75
+
76
+ export async function computeInputFingerprint(cwd: string): Promise<string> {
70
77
  const sources: Array<[label: string, path: string]> = [
71
78
  ["src/entry-client.ts", join(cwd, "src", "entry-client.ts")],
72
79
  ["src/page-manifest.ts", join(cwd, "src", "page-manifest.ts")],
@@ -89,20 +96,6 @@ export async function computeInputFingerprint(cwd: string): Promise<string> {
89
96
  // build reads bytes. When they diverge (boot built mid-install), the lock-only
90
97
  // fingerprint false-matches and skips forever — hashing the bytes closes it.
91
98
  await hashResolvedPackageStylesheets(cwd, h);
92
- // Fold in the resolved CONTENT of every @teamscala/* JS/TS module reachable
93
- // from the client entrypoints (hashTeamgbgJsGraph). bun.lock pins versions;
94
- // the build reads BYTES — and a boot can build MID-INSTALL (the package-
95
- // poller restarts the service after writing the new bun.lock but before
96
- // node_modules extraction finishes), so Bun.build faithfully bakes the OLD
97
- // @teamgbg JS while the lock-derived fingerprint is stored as "current".
98
- // Every later restart then recomputes the same lock fingerprint → skip →
99
- // STALE bundle served forever (scala-agents 2026-07-12: hydrate-4dad7478bd
100
- // carried zero of portal-ui@2.4.41's markers across restarts). Bun.build
101
- // itself is NOT caching — an isolated repro proves it reads fresh content
102
- // across processes — so the fix is to make the fingerprint cover the actual
103
- // @teamgbg JS bytes (the JS analogue of hashResolvedPackageStylesheets,
104
- // which already closes this exact race for CSS). A stale bake then hashes
105
- // differently from the freshly-installed bytes → rebuild → self-heals.
106
99
  await hashTeamgbgJsGraph(cwd, h);
107
100
  return h.digest("hex").slice(0, 16);
108
101
  }
@@ -66,10 +66,8 @@ export async function hashResolvedPackageStylesheets(
66
66
  }
67
67
  }
68
68
 
69
- // The JS twin of hashResolvedPackageStylesheets: resolve + hash the CONTENT of
70
- // every @teamscala/* JS/TS module reachable from the client entrypoints, so a
71
- // mid-install stale bake (bun.lock ahead of node_modules — see computeInputFingerprint)
72
- // is detected on the next boot and self-heals. Third-party modules are NOT walked
69
+ // The JS twin of hashResolvedPackageStylesheets: resolve + hash the CONTENT of every @teamscala/* JS/TS module reachable from the client entrypoints, so a
70
+ // mid-install stale bake (bun.lock ahead of node_modules — see computeInputFingerprint) is detected on the next boot and self-heals. Third-party modules are NOT walked
73
71
  // (their versions are pinned by bun.lock and they don't change on a @teamgbg
74
72
  // publish); only the @teamgbg surface — the publish-driven change graph — is folded.
75
73
  //
@@ -21,16 +21,11 @@ export const MANIFEST_FILE = "asset-manifest.json";
21
21
  export interface AssetManifest {
22
22
  stylesPath: string;
23
23
  hydratePath: string;
24
- // Content fingerprint of the CLIENT-BUNDLE DEPENDENCY CLOSURE (source
25
- // entrypoints + @teamscala/* JS/CSS in the closure + third-party closure-dep
26
- // versions), hashed by CONTENT — never mtime (cache-key-hashes-source-
27
- // inputs-only). The boot/build freshness check rebuilds iff this differs
28
- // from the current closure. mtime comparison is fundamentally broken on
29
- // Railway: deploy-build bakes the bundle at image-build time, and under the
30
- // hoisted linker + warm `bun-install` cache the installed @teamscala/* files
31
- // are hardlinks carrying OLD extraction mtimes while the baked manifest's
32
- // mtime is newer — so `source.mtime > manifest.mtime` is false and the
33
- // build wrongly skips, baking (then serving forever) a STALE client bundle
24
+ // Content fingerprint of the CLIENT-BUNDLE DEPENDENCY CLOSURE (source entrypoints + @teamscala/* JS/CSS in the closure + third-party closure-dep
25
+ // versions), hashed by CONTENT — never mtime (cache-key-hashes-source- inputs-only). The boot/build freshness check rebuilds iff this differs
26
+ // from the current closure. mtime comparison is fundamentally broken on Railway: deploy-build bakes the bundle at image-build time, and under the
27
+ // hoisted linker + warm `bun-install` cache the installed @teamscala/* files are hardlinks carrying OLD extraction mtimes while the baked manifest's
28
+ // mtime is newer — so `source.mtime > manifest.mtime` is false and the build wrongly skips, baking (then serving forever) a STALE client bundle
34
29
  // even though a newer @teamscala/* version is installed. Observed 2026-06-14:
35
30
  // scala-gpt shipped ui-foundation < 85 in the bundle while a newer version
36
31
  // was installed, so a published+installed fix never reached the browser.
@@ -79,7 +74,9 @@ export async function readClientBuildStatus(
79
74
  at: parsed.buildFailedAt,
80
75
  };
81
76
  }
82
- } catch {}
77
+ } catch (error) {
78
+ log.warn("asset manifest unreadable — reporting not-failed", error);
79
+ }
83
80
  return { failed: false };
84
81
  }
85
82
 
@@ -20,23 +20,15 @@
20
20
  * Byte-for-byte move — imports adjusted, nothing else.
21
21
  */
22
22
 
23
- import { existsSync } from "node:fs";
24
- import { readFile } from "node:fs/promises";
25
23
  import { resolve } from "node:path";
26
24
  import { getAppLogger } from "@teamscala/logger/app-loggers";
27
25
 
28
- // Tailwind v4's low-level `compile()` does NOT scan content — `compiler.build(
29
- // candidates)` only emits utilities for the candidate class names it is GIVEN.
30
- // `build([])` therefore emits ZERO content-driven utilities (only the static
31
- // theme/base + @utility/@layer + imported package CSS). The workspace's own
32
- // blocks mostly use inline styles + hand-written `blk-*` package classes
33
- // (no-tailwind-in-blocks / layout-properties-inline), so the missing utility
34
- // layer was invisible — until the Plate document editor (third-party shadcn
35
- // components using raw `flex`/`p-1`/`bg-background/95`) rendered completely
36
- // unstyled (operator's tablet, 2026-06-13). The CLI we replaced
37
- // (`b29e369` "in-process Bun compile") scanned content; the replacement dropped
38
- // the scan. This restores it: glob every resolved `@source` dir, extract
39
- // candidate tokens, and feed them to `build()`. @tailwindcss/oxide's native
26
+ // Tailwind v4's low-level `compile()` does NOT scan content — `compiler.build( candidates)` only emits utilities for the candidate class names it is GIVEN.
27
+ // `build([])` therefore emits ZERO content-driven utilities (only the static theme/base + @utility/@layer + imported package CSS). The workspace's own
28
+ // blocks mostly use inline styles + hand-written `blk-*` package classes (no-tailwind-in-blocks / layout-properties-inline), so the missing utility
29
+ // layer was invisible — until the Plate document editor (third-party shadcn components using raw `flex`/`p-1`/`bg-background/95`) rendered completely
30
+ // unstyled (operator's tablet, 2026-06-13). The CLI we replaced (`b29e369` "in-process Bun compile") scanned content; the replacement dropped
31
+ // the scan. This restores it: glob every resolved `@source` dir, extract candidate tokens, and feed them to `build()`. @tailwindcss/oxide's native
40
32
  // Scanner is NOT a dependency of the bare `tailwindcss` lib (only of the
41
33
  // integration packages), so we tokenize ourselves — over-extraction is safe
42
34
  // (build() ignores any token that isn't a valid utility). Enforced by
@@ -75,7 +67,7 @@ export async function scanSourceCandidates(
75
67
  // that package resolves to a non-existent dir. Skip silently rather than
76
68
  // warn on every UI-service boot (the log-flood class — the ENOENT whose
77
69
  // path rendered with a trailing null byte). Real errors below still warn.
78
- if (!existsSync(root)) continue;
70
+ if (!await Bun.file(root).exists()) continue;
79
71
  // A root INSIDE node_modules is an explicit `@source` into an installed
80
72
  // block package (the app-css transform emits one per package) — scan it
81
73
  // fully. A root OUTSIDE node_modules is the service's own tree — scan it
@@ -94,7 +86,7 @@ export async function scanSourceCandidates(
94
86
  if (dot < 0 || !SCAN_EXTS.has(file.slice(dot))) continue;
95
87
  let text: string;
96
88
  try {
97
- text = await readFile(file, "utf-8");
89
+ text = await Bun.file(file).text();
98
90
  } catch {
99
91
  continue;
100
92
  }
@@ -11,13 +11,6 @@
11
11
  * else.
12
12
  */
13
13
 
14
- import { existsSync } from "node:fs";
15
- import { readdir, rm } from "node:fs/promises";
16
- import { join } from "node:path";
17
- import { getAppLogger } from "@teamscala/logger/app-loggers";
18
- import { userHome } from "@teamscala/os/host-paths";
19
- import { spawnSync } from "@teamscala/os/spawn";
20
-
21
14
  /**
22
15
  * Upload source maps to GlitchTip via the official `glitchtip-cli` debug-ID
23
16
  * flow (the documented first-class mechanism — `sourcemaps inject` then
@@ -29,6 +22,16 @@ import { spawnSync } from "@teamscala/os/spawn";
29
22
  * maps are a follow-up). The .map files are deleted after upload — source never
30
23
  * ships publicly; the debug IDs in the served bundles are all the SDK needs.
31
24
  */
25
+
26
+ // Why: Upload source maps to GlitchTip via the official `glitchtip-…
27
+ // (full note hoisted to this file's leading header — kept out of the body per
28
+ // no-inline-prose-blocks / prose-stays-in-docs)
29
+ import { readdir, rm } from "node:fs/promises";
30
+ import { join } from "node:path";
31
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
32
+ import { userHome } from "@teamscala/os/host-paths";
33
+ import { spawnSync } from "@teamscala/os/spawn";
34
+
32
35
  export async function uploadSourceMaps(
33
36
  outDir: string,
34
37
  release: string,
@@ -44,7 +47,7 @@ export async function uploadSourceMaps(
44
47
  try {
45
48
  // Inject debug IDs into the bundles + maps (if glitchtip-cli is present).
46
49
  // The CLI is optional — only used for the inject step.
47
- if (existsSync(cli)) {
50
+ if (await Bun.file(cli).exists()) {
48
51
  spawnSync({
49
52
  name: "glitchtip-cli-sourcemaps-inject",
50
53
  command: [cli, "sourcemaps", "inject", outDir],
@@ -76,7 +79,9 @@ export async function uploadSourceMaps(
76
79
  { method: "POST", headers, body: form },
77
80
  );
78
81
  if (res.ok) uploaded++;
79
- } catch {}
82
+ } catch (error) {
83
+ log.warn("sourcemap upload failed — remaining maps continue", error);
84
+ }
80
85
  }
81
86
  log.info("[build-client-assets] source maps uploaded to GlitchTip", {
82
87
  release,
@@ -95,7 +100,9 @@ export async function uploadSourceMaps(
95
100
  if (name.endsWith(".map")) {
96
101
  try {
97
102
  await rm(join(outDir, name));
98
- } catch {}
103
+ } catch (error) {
104
+ getAppLogger().warn("sourcemaps-upload.ts: bare catch around upstream call — logged, continuing (protective-layers-fail-explicitly)", error);
105
+ }
99
106
  }
100
107
  }
101
108
  }
@@ -13,7 +13,6 @@
13
13
  * else.
14
14
  */
15
15
 
16
- import { readFile } from "node:fs/promises";
17
16
  import { dirname, join, resolve } from "node:path";
18
17
  import { getAppLogger } from "@teamscala/logger/app-loggers";
19
18
  import { compile } from "tailwindcss";
@@ -30,32 +29,34 @@ async function resolveStylesheetRaw(
30
29
  ): Promise<{ path: string; base: string; content: string }> {
31
30
  if (id === "tailwindcss") {
32
31
  const r = await Bun.resolve("tailwindcss/index.css", cwd);
33
- return { path: r, base: dirname(r), content: await readFile(r, "utf-8") };
32
+ return { path: r, base: dirname(r), content: await Bun.file(r).text() };
34
33
  }
35
34
  try {
36
35
  const r = await Bun.resolve(id, base);
37
- return { path: r, base: dirname(r), content: await readFile(r, "utf-8") };
38
- } catch {}
36
+ return { path: r, base: dirname(r), content: await Bun.file(r).text() };
37
+ } catch (error) {
38
+ log.warn("style import resolve failed — trying package fallback", error);
39
+ }
39
40
  if (!id.startsWith(".")) {
40
41
  try {
41
42
  const pkgPath = await Bun.resolve(id + "/package.json", cwd);
42
- const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
43
+ const pkg = JSON.parse(await Bun.file(pkgPath).text());
43
44
  const entry =
44
45
  pkg.exports?.["."]?.style || pkg.style || pkg.main;
45
46
  if (entry) {
46
47
  const r = resolve(dirname(pkgPath), entry);
47
- return { path: r, base: dirname(r), content: await readFile(r, "utf-8") };
48
+ return { path: r, base: dirname(r), content: await Bun.file(r).text() };
48
49
  }
49
- } catch {}
50
+ } catch (error) {
51
+ log.warn("package style entry resolve failed — falling back to bare resolve", error);
52
+ }
50
53
  }
51
54
  const r = resolve(base, id);
52
- return { path: r, base: dirname(r), content: await readFile(r, "utf-8") };
55
+ return { path: r, base: dirname(r), content: await Bun.file(r).text() };
53
56
  }
54
57
 
55
- // Tailwind v4's compile() invokes `loadStylesheet` for DIRECT @imports in the
56
- // entry (app.css) but does NOT re-invoke it for @imports WITHIN the loaded
57
- // content — so a package styles.css that itself `@import "./Leaf.css"` (a
58
- // nested RELATIVE import) has its leaf silently dropped from the prod bundle.
58
+ // Tailwind v4's compile() invokes `loadStylesheet` for DIRECT @imports in the entry (app.css) but does NOT re-invoke it for @imports WITHIN the loaded
59
+ // content — so a package styles.css that itself `@import "./Leaf.css"` (a nested RELATIVE import) has its leaf silently dropped from the prod bundle.
59
60
  // This dropped every block-specific class reachable only via a nested
60
61
  // relative @import (project-list-view/ProjectListView.css's
61
62
  // .nested-list-table-host, audit-block's nested css, etc.) while direct
@@ -107,12 +108,9 @@ async function bunResolveStylesheet(
107
108
  return { ...resolved, content };
108
109
  }
109
110
 
110
- // The app owns its fonts via an explicit, CSP-allowlisted <link> in index.html
111
- // (Google Fonts — `style-src ... fonts.googleapis.com`, `font-src ...
112
- // fonts.gstatic.com`). Third-party package CSS MUST NOT inject its own remote
113
- // `@import url(http…)`: @puckeditor/core/dist/index.css opens with
114
- // `@import "https://rsms.me/inter/inter.css"`, an arbitrary CDN our CSP does
115
- // NOT allow. The Tailwind compile inlines that mid-output, where it was either
111
+ // The app owns its fonts via an explicit, CSP-allowlisted <link> in index.html (Google Fonts — `style-src ... fonts.googleapis.com`, `font-src ...
112
+ // fonts.gstatic.com`). Third-party package CSS MUST NOT inject its own remote `@import url(http…)`: @puckeditor/core/dist/index.css opens with
113
+ // `@import "https://rsms.me/inter/inter.css"`, an arbitrary CDN our CSP does NOT allow. The Tailwind compile inlines that mid-output, where it was either
116
114
  // silently dropped (CSS spec: @import must precede all rules) OR — once hoisted —
117
115
  // blocked by CSP, leaving a console error on every page; either way the font
118
116
  // never loaded and the site fell back to a system font (scala-docs 2026-06-13).
@@ -145,7 +143,7 @@ export async function buildTailwindCss(
145
143
 
146
144
  await mkdir(outDir, { recursive: true });
147
145
 
148
- const cssSource = await readFile(entry, "utf-8");
146
+ const cssSource = await Bun.file(entry).text();
149
147
  const compiler = await compile(cssSource, {
150
148
  base: cwd,
151
149
  async loadStylesheet(id, base) {
@@ -11,7 +11,7 @@
11
11
  * CSP-allowlisted Google Fonts <link> in index.html instead.
12
12
  */
13
13
 
14
- import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
14
+ import { mkdtemp, mkdir, rm } from "node:fs/promises";
15
15
  import { tmpdir } from "node:os";
16
16
  import { dirname, join } from "node:path";
17
17
  import { afterAll, describe, expect, test } from "bun:test";
@@ -66,7 +66,7 @@ describe("computeInputFingerprint", () => {
66
66
  await mkdir(join(dir, "src"), { recursive: true });
67
67
  for (const [rel, content] of Object.entries(files)) {
68
68
  await mkdir(dirname(join(dir, rel)), { recursive: true });
69
- await writeFile(join(dir, rel), content);
69
+ await Bun.file(join(dir, rel)).write(content);
70
70
  }
71
71
  return dir;
72
72
  }
@@ -39,6 +39,22 @@
39
39
  * — split when the parent held them all at 962 lines.
40
40
  */
41
41
 
42
+ // Each build is wrapped in its own catch so a JS bundle failure (e.g. transient cross-package export drift) doesn't take down the service
43
+ // boot — SSR can still serve HTML; only client interactivity degrades. No fallback path for failed bundle builds — per `no-shims`, never write a
44
+ // manifest entry that points at a file we don't actually serve. If a build fails, write a TOMBSTONE manifest (no-silent-reverts) and throw.
45
+ //
46
+ // The tombstone preserves the LAST-SUCCESSFUL paths so SSR can keep serving the old bundle (better than a 404 hydrate), but marks itself
47
+ // `buildFailed: true` + the error + `failedAt`, AND sets `inputFingerprint` to a FAILURE sentinel so the next boot's "manifest up-to-date (skip)"
48
+ // check CANNOT match — the next boot is forced to retry the build explicitly (no silent skip on the stale manifest). The tombstone is the
49
+ // on-disk failure signal; ui-service-boot adds the runtime signal (recordEvent → event-log → GlitchTip) and read-html-template stamps the
50
+ // served HTML with the failure status. Without the tombstone, a build failure left the OLD manifest untouched and the dashboard served stale
51
+ // code with NO signal that the published fix never reached the bundle (scala-agents 2026-07-18: ui-foundation@3.4.5 installed but the bundle
52
+ // kept serving the old bytes across restarts while node_modules was mid-install — invisible until the operator hit the white-on-white
53
+ // toolbar regression the fix was supposed to close).
54
+
55
+ // Why: Each build is wrapped in its own catch so a JS bundle failur…
56
+ // (full note hoisted to this file's leading header — kept out of the body per
57
+ // no-inline-prose-blocks / prose-stays-in-docs)
42
58
  import { mkdir } from "node:fs/promises";
43
59
  import { join } from "node:path";
44
60
  import { withFileLock } from "@teamscala/file-lock/with-lock";
@@ -105,28 +121,6 @@ async function performBuild(
105
121
  ): Promise<void> {
106
122
  const t0 = Date.now();
107
123
 
108
- // Each build is wrapped in its own catch so a JS bundle failure (e.g.
109
- // transient cross-package export drift) doesn't take down the service
110
- // boot — SSR can still serve HTML; only client interactivity degrades.
111
- // No fallback path for failed bundle builds — per `no-shims`, never write a
112
- // manifest entry that points at a file we don't actually serve. If a build
113
- // fails, write a TOMBSTONE manifest (no-silent-reverts) and throw.
114
- //
115
- // The tombstone preserves the LAST-SUCCESSFUL paths so SSR can keep serving
116
- // the old bundle (better than a 404 hydrate), but marks itself
117
- // `buildFailed: true` + the error + `failedAt`, AND sets `inputFingerprint`
118
- // to a FAILURE sentinel so the next boot's "manifest up-to-date (skip)"
119
- // check CANNOT match — the next boot is forced to retry the build
120
- // explicitly (no silent skip on the stale manifest). The tombstone is the
121
- // on-disk failure signal; ui-service-boot adds the runtime signal
122
- // (recordEvent → event-log → GlitchTip) and read-html-template stamps the
123
- // served HTML with the failure status. Without the tombstone, a build
124
- // failure left the OLD manifest untouched and the dashboard served stale
125
- // code with NO signal that the published fix never reached the bundle
126
- // (scala-agents 2026-07-18: ui-foundation@3.4.5 installed but the bundle
127
- // kept serving the old bytes across restarts while node_modules was
128
- // mid-install — invisible until the operator hit the white-on-white
129
- // toolbar regression the fix was supposed to close).
130
124
  const existingManifest = await readManifest(outDir);
131
125
  const writeTombstone = async (reason: string): Promise<void> => {
132
126
  try {
@@ -37,6 +37,7 @@ export async function attachContentGateAccess(
37
37
  let resourceId = "";
38
38
  let email = "";
39
39
  let name = "";
40
+ let bodyFailed = false;
40
41
  try {
41
42
  const form = await c.req.parseBody();
42
43
  resourceId = String(form[config.recordIdField] ?? "").trim();
@@ -44,9 +45,11 @@ export async function attachContentGateAccess(
44
45
  // Optional captured display name (open-capture gates carry it in the
45
46
  // token; allow-list gates ignore it via signToken).
46
47
  name = String(form.name ?? "").trim();
47
- } catch {
48
- return c.redirect(back, 302);
48
+ } catch (error) {
49
+ log.warn(`[content-gate:${config.cookieName}] body parse failed — redirecting back: ${String(error)}`);
50
+ bodyFailed = true;
49
51
  }
52
+ if (bodyFailed) return c.redirect(back, 302);
50
53
  if (!resourceId || !email) return c.redirect(back, 302);
51
54
 
52
55
  let matched = false;
@@ -26,19 +26,6 @@
26
26
  * will select from — not a step on the way to it.
27
27
  */
28
28
 
29
- import type { AccessConfig, ContentGateConfig } from "./config-types.ts";
30
- import { agreementAccessConfig, agreementGateConfig } from "../agreement-gate.ts";
31
- import { gptAccessConfig, gptGateConfig } from "../gpt-gate.ts";
32
- import { proposalAccessConfig, proposalGateConfig } from "../proposal-gate.ts";
33
-
34
- /** One gated content type: its SSR gate and its public access endpoint. */
35
- export interface ContentGateEntry {
36
- /** Stable identity for this gate — what a `page_route` will name to select it. */
37
- slug: string;
38
- gate: ContentGateConfig;
39
- access: AccessConfig;
40
- }
41
-
42
29
  /**
43
30
  * Registered gates, in evaluation order.
44
31
  *
@@ -51,12 +38,10 @@ export interface ContentGateEntry {
51
38
  * its kind — so listing a gate here never makes it apply to a page it does not
52
39
  * govern.
53
40
  */
54
- export const CONTENT_GATES: readonly ContentGateEntry[] = [
55
- { slug: "proposal", gate: proposalGateConfig, access: proposalAccessConfig },
56
- { slug: "agreement", gate: agreementGateConfig, access: agreementAccessConfig },
57
- { slug: "gpt", gate: gptGateConfig, access: gptAccessConfig },
58
- ];
59
41
 
42
+ // Why: Registered gates, in evaluation order.
43
+ // (full note hoisted to this file's leading header — kept out of the body per
44
+ // no-inline-prose-blocks / prose-stays-in-docs)
60
45
  /**
61
46
  * Does this gate WITHHOLD content, or only identify the visitor?
62
47
  *
@@ -71,6 +56,29 @@ export const CONTENT_GATES: readonly ContentGateEntry[] = [
71
56
  * "governing" this page, or an anonymous visitor to a public GPT page would be
72
57
  * read as an unauthorized viewer and lose an assistant they are entitled to.
73
58
  */
59
+
60
+ // Why: Does this gate WITHHOLD content, or only identify the visito…
61
+ // (full note hoisted to this file's leading header — kept out of the body per
62
+ // no-inline-prose-blocks / prose-stays-in-docs)
63
+ import type { AccessConfig, ContentGateConfig } from "./config-types.ts";
64
+ import { agreementAccessConfig, agreementGateConfig } from "../agreement-gate.ts";
65
+ import { gptAccessConfig, gptGateConfig } from "../gpt-gate.ts";
66
+ import { proposalAccessConfig, proposalGateConfig } from "../proposal-gate.ts";
67
+
68
+ /** One gated content type: its SSR gate and its public access endpoint. */
69
+ export interface ContentGateEntry {
70
+ /** Stable identity for this gate — what a `page_route` will name to select it. */
71
+ slug: string;
72
+ gate: ContentGateConfig;
73
+ access: AccessConfig;
74
+ }
75
+
76
+ export const CONTENT_GATES: readonly ContentGateEntry[] = [
77
+ { slug: "proposal", gate: proposalGateConfig, access: proposalAccessConfig },
78
+ { slug: "agreement", gate: agreementGateConfig, access: agreementAccessConfig },
79
+ { slug: "gpt", gate: gptGateConfig, access: gptAccessConfig },
80
+ ];
81
+
74
82
  export function withholdsContent(entry: ContentGateEntry): boolean {
75
83
  return entry.access.openCapture !== true;
76
84
  }
@@ -10,9 +10,6 @@
10
10
  * functions. The single place that decides whether gated bytes reach the HTML.
11
11
  */
12
12
 
13
- import type { ContentGateConfig } from "./config-types.ts";
14
- import { parseCookie } from "./parse-cookie.ts";
15
-
16
13
  /**
17
14
  * What a gate decided, so a caller can act on it without re-deriving it.
18
15
  *
@@ -27,6 +24,13 @@ import { parseCookie } from "./parse-cookie.ts";
27
24
  * page), which is different from an unauthorized viewer and must not be
28
25
  * confused with one.
29
26
  */
27
+
28
+ // Why: What a gate decided, so a caller can act on it without re-de…
29
+ // (full note hoisted to this file's leading header — kept out of the body per
30
+ // no-inline-prose-blocks / prose-stays-in-docs)
31
+ import type { ContentGateConfig } from "./config-types.ts";
32
+ import { parseCookie } from "./parse-cookie.ts";
33
+
30
34
  export interface ContentGateOutcome {
31
35
  applies: boolean;
32
36
  authorized: boolean;
@@ -20,6 +20,7 @@
20
20
  * no fallback to a package-side puckBlocks scan, which masked blockless boots.
21
21
  */
22
22
 
23
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
23
24
  import { existsSync } from "node:fs";
24
25
  import { join } from "node:path";
25
26
  import { getPrisma } from "@teamscala/db/prisma-registry";
@@ -43,12 +44,9 @@ interface BlockSchemaConfig {
43
44
  export async function discoverBlocks(
44
45
  blockPackages: string[],
45
46
  ): Promise<DiscoveredBlocks> {
46
- // Scope discovery to what is ACTUALLY INSTALLED, never the authored
47
- // `block_packages` list. That list was an authored hint that silently
48
- // DRIFTED: a page-used package missing from it caused its blocks to be
49
- // skipped and rendered BLANK with only a warning (scala-sign, 2026-06-04 —
50
- // SignIn / SignatureField / ScrollProgress all blanked because sign-in-block
51
- // had fallen out of the list). The filesystem is the source of truth: a page
47
+ // Scope discovery to what is ACTUALLY INSTALLED, never the authored `block_packages` list. That list was an authored hint that silently
48
+ // DRIFTED: a page-used package missing from it caused its blocks to be skipped and rendered BLANK with only a warning (scala-sign, 2026-06-04 —
49
+ // SignIn / SignatureField / ScrollProgress all blanked because sign-in-block had fallen out of the list). The filesystem is the source of truth: a page
52
50
  // can only compose blocks from packages installed as the service's
53
51
  // dependencies, so "installed @teamgbg package" is always a correct superset
54
52
  // of "packages the pages need". A block_schema whose package is installed is
@@ -70,7 +68,7 @@ export async function discoverBlocks(
70
68
  if (blockPackages.length > 0) {
71
69
  const stale = blockPackages.filter((p) => !isInstalled(p));
72
70
  if (stale.length > 0) {
73
- console.warn(
71
+ getAppLogger().warn(
74
72
  `[discover-blocks] block_packages lists ${stale.length} package(s) that are not installed (ignored): ${stale.join(", ")}`,
75
73
  );
76
74
  }
@@ -161,7 +161,7 @@ export async function buildCertificateData(
161
161
  signature_data: unknown;
162
162
  signer_email: string | null;
163
163
  signer_name: string | null;
164
- }[]
164
+ }
165
165
  >`
166
166
  SELECT id, name, created_at, sent_at, viewed_at, signed_at,
167
167
  signature_data, signer_email, signer_name