@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.
- package/package.json +12 -19
- package/src/agreement-gate/signing-shell.ts +19 -15
- package/src/attach-proposal-accept.ts +5 -2
- package/src/auth-gate/public-paths.ts +7 -16
- package/src/auth-gate.ts +54 -42
- package/src/bind-page-handler.ts +20 -32
- package/src/build-client-assets/build-lock.ts +15 -6
- package/src/build-client-assets/hashed-output.ts +3 -1
- package/src/build-client-assets/hydrate-bundle.ts +59 -57
- package/src/build-client-assets/input-fingerprint.ts +50 -57
- package/src/build-client-assets/input-hashing.ts +2 -4
- package/src/build-client-assets/manifest.ts +8 -11
- package/src/build-client-assets/source-candidates.ts +8 -16
- package/src/build-client-assets/sourcemaps-upload.ts +17 -10
- package/src/build-client-assets/styles-build.ts +17 -19
- package/src/build-client-assets.test.ts +2 -2
- package/src/build-client-assets.ts +16 -22
- package/src/content-gate/access.ts +5 -2
- package/src/content-gate/catalog.ts +26 -18
- package/src/content-gate/gate.ts +7 -3
- package/src/discover-blocks.ts +5 -7
- package/src/fn-routers/agreement-certificate.ts +1 -1
- package/src/fn-routers/agreement-send-router.ts +5 -4
- package/src/fn-routers/agreement-sign-side-effects.ts +27 -22
- package/src/fn-routers/assessment-report-email.ts +3 -2
- package/src/fn-routers/auth-router.ts +20 -21
- package/src/fn-routers/chat-session-router-shared.ts +12 -3
- package/src/fn-routers/chat-session-router.ts +8 -6
- package/src/fn-routers/conversation-ownership.ts +3 -2
- package/src/fn-routers/messaging-whatsapp-router.test.ts +3 -1
- package/src/fn-routers/messaging-whatsapp-router.ts +17 -3
- package/src/fn-routers/persist-chat-messages.ts +6 -4
- package/src/fn-routers/proposal-send-router.ts +4 -3
- package/src/fn-routers/public-agreement-router.ts +5 -4
- package/src/fn-routers/public-assessment-router.ts +5 -4
- package/src/fn-routers/public-gpt-session-router.ts +17 -12
- package/src/fn-routers/user-preferences-router.ts +6 -15
- package/src/fn-routers/work-items-router.ts +21 -17
- package/src/handlers/platform-fault-escalation/delivery.ts +36 -0
- package/src/handlers/platform-fault-escalation/orpc-client.ts +38 -18
- package/src/handlers/platform-fault-escalation-logic.ts +26 -5
- package/src/handlers/platform-fault-escalation.test.ts +67 -15
- package/src/handlers/platform-fault-escalation.ts +16 -0
- package/src/handlers/projects-digest.ts +1 -1
- package/src/handlers/scala-tools-currency-watch.test.ts +1 -5
- package/src/handlers/scala-tools-currency-watch.ts +23 -32
- package/src/handlers/singleton-tools-currency-watch.test.ts +1 -5
- package/src/handlers/singleton-tools-currency-watch.ts +27 -19
- package/src/handlers/upstream-issue-sync.ts +11 -4
- package/src/handlers/whatsapp-liveness.ts +14 -4
- package/src/install-harness.ts +1 -1
- package/src/install-logging.ts +1 -1
- package/src/mount-orpc-router.test.ts +56 -10
- package/src/mount-orpc-router.ts +23 -9
- package/src/mount-server-packages.ts +17 -15
- package/src/pg-boss-mount.ts +5 -12
- package/src/proposal-gate.ts +7 -2
- package/src/read-html-template.test.ts +12 -15
- package/src/read-html-template.ts +2 -1
- package/src/readiness-gate.ts +2 -1
- package/src/register-crash-handlers.ts +4 -8
- package/src/register-page-server-client.ts +15 -19
- package/src/register-registry-page-route/ai-chat-prefetch.ts +16 -12
- package/src/register-registry-page-route/apply-page-branding.ts +68 -56
- package/src/register-registry-page-route/graph-cache.ts +22 -18
- package/src/register-registry-page-route/meta-title.ts +24 -20
- package/src/register-registry-page-route/prefetch-org-id.ts +6 -2
- package/src/register-registry-page-route/proposal-approval-email-token.ts +5 -3
- package/src/register-registry-page-route/run-content-gates.ts +2 -4
- package/src/register-registry-page-route.ts +1 -2
- package/src/register-static-assets.ts +6 -5
- package/src/resolve-app-base-url.ts +12 -5
- package/src/resolvers/portal-route-paths.ts +15 -11
- package/src/resource-chat-inject.ts +84 -72
- package/src/resource-not-found.ts +23 -19
- package/src/scala-tools-currency-watchdog.ts +41 -12
- package/src/two-phase-full-app.ts +61 -29
- package/src/two-phase-minimal-app.ts +39 -31
- package/src/two-phase-readiness.ts +50 -38
- package/src/ui-service.ts +50 -38
- package/src/handlers/pane-outbox-drain.ts +0 -39
- 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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// from
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
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
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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 (!
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
112
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/content-gate/gate.ts
CHANGED
|
@@ -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;
|
package/src/discover-blocks.ts
CHANGED
|
@@ -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
|
-
//
|
|
48
|
-
//
|
|
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
|
-
|
|
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
|