@teamscala/service-boot 0.23.284

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 (138) hide show
  1. package/package.json +1016 -0
  2. package/src/agreement-gate.test.ts +118 -0
  3. package/src/agreement-gate.ts +887 -0
  4. package/src/asset-cache-key.ts +59 -0
  5. package/src/asset-optimize.ts +93 -0
  6. package/src/asset-proxy.ts +148 -0
  7. package/src/asset-social-card.ts +58 -0
  8. package/src/attach-agreement-magic.ts +62 -0
  9. package/src/attach-portal-members.ts +107 -0
  10. package/src/attach-proposal-accept.ts +123 -0
  11. package/src/attach-proposal-magic.ts +55 -0
  12. package/src/attach-transfers-upload.ts +100 -0
  13. package/src/auth-gate.ts +385 -0
  14. package/src/bind-page-handler.ts +223 -0
  15. package/src/build-client-assets/input-hashing.ts +206 -0
  16. package/src/build-client-assets/source-candidates.ts +115 -0
  17. package/src/build-client-assets.test.ts +216 -0
  18. package/src/build-client-assets.ts +962 -0
  19. package/src/compose-page-title.test.ts +88 -0
  20. package/src/compose-page-title.ts +73 -0
  21. package/src/content-gate/access.ts +89 -0
  22. package/src/content-gate/config-types.ts +108 -0
  23. package/src/content-gate/gate.ts +74 -0
  24. package/src/content-gate/parse-cookie.ts +23 -0
  25. package/src/content-gate/token.test.ts +70 -0
  26. package/src/content-gate/token.ts +148 -0
  27. package/src/content-page-floor.test.ts +127 -0
  28. package/src/content-page-floor.ts +70 -0
  29. package/src/discover-blocks.ts +228 -0
  30. package/src/expand-roster.ts +197 -0
  31. package/src/fn-routers/agreement-completion-email.ts +115 -0
  32. package/src/fn-routers/agreement-send-router.ts +303 -0
  33. package/src/fn-routers/ai-health-router.ts +64 -0
  34. package/src/fn-routers/auth-router.ts +131 -0
  35. package/src/fn-routers/cache-admin-router.ts +50 -0
  36. package/src/fn-routers/chat-session-router.ts +455 -0
  37. package/src/fn-routers/email-defaults.ts +22 -0
  38. package/src/fn-routers/email-link-ttl.ts +23 -0
  39. package/src/fn-routers/index.ts +70 -0
  40. package/src/fn-routers/panel-data-router.ts +93 -0
  41. package/src/fn-routers/persist-chat-messages.test.ts +86 -0
  42. package/src/fn-routers/persist-chat-messages.ts +96 -0
  43. package/src/fn-routers/plans-router.ts +141 -0
  44. package/src/fn-routers/proposal-send-router.ts +207 -0
  45. package/src/fn-routers/public-agreement-router.ts +865 -0
  46. package/src/fn-routers/public-assessment-router.ts +421 -0
  47. package/src/fn-routers/public-gpt-session-router.ts +373 -0
  48. package/src/fn-routers/sign-in-flow-router.ts +196 -0
  49. package/src/fn-routers/task-sync-bind.test.ts +105 -0
  50. package/src/fn-routers/task-sync-classify.test.ts +109 -0
  51. package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
  52. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  53. package/src/fn-routers/task-sync-router.test.ts +279 -0
  54. package/src/fn-routers/task-sync-router.ts +1036 -0
  55. package/src/fn-routers/tasks-router.ts +77 -0
  56. package/src/fn-routers/user-preferences-router.ts +139 -0
  57. package/src/fn-routers/work-items-router.ts +341 -0
  58. package/src/gate-ttls.ts +56 -0
  59. package/src/gpt-gate.ts +76 -0
  60. package/src/handlers/bun-stable-watch.ts +89 -0
  61. package/src/handlers/projects-digest.ts +198 -0
  62. package/src/handlers/prune-stale-data.ts +110 -0
  63. package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
  64. package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
  65. package/src/handlers/scala-tools-currency-watch.ts +171 -0
  66. package/src/handlers/scheduled-browser-tests.ts +85 -0
  67. package/src/handlers/sweep-event-log.ts +98 -0
  68. package/src/handlers/sweep-host-cache.ts +211 -0
  69. package/src/handlers/upstream-issue-sync.ts +210 -0
  70. package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
  71. package/src/handlers/whatsapp-liveness-logic.ts +53 -0
  72. package/src/handlers/whatsapp-liveness.test.ts +78 -0
  73. package/src/handlers/whatsapp-liveness.ts +125 -0
  74. package/src/hono-error-handler.ts +55 -0
  75. package/src/install-harness.ts +80 -0
  76. package/src/install-logging.ts +182 -0
  77. package/src/load-service-install-row.ts +98 -0
  78. package/src/load-service-prisma-client.ts +53 -0
  79. package/src/mount-orpc-router.ts +148 -0
  80. package/src/mount-server-packages.ts +155 -0
  81. package/src/mount.ts +18 -0
  82. package/src/mutable-app-ref.ts +68 -0
  83. package/src/page-route-shell-types.ts +16 -0
  84. package/src/page-server-orpc/audit-revert-router.ts +102 -0
  85. package/src/page-server-orpc/hub-mcp-client.ts +20 -0
  86. package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
  87. package/src/page-server-orpc/puck-pages-router.ts +272 -0
  88. package/src/page-server-orpc/server-client.ts +31 -0
  89. package/src/page-server-orpc/templates-router.ts +116 -0
  90. package/src/page-server-orpc/user-preferences-router.ts +132 -0
  91. package/src/pg-boss-handler-registry.ts +36 -0
  92. package/src/pg-boss-mount.ts +295 -0
  93. package/src/proposal-authorized-emails.ts +65 -0
  94. package/src/proposal-chat-inject.ts +141 -0
  95. package/src/proposal-gate.ts +175 -0
  96. package/src/read-html-template.test.ts +74 -0
  97. package/src/read-html-template.ts +102 -0
  98. package/src/read-viewport-from-request.ts +36 -0
  99. package/src/readiness-gate.ts +39 -0
  100. package/src/register-api-routes.ts +127 -0
  101. package/src/register-crash-handlers.ts +39 -0
  102. package/src/register-page-routes.ts +62 -0
  103. package/src/register-page-server-client.ts +109 -0
  104. package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
  105. package/src/register-registry-page-route/graph-cache.test.ts +84 -0
  106. package/src/register-registry-page-route/graph-cache.ts +90 -0
  107. package/src/register-registry-page-route/meta-title.ts +73 -0
  108. package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
  109. package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
  110. package/src/register-registry-page-route/resolve-page-data.ts +518 -0
  111. package/src/register-registry-page-route.ts +765 -0
  112. package/src/register-static-assets.ts +180 -0
  113. package/src/register-ws-routes.ts +180 -0
  114. package/src/resolve-app-base-url.ts +28 -0
  115. package/src/resolve-brand-favicon.ts +65 -0
  116. package/src/resolve-from-loader-config.test.ts +44 -0
  117. package/src/resolve-from-loader-config.ts +291 -0
  118. package/src/resolve-service-context.ts +67 -0
  119. package/src/resolve-theme-mode.ts +61 -0
  120. package/src/resolvers/fetch-org-brand.ts +28 -0
  121. package/src/resolvers/portal-route-paths.test.ts +102 -0
  122. package/src/resolvers/portal-route-paths.ts +83 -0
  123. package/src/resolvers/website-by-host-map.ts +49 -0
  124. package/src/resource-chat-inject.ts +168 -0
  125. package/src/resource-not-found.ts +263 -0
  126. package/src/run-boot-hook-list.ts +75 -0
  127. package/src/run-boot-hooks.ts +97 -0
  128. package/src/scala-tools-currency-watchdog.ts +160 -0
  129. package/src/security-headers.ts +59 -0
  130. package/src/serve/serve-ws.test.ts +135 -0
  131. package/src/serve/serve.ts +190 -0
  132. package/src/server-error-page.test.ts +102 -0
  133. package/src/server-error-page.ts +95 -0
  134. package/src/start-scheduled-jobs.ts +84 -0
  135. package/src/two-phase-boot.ts +449 -0
  136. package/src/types.ts +52 -0
  137. package/src/ui-service.ts +171 -0
  138. package/src/ws-boot-self-test.ts +114 -0
@@ -0,0 +1,223 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Calls a createPageHandler() handler with the per-request extras the
7
+ * SSR pipeline needs: the resolved userId/orgId (from localhost-bypass
8
+ * or better-auth-cookie), and the htmlTemplate. Shared by the regions
9
+ * and page_slug shell variants.
10
+ */
11
+
12
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
13
+ import type { Context } from "hono";
14
+ import { readHtmlTemplate } from "./read-html-template.ts";
15
+ import {
16
+ PrimaryResourceMissingError,
17
+ buildResourceNotFoundResponse,
18
+ resolveErrorPageLogo,
19
+ } from "./resource-not-found.ts";
20
+ import { extractOrgIdFromCallbackPath } from "./resolvers/portal-route-paths.ts";
21
+
22
+ type PageHandler = (
23
+ req: Request,
24
+ extras: Record<string, unknown>,
25
+ ) => Promise<{ body: unknown; status: number; headers: HeadersInit }>;
26
+
27
+ export async function bindPageHandler(
28
+ c: Context,
29
+ handler: PageHandler,
30
+ authMode: string,
31
+ serviceSlug: string,
32
+ label: string,
33
+ resourceOrgId?: string,
34
+ ): Promise<Response> {
35
+ const log = getAppLogger();
36
+ try {
37
+ let userId: string | undefined;
38
+ let orgId: string | undefined;
39
+ let visitor: { id: string; name: string; email: string } | null = null;
40
+
41
+ // NO localhost super-admin bypass: identity comes ONLY from the session
42
+ // (the auth-gate already forces sign-in for authed services on every
43
+ // host, 127.0.0.1 included). The old `if (isLocalhost) userId="super-admin"`
44
+ // rendered authed pages as a privileged user on localhost regardless of
45
+ // who (if anyone) was signed in — so agent/tool verification on 127.0.0.1
46
+ // saw a view that diverged from the real signed-in user's. Tools that need
47
+ // an authed render sign in and carry the cookie (getSuperAdminSessionCookie).
48
+
49
+ // The .scala.business session cookie reaches EVERY app on the domain,
50
+ // so a signed-in platform user is identifiable at SSR regardless of
51
+ // this service's own auth mode. Resolving it per-request (never via
52
+ // the cross-request loader graph) lets identity-gated blocks
53
+ // (PublicGptBlock's SignInBlock gate) first-paint the real experience
54
+ // instead of a client-side "checking who you are" loading pass
55
+ // (ssr-then-live-handoff). authMode still governs AUTHORIZATION;
56
+ // this is read-only identity convenience.
57
+ const hasSessionCookie = /better-auth\.session_token=/.test(
58
+ c.req.header("cookie") ?? "",
59
+ );
60
+
61
+ if (authMode === "better-auth-cookie" || hasSessionCookie) {
62
+ // Visitor identity is an ENHANCEMENT: on auth:none services
63
+ // (scala-public et al.) no auth factory is registered, and a
64
+ // signed-in operator's domain-wide cookie reaches every
65
+ // *.scala.business site — getSession throwing "Auth factory not
66
+ // registered" 500'd every page for signed-in visitors
67
+ // (public-dev outage, 2026-06-11). Degrade to anonymous, never
68
+ // fail the render (block-and-provider-error-isolation).
69
+ try {
70
+ const { getSession } = await import("@teamscala/auth/get-session.ts");
71
+ const session = await getSession(c.req.raw.headers);
72
+ if (authMode === "better-auth-cookie") {
73
+ userId = session?.user?.id;
74
+ orgId = session?.session.activeOrganizationId ?? undefined;
75
+ }
76
+ if (session?.user?.email) {
77
+ visitor = {
78
+ id: session.user.id,
79
+ name: session.user.name ?? session.user.email,
80
+ email: session.user.email,
81
+ };
82
+ }
83
+ } catch (err) {
84
+ if (authMode === "better-auth-cookie") throw err;
85
+ // cookie-only enhancement path: anonymous render proceeds
86
+ }
87
+ }
88
+
89
+ // Anonymous dynamic routes (proposal/form/quiz/agent) resolve a
90
+ // resource whose owning org is known from the loader. That org owns
91
+ // the brand the block-level serverLoaders read — override the
92
+ // viewer's org (if any) so the SSR injects the resource owner's
93
+ // logo/colours, not the viewer's.
94
+ if (resourceOrgId) orgId = resourceOrgId;
95
+
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
106
+ // fix, still live until 2026-07-21; violates portal-branding-resolves-page-org-first.
107
+ // scala-projects routes /<orgId>/… (single UUID) and proposal/form/quiz hosts
108
+ // route /<resourceId>/… (a non-org first segment) — both keep segment 0 via
109
+ // the helper's single-UUID fallback (resolves to no brand, falls back as
110
+ // before). The helper is the single authority shared with the route resolver.
111
+ if (!orgId) {
112
+ const cb = c.req.query("callbackURL") ?? "";
113
+ const resolved = extractOrgIdFromCallbackPath(cb);
114
+ if (resolved) orgId = resolved;
115
+ }
116
+
117
+ const nonce = c.get("nonce") as string | undefined;
118
+
119
+ const resp = await handler(c.req.raw, {
120
+ userId,
121
+ orgId,
122
+ visitor,
123
+ searchParams: c.req.query(),
124
+ htmlTemplate: await readHtmlTemplate(),
125
+ nonce,
126
+ });
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
+ const headers = new Headers(resp.headers as HeadersInit);
150
+ headers.set(
151
+ "Cache-Control",
152
+ "no-store, no-cache, no-transform, must-revalidate, max-age=0",
153
+ );
154
+ headers.set("Pragma", "no-cache");
155
+ headers.set("Expires", "0");
156
+ // Viewport-aware slot loading varies the response by these headers.
157
+ // Even with Cache-Control: no-store, browsers and intermediaries
158
+ // honour Vary on back/forward navigation and BFCache; Accept-CH
159
+ // asks the browser to send Sec-CH-UA-Mobile on subsequent
160
+ // navigations (Chromium-family auto-sends, others opt in).
161
+ headers.set("Vary", "Sec-CH-UA-Mobile, User-Agent");
162
+ headers.set("Accept-CH", "Sec-CH-UA-Mobile");
163
+ // Origin-side gzip. Cache-Control: no-transform (load-bearing above —
164
+ // it disables Cloudflare HTML rewrites that corrupt hydration) ALSO
165
+ // makes hono's compress() middleware skip this response, which shipped
166
+ // 1.2MB of raw SSR HTML per page load through the dev tunnel (~3s of
167
+ // pure transfer; the operator's tablet timed out — 2026-06-06).
168
+ // no-transform forbids INTERMEDIARY transforms; origin-applied
169
+ // content-coding is part of the representation (RFC 9110 §8.4.1), so
170
+ // compressing here honors both. Bun.gzipSync on ~1.2MB costs ~10ms.
171
+ const acceptEnc = c.req.header("Accept-Encoding") ?? "";
172
+ // Brotli preferred when offered (every modern browser): ~15-20%
173
+ // smaller than gzip on SSR HTML. Quality 4, NOT the library default
174
+ // 11 — q11 costs hundreds of ms on a ~1MB page (defeats the point);
175
+ // q4 compresses in single-digit ms and still beats gzip.
176
+ if (/\bbr\b/.test(acceptEnc) && resp.body) {
177
+ const { brotliCompressSync, constants } = await import("node:zlib");
178
+ const raw = new Uint8Array(await new Response(resp.body as never).arrayBuffer());
179
+ const br = brotliCompressSync(raw, {
180
+ params: { [constants.BROTLI_PARAM_QUALITY]: 4 },
181
+ });
182
+ headers.set("Content-Encoding", "br");
183
+ headers.delete("Content-Length");
184
+ return new Response(br as never, { status: resp.status, headers });
185
+ }
186
+ if (/\bgzip\b/.test(acceptEnc) && resp.body) {
187
+ const raw = new Uint8Array(await new Response(resp.body as never).arrayBuffer());
188
+ const gz = Bun.gzipSync(raw);
189
+ headers.set("Content-Encoding", "gzip");
190
+ headers.delete("Content-Length");
191
+ return new Response(gz as never, { status: resp.status, headers });
192
+ }
193
+ return new Response(resp.body as never, { status: resp.status, headers });
194
+ } catch (err) {
195
+ // Dead entity link (wrong id, deleted row) → the branded not-found
196
+ // page with a real 404 — never a 500, never default-content leakage.
197
+ if (err instanceof PrimaryResourceMissingError) {
198
+ log.warn(
199
+ `[scala-install:${serviceSlug}] 404 ${err.resourceType} not found (${label}) for ${new URL(c.req.url).pathname}`,
200
+ );
201
+ return buildResourceNotFoundResponse(
202
+ err.resourceType,
203
+ await resolveErrorPageLogo(serviceSlug),
204
+ err.httpStatus,
205
+ // "Take me home" → the host root (relative "/" resolves to the
206
+ // current site's home; the portal redirects / to its dashboard).
207
+ // empty → the page-row-exists-but-zero-blocks "This page is empty"
208
+ // surface, distinct from a dead link (operator ruling 2026-07-21).
209
+ { homeUrl: "/", empty: err.empty },
210
+ );
211
+ }
212
+ log.error(
213
+ `[scala-install:${serviceSlug}] Render error (${label}) for ${new URL(c.req.url).pathname}:`,
214
+ err,
215
+ );
216
+ return new Response(
217
+ `Render error: ${err instanceof Error ? err.message : String(err)}`,
218
+ {
219
+ status: 500,
220
+ },
221
+ );
222
+ }
223
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Input hashing for the client-asset build fingerprint.
7
+ *
8
+ * Split out of build-client-assets.ts, which had grown to 1221 lines holding
9
+ * six distinct concerns (lock + status, manifest, hydrate bundle, source maps,
10
+ * stylesheet/tailwind, and this). `single-purpose-file` did not catch it: that
11
+ * rule flags a file whose top-level exports share no common name root, which
12
+ * measures export-NAME cohesion rather than how many concerns a file holds — so
13
+ * a large file with few tidily-named exports stays invisible to it.
14
+ *
15
+ * This module owns ONE concern: turning the build's real inputs into a stable
16
+ * hash. `computeInputFingerprint` (still in the parent, and still the only
17
+ * exported entry point) composes the two functions exported here; everything
18
+ * else is module-private because nothing outside needs it.
19
+ *
20
+ * Byte-for-byte move — imports adjusted, nothing else. No renames, no type
21
+ * changes (`migrations-copy-not-rewrite`).
22
+ */
23
+
24
+ import { createHash } from "node:crypto";
25
+ import { existsSync } from "node:fs";
26
+ import { dirname, join, relative, resolve } from "node:path";
27
+
28
+ // Resolve + hash the content of every `@import` in app.css's @teamscala/*
29
+ // stylesheet chain — INCLUDING transitive @imports (generic-ui/styles.css
30
+ // @imports blk-utilities/styles.css; hashing only the direct import left
31
+ // blk-utilities changes undetected → stale bundle). Cycle-safe via `seen`.
32
+ export async function hashResolvedPackageStylesheets(
33
+ cwd: string,
34
+ h: ReturnType<typeof createHash>,
35
+ ): Promise<void> {
36
+ let appCss: string;
37
+ try {
38
+ appCss = await Bun.file(join(cwd, "src", "app.css")).text();
39
+ } catch {
40
+ return; // no app.css → nothing to fold (the main loop already recorded <absent>)
41
+ }
42
+ const seen = new Set<string>();
43
+ const hashSpec = async (spec: string, base: string): Promise<void> => {
44
+ if (seen.has(spec)) return;
45
+ seen.add(spec);
46
+ try {
47
+ const resolved = await Bun.resolve(spec, base);
48
+ const content = await Bun.file(resolved).text();
49
+ h.update("pkgcss:").update(spec).update("\0").update(content).update("\0");
50
+ // Follow transitive @imports within this stylesheet (relative + @teamscala/*).
51
+ for (const child of [
52
+ ...content.matchAll(/@import\s+["']([^"']+)["']/g),
53
+ ].map((m) => m[1].trim())) {
54
+ if (child.startsWith("tailwindcss")) continue;
55
+ await hashSpec(child, dirname(resolved));
56
+ }
57
+ } catch {
58
+ h.update("pkgcss:").update(spec).update("\0<unresolved>\0");
59
+ }
60
+ };
61
+ for (const spec of [
62
+ ...appCss.matchAll(/@import\s+["'](@teamgbg\/[^"']+)["']/g),
63
+ ].map((m) => m[1].trim())) {
64
+ await hashSpec(spec, join(cwd, "src"));
65
+ }
66
+ }
67
+
68
+ // The JS twin of hashResolvedPackageStylesheets: resolve + hash the CONTENT of
69
+ // 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)
71
+ // is detected on the next boot and self-heals. Third-party modules are NOT walked
72
+ // (their versions are pinned by bun.lock and they don't change on a @teamgbg
73
+ // publish); only the @teamgbg surface — the publish-driven change graph — is folded.
74
+ //
75
+ // Determinism: per-file content hashes are collected into a map keyed on the
76
+ // package-relative path, then folded into the aggregate hasher in SORTED path
77
+ // order. The walk itself runs bounded-concurrent (no-serial-per-unit-work-on-hot-
78
+ // paths — the @teamgbg graph is ~1k+ files for a large UI; a serial await-per-file
79
+ // walk starves the boot path), but scheduling order never affects the result.
80
+ const JS_IMPORT_SPEC_RE = /\b(?:from|import|export|require)\s*\(?["']([^"']+)["']/g;
81
+ const JS_GRAPH_CONCURRENCY = 32;
82
+
83
+ // Parse the package name (scoped `@scope/pkg` or unscoped `pkg`) from a
84
+ // resolved node_modules path. lastIndexOf picks the INNERMOST package when
85
+ // node_modules is nested (a -> node_modules/b), which is the package actually
86
+ // imported. Returns null for a path not under node_modules.
87
+ function nodeModulesPkgName(resolvedPath: string): string | null {
88
+ const idx = resolvedPath.lastIndexOf("/node_modules/");
89
+ if (idx < 0) return null;
90
+ const rest = resolvedPath.slice(idx + "/node_modules/".length);
91
+ const slash = rest.indexOf("/");
92
+ if (rest.startsWith("@")) {
93
+ if (slash < 0) return rest; // malformed scoped name with no slash
94
+ const secondSlash = rest.indexOf("/", slash + 1);
95
+ return secondSlash < 0 ? rest : rest.slice(0, secondSlash);
96
+ }
97
+ return slash < 0 ? rest : rest.slice(0, slash);
98
+ }
99
+
100
+ // Best-effort read of an installed package's declared version. Used at the
101
+ // third-party closure boundary so a version bump of a closure dep (react, etc.)
102
+ // invalidates the fingerprint without content-walking the (large, pinned)
103
+ // package. Returns null on any failure — an ABSENT signal, not a masking
104
+ // fallback (protective-layers-fail-explicitly): the caller omits the package,
105
+ // so a later successful read folds in a real version and invalidates. Never
106
+ // returns a non-null placeholder that would hide the failure.
107
+ async function readInstalledPkgVersion(
108
+ resolvedPath: string,
109
+ pkgName: string,
110
+ ): Promise<string | null> {
111
+ const marker = "/node_modules/";
112
+ const idx = resolvedPath.lastIndexOf(marker);
113
+ const pkgRoot = join(resolvedPath.slice(0, idx + marker.length), pkgName);
114
+ try {
115
+ const pkg = JSON.parse(await Bun.file(join(pkgRoot, "package.json")).text());
116
+ return typeof pkg.version === "string" ? pkg.version : null;
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ export async function hashTeamgbgJsGraph(
123
+ cwd: string,
124
+ h: ReturnType<typeof createHash>,
125
+ ): Promise<void> {
126
+ const roots = [
127
+ join(cwd, "src", "entry-client.ts"),
128
+ join(cwd, "src", "page-manifest.ts"),
129
+ ].filter((p) => existsSync(p));
130
+ if (roots.length === 0) return; // backend service — no client bundle to guard
131
+
132
+ const seen = new Set<string>();
133
+ for (const r of roots) seen.add(r);
134
+ const fileHashes = new Map<string, string>(); // pkg-relative path → content hash
135
+ const thirdPartyVersions = new Map<string, string>(); // pkg name → installed version
136
+
137
+ let frontier: string[] = [...roots];
138
+ while (frontier.length > 0) {
139
+ const batch = frontier.splice(0, JS_GRAPH_CONCURRENCY);
140
+ const expanded = await Promise.all(
141
+ batch.map(async (file) => {
142
+ let text: string;
143
+ try {
144
+ text = await Bun.file(file).text();
145
+ } catch {
146
+ return [] as string[];
147
+ }
148
+ const nmIdx = file.indexOf("/node_modules/@teamscala/");
149
+ if (nmIdx >= 0) {
150
+ const rel = file.slice(nmIdx + "/node_modules/".length); // @teamscala/pkg/...
151
+ fileHashes.set(rel, createHash("sha256").update(text).digest("hex"));
152
+ }
153
+ const next: string[] = [];
154
+ for (const m of text.matchAll(JS_IMPORT_SPEC_RE)) {
155
+ let resolved: string;
156
+ try {
157
+ resolved = await Bun.resolve(m[1].trim(), dirname(file));
158
+ } catch {
159
+ continue;
160
+ }
161
+ const inNodeModules = resolved.includes("/node_modules/");
162
+ const isTeamgbg = resolved.includes("/node_modules/@teamscala/");
163
+ if (inNodeModules && !isTeamgbg) {
164
+ // Third-party closure boundary: stop the content walk (large,
165
+ // version-pinned packages) but record this dep's resolved VERSION
166
+ // so a closure version bump (react, …) still invalidates the
167
+ // fingerprint. The closure-scoped replacement for hashing all of
168
+ // bun.lock (`no-uncontrolled-repetition-or-cascade`). has()+set()
169
+ // straddle an await, so two concurrent imports of the same package
170
+ // can both read its version — idempotent (same value), so the
171
+ // folded result is deterministic regardless of scheduling.
172
+ const pkgName = nodeModulesPkgName(resolved);
173
+ if (pkgName && !thirdPartyVersions.has(pkgName)) {
174
+ const version = await readInstalledPkgVersion(resolved, pkgName);
175
+ // null = absent (unreadable package.json) — OMIT so a later
176
+ // successful read invalidates; never mask behind a placeholder
177
+ // (protective-layers-fail-explicitly). has() stays false → the
178
+ // read is re-attempted on the next import (cheap, bounded by
179
+ // closure size); a successful read sets + dedups.
180
+ if (version !== null) thirdPartyVersions.set(pkgName, version);
181
+ }
182
+ continue;
183
+ }
184
+ // Atomic dedup: check+add are synchronous (no await between), so on
185
+ // the single-threaded event loop only one task ever enqueues a path.
186
+ if (seen.has(resolved)) continue;
187
+ seen.add(resolved);
188
+ next.push(resolved);
189
+ }
190
+ return next;
191
+ }),
192
+ );
193
+ for (const arr of expanded) frontier.push(...arr);
194
+ }
195
+
196
+ for (const rel of [...fileHashes.keys()].sort()) {
197
+ h.update("tgjs:").update(rel).update("\0").update(fileHashes.get(rel) as string).update("\0");
198
+ }
199
+ // Fold the resolved versions of every third-party package at the closure
200
+ // boundary, sorted by name for determinism. This is the ONLY signal for
201
+ // third-party changes (their bodies are not content-walked); without it a
202
+ // react version bump in the closure would bake a stale bundle.
203
+ for (const name of [...thirdPartyVersions.keys()].sort()) {
204
+ h.update("tpv:").update(name).update("\0").update(thirdPartyVersions.get(name) as string).update("\0");
205
+ }
206
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Tailwind source-candidate scanning for the client CSS build.
7
+ *
8
+ * Split out of build-client-assets.ts (1221 lines, six concerns). Owns ONE
9
+ * concern: turning the resolved `@source` directories into the candidate token
10
+ * set Tailwind's `build()` consumes.
11
+ *
12
+ * Why it exists at all, preserved from the original: Tailwind v4's low-level
13
+ * `compile()` does NOT scan content, and @tailwindcss/oxide's native Scanner is
14
+ * not a dependency of the bare `tailwindcss` lib — only of the integration
15
+ * packages. So the tokens are extracted here. Over-extraction is safe, because
16
+ * `build()` ignores any token that is not a valid utility. Dropping this scan is
17
+ * what shipped an unstyled page to the operator's tablet on 2026-06-13; it is
18
+ * enforced by `css-build-scans-source-candidates`.
19
+ *
20
+ * Byte-for-byte move — imports adjusted, nothing else.
21
+ */
22
+
23
+ import { existsSync } from "node:fs";
24
+ import { readFile } from "node:fs/promises";
25
+ import { resolve } from "node:path";
26
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
27
+
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
40
+ // Scanner is NOT a dependency of the bare `tailwindcss` lib (only of the
41
+ // integration packages), so we tokenize ourselves — over-extraction is safe
42
+ // (build() ignores any token that isn't a valid utility). Enforced by
43
+ // `css-build-scans-source-candidates`.
44
+ const SCAN_EXTS = new Set([
45
+ ".ts",
46
+ ".tsx",
47
+ ".js",
48
+ ".jsx",
49
+ ".mjs",
50
+ ".cjs",
51
+ ".html",
52
+ ".md",
53
+ ".mdx",
54
+ ]);
55
+ // Class-name candidate chars: identifiers, variant `:`, opacity `/`, arbitrary
56
+ // values `[]()`, decimals `.`, important `!`, plus `#%,&-` seen in arbitrary
57
+ // values and modifiers. Deliberately broad — invalid tokens are dropped by build().
58
+ const CANDIDATE_RE = /[A-Za-z0-9_:/[\].%#!,&-]+/g;
59
+
60
+ export async function scanSourceCandidates(
61
+ sources: Array<{ base: string; pattern: string; negated?: boolean }>,
62
+ log: ReturnType<typeof getAppLogger>,
63
+ ): Promise<string[]> {
64
+ const candidates = new Set<string>();
65
+ for (const src of sources) {
66
+ if (src.negated) continue;
67
+ let root: string;
68
+ try {
69
+ root = resolve(src.base, src.pattern);
70
+ } catch {
71
+ continue;
72
+ }
73
+ // A resolved @source root absent on disk is benign: a published block
74
+ // package ships compiled output, not its src/ tree, so an `@source` into
75
+ // that package resolves to a non-existent dir. Skip silently rather than
76
+ // warn on every UI-service boot (the log-flood class — the ENOENT whose
77
+ // path rendered with a trailing null byte). Real errors below still warn.
78
+ if (!existsSync(root)) continue;
79
+ // A root INSIDE node_modules is an explicit `@source` into an installed
80
+ // block package (the app-css transform emits one per package) — scan it
81
+ // fully. A root OUTSIDE node_modules is the service's own tree — scan it
82
+ // but never descend into node_modules (the entire dependency graph).
83
+ const rootInNodeModules = root.includes("/node_modules/");
84
+ try {
85
+ const glob = new Bun.Glob("**/*");
86
+ for await (const file of glob.scan({
87
+ cwd: root,
88
+ absolute: true,
89
+ onlyFiles: true,
90
+ })) {
91
+ if (!rootInNodeModules && file.includes("/node_modules/")) continue;
92
+ if (file.includes("/dist/") || file.endsWith(".d.ts")) continue;
93
+ const dot = file.lastIndexOf(".");
94
+ if (dot < 0 || !SCAN_EXTS.has(file.slice(dot))) continue;
95
+ let text: string;
96
+ try {
97
+ text = await readFile(file, "utf-8");
98
+ } catch {
99
+ continue;
100
+ }
101
+ const matched = text.match(CANDIDATE_RE);
102
+ if (matched) for (const t of matched) candidates.add(t);
103
+ }
104
+ } catch (err) {
105
+ // A bad source dir never aborts the whole CSS build (the other
106
+ // sources still contribute their utilities).
107
+ log.warn(
108
+ `[build-client-assets] candidate scan skipped ${root}: ${
109
+ err instanceof Error ? err.message : String(err)
110
+ }`,
111
+ );
112
+ }
113
+ }
114
+ return [...candidates];
115
+ }