@teamscala/service-boot 0.23.524 → 0.23.526

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 (83) hide show
  1. package/package.json +1 -11
  2. package/src/agreement-gate/authorized-emails.ts +0 -7
  3. package/src/agreement-gate/placeholder-strip.ts +0 -2
  4. package/src/agreement-gate/settings.ts +0 -15
  5. package/src/agreement-gate.ts +0 -34
  6. package/src/attach-proposal-accept.ts +1 -1
  7. package/src/attach-proposal-magic.ts +1 -1
  8. package/src/auth/sign-out.ts +0 -2
  9. package/src/auth-gate/public-paths.ts +0 -86
  10. package/src/auth-gate.ts +0 -19
  11. package/src/bind-page-handler.ts +0 -76
  12. package/src/build-client-assets/hydrate-bundle.ts +0 -126
  13. package/src/build-client-assets/manifest.ts +0 -18
  14. package/src/build-client-assets/source-candidates.ts +0 -34
  15. package/src/build-client-assets/sourcemaps-upload.ts +0 -42
  16. package/src/build-client-assets/styles-build.ts +0 -50
  17. package/src/capability-slug.ts +0 -10
  18. package/src/compose-page-title.ts +0 -9
  19. package/src/content-gate/access.ts +0 -26
  20. package/src/content-gate/catalog.ts +0 -29
  21. package/src/content-gate/config-types.ts +3 -84
  22. package/src/content-gate/gate.ts +0 -20
  23. package/src/content-page-floor.ts +0 -11
  24. package/src/error-page-logo.ts +2 -12
  25. package/src/expand-roster.ts +0 -9
  26. package/src/gpt-gate.ts +1 -1
  27. package/src/hono-error-handler.ts +0 -4
  28. package/src/install-logging.ts +0 -32
  29. package/src/load-page-manifest.ts +0 -6
  30. package/src/load-service-install-row.ts +41 -4
  31. package/src/module-not-found.ts +0 -11
  32. package/src/mount-orpc-router.ts +1 -88
  33. package/src/mount-server-packages.ts +0 -29
  34. package/src/mutable-app-ref.ts +0 -11
  35. package/src/orpc-contract-rows.json +4102 -61
  36. package/src/orpc-openapi-document.ts +0 -6
  37. package/src/orpc-openapi-gate.ts +0 -10
  38. package/src/pg-boss-handler-registry.ts +0 -12
  39. package/src/pg-boss-mount.ts +0 -101
  40. package/src/proposal-gate.ts +1 -41
  41. package/src/quiz-gate.ts +1 -9
  42. package/src/register-page-server-client.ts +0 -53
  43. package/src/register-registry-page-route/ai-chat-prefetch.ts +0 -18
  44. package/src/register-registry-page-route/apply-page-branding.ts +0 -90
  45. package/src/register-registry-page-route/captured-loader-config.ts +0 -1
  46. package/src/register-registry-page-route/live-loader-config.ts +0 -16
  47. package/src/register-registry-page-route/meta-title.ts +0 -24
  48. package/src/register-registry-page-route/page-data-shared.ts +0 -6
  49. package/src/register-registry-page-route/prefetch-org-id.ts +0 -17
  50. package/src/register-registry-page-route/proposal-approval-email-token.ts +0 -11
  51. package/src/register-registry-page-route/proposal-brand-stamping.ts +0 -20
  52. package/src/register-registry-page-route/public-shell-page-data.ts +0 -26
  53. package/src/register-registry-page-route/resolve-request-graph.ts +2 -155
  54. package/src/register-registry-page-route/run-content-gates.ts +2 -54
  55. package/src/register-registry-page-route/warn-uncovered-blocks.ts +0 -10
  56. package/src/register-registry-page-route.ts +0 -85
  57. package/src/register-static-assets.ts +0 -45
  58. package/src/resolve-brand-favicon.ts +0 -9
  59. package/src/resolve-service-port.ts +0 -11
  60. package/src/resolve-theme-mode.ts +0 -8
  61. package/src/resolvers/app-header.ts +0 -1
  62. package/src/resolvers/fetch-org-brand.ts +0 -1
  63. package/src/resolvers/portal-route-paths.ts +0 -17
  64. package/src/resource-not-found.ts +0 -24
  65. package/src/select-service-routes.ts +0 -14
  66. package/src/theme-fonts-link.ts +0 -5
  67. package/src/two-phase-full-app.ts +0 -76
  68. package/src/two-phase-minimal-app.ts +1 -105
  69. package/src/types.ts +0 -14
  70. package/src/ui-service.ts +1 -42
  71. package/src/content-gate/gate-entry-marker.test.ts +0 -111
  72. package/src/content-gate/token.segmentation.test.ts +0 -76
  73. package/src/content-gate/token.test.ts +0 -65
  74. package/src/content-gate/token.ts +0 -197
  75. package/src/is-react-element-type.test.ts +0 -45
  76. package/src/is-react-element-type.ts +0 -10
  77. package/src/page-server-orpc/hub-mcp-client.ts +0 -14
  78. package/src/pg-boss-mount.test.ts +0 -101
  79. package/src/register-registry-page-route/graph-cache.test.ts +0 -81
  80. package/src/register-registry-page-route/graph-cache.ts +0 -71
  81. package/src/resource-chat-inject.page-assistant.test.ts +0 -145
  82. package/src/resource-chat-inject.test.ts +0 -240
  83. package/src/resource-chat-inject.ts +0 -357
@@ -3,48 +3,6 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
- // Externalize an unresolvable @teamscala/* block import (a deactivated block_schema whose manifest regen never propagated) or a broken
7
- // third-party internal (@fullcalendar/core/internal.js during a version mismatch) so it degrades ONE block at runtime instead of crash-looping
8
- // the service (2026-07-21 portal outage: @fullcalendar 7.x + plugins 6.x → unresolvable internal.js → 21 crash-loop restarts).
9
- //
10
- // The filter is deliberately NARROW — only the package prefixes where unresolvable imports actually occur — NOT every bare import. The prior
11
- // broad /^(?![./])/ filter intercepted resolvable shared packages too, and onResolve pass-through (return undefined) on a shared
12
- // sideEffects:false package (@tanstack/react-query, imported across many lazy block chunks) tripped Bun's code-splitter: it emitted a dead
13
- // `export{...}`-with-no-`from` facade and tree-shook away the entire query-core implementation → a fatal hydration SyntaxError that froze
14
- // agents.scala.business while every component reported healthy (2026-07-22; see the dead-facade gate + minifyIdentifiers:false
15
- // below). Intercepts of node builtins (stream/web) likewise pulled them into the browser build. The narrow filter leaves @tanstack/* + builtins
16
- // to Bun's native (correct) resolution. A future unresolvable import OUTSIDE these prefixes fails loud via the manifest guard + build
17
- // tombstone (no-dead-block-imports-in-manifest) — never silent. Entry-point imports are excluded — those must fail loudly (silently
18
- // externalizing a core import turns a build error into a mystery runtime crash; containment must not become a swallow).
19
-
20
- // minifyIdentifiers is DISABLED (whitespace + syntax/dead-code minify
21
- // kept): under code-splitting, identifier mangling interacting with a
22
- // shared sideEffects:false package (@tanstack/react-query) trips Bun's
23
- // splitter to tree-shake away the entire implementation and emit a dead
24
- // re-export facade — a fatal hydration SyntaxError that froze
25
- // agents.scala.business while every component reported healthy
26
- // (2026-07-22; reproduces in stable 1.3.14 + canary 1.4.0, so an upstream
27
- // fix is not near). Whitespace + syntax minify retain the bulk of the
28
- // size win; only name-mangling is dropped. The post-build dead-facade
29
- // gate below fails the build LOUD if this or any future regression slips
30
- // a fatal facade through on any UI service.
31
-
32
- // dead-facade prevention gate (agents.scala.business 2026-07-22): a Bun
33
- // splitter+tree-shake bug (stable 1.3.14 + canary 1.4.0) can emit a "dead
34
- // facade" chunk — `export { ... }` with NO `from` clause and NO local binding
35
- // definitions — a fatal SyntaxError at module load that kills React hydration
36
- // SILENTLY (the whole dashboard froze while every component reported healthy).
37
- // The narrow resilientBlockPlugin filter + minifyIdentifiers:false above
38
- // prevent the known trigger (@tanstack/react-query under split); this gate
39
- // makes the CLASS unshippable — any dead facade from ANY package on ANY UI
40
- // service fails the build LOUD here instead of freezing the page. Detection
41
- // is POSITION-INDEPENDENT — a named export{} ANYWHERE in the comment-stripped
42
- // body, with no `from` clause and no local declaration/import backing the
43
- // names, is the facade (Bun could prefix it with a directive or statement, not
44
- // only emit it at the very start — a positional assumption is a known way to
45
- // be silently bypassed; a valid local-export chunk carries a
46
- // class/function/const/var/let/import, a valid re-export carries `from`).
47
-
48
6
  import { mkdir, readdir, rm } from "node:fs/promises";
49
7
  import { join } from "node:path";
50
8
  import { getAppLogger } from "@teamscala/logger/app-loggers";
@@ -79,16 +37,6 @@ export async function buildHydrateBundle(
79
37
 
80
38
  await mkdir(outDir, { recursive: true });
81
39
 
82
- // Resilience (block-and-provider-error-isolation applied to the BUILD): a page-manifest.ts lazy import of a block whose component no longer resolves
83
- // (a block_schema deactivation or a removed package export whose manifest regen never propagated) MUST NOT fail the whole bundle and crash-loop the
84
- // service. Seven UI services went down at once this way on 2026-06-13. Such
85
- // an import is EXTERNALIZED instead: the build succeeds, the service boots,
86
- // and at runtime that one lazy import rejects into the block's
87
- // BlockErrorBoundary — the dead block degrades, the page + service stay up.
88
- // Only UNRESOLVABLE @teamgbg specifiers are touched (resolvable ones fall
89
- // through to Bun's normal resolution unchanged). Surfaced via warn so it is
90
- // never silent; the `no-dead-block-imports-in-manifest` guard still flags the
91
- // stale manifest so it gets regenerated.
92
40
  const externalizedBlocks: string[] = [];
93
41
  const resilientBlockPlugin: Bun.BunPlugin = {
94
42
  name: "resilient-block-imports",
@@ -112,21 +60,6 @@ export async function buildHydrateBundle(
112
60
  entrypoints: [entry],
113
61
  target: "browser",
114
62
  outdir: outDir,
115
- // THE BUNDLER OWNS THE HASH, because it also writes the references.
116
- // Under `splitting: true` + `publicPath`, Bun bakes the entry's
117
- // build-time name into every chunk that imports it. Naming the entry
118
- // `hydrate.js` here and RENAMING it to `hydrate-<hash>.js` afterwards
119
- // moved the file but could not rewrite the specifiers already emitted
120
- // into the chunks — so every chunk imported `/assets/hydrate.js`, which
121
- // no longer existed. 404 on the module, the importing block dies, and
122
- // the page still returns 200 with every guard green.
123
- //
124
- // MEASURED 2026-09-08 on quiz.scala.business: `AssessmentFlowBlock`
125
- // failed with "Failed to fetch dynamically imported module"; crawling
126
- // the served graph (142 level-1 + 76 level-2 assets) found exactly one
127
- // broken URL — `/assets/hydrate.js` — imported by the entry itself and
128
- // by most chunks. Hash applied OUTSIDE the generator is two artefacts
129
- // that must agree with nothing making them (`one-generator-pattern`).
130
63
  naming: {
131
64
  entry: `${HYDRATE_BASE}-[hash].[ext]`,
132
65
  chunk: "chunk-[hash].[ext]",
@@ -135,36 +68,13 @@ export async function buildHydrateBundle(
135
68
  minifyWhitespace: process.env.NODE_ENV === "production",
136
69
  minifySyntax: process.env.NODE_ENV === "production",
137
70
  minifyIdentifiers: false,
138
- // Bun plugin type
139
71
  plugins: [resilientBlockPlugin],
140
- // Code-splitting honours `lazy-blocks-per-page`: page-manifest.ts wraps
141
- // every block in `lazy(() => import())`, so splitting emits the heavy
142
- // Plate editor + each lazy block as a SEPARATE chunk loaded on demand
143
- // instead of inlining all of them into one ~12.5MB entry. The initial
144
- // download drops ~10× (entry ~1.2MB; chunks lazy). splitting:false was
145
- // drift that defeated the lazy manifest. SSR safety is intact — the
146
- // page-server awaits stream.allReady before flushing (render-page-html),
147
- // so every block is server-rendered and hydration is consistent.
148
72
  splitting: true,
149
- // External source maps so minified client stacks resolve to source in
150
- // GlitchTip (uploaded post-build by uploadSourceMaps via the official
151
- // glitchtip-cli debug-ID flow). Maps are deleted after upload — never
152
- // served; the debug IDs injected into the bundles are all the SDK needs.
153
73
  sourcemap: "external",
154
- // Chunks are referenced by absolute /assets/ URL (served by
155
- // register-static-assets' /assets/* handler), never a relative path that
156
- // could mis-resolve under a record route like /?id=<uuid>.
157
74
  publicPath: "/assets/",
158
75
  external: ["*.css"],
159
76
  });
160
77
  } catch (e) {
161
- // Bun.build can THROW (not return {success:false}) on certain failure
162
- // modes — e.g. entrypoint resolves to a path Bun can't read, or an
163
- // internal panic. Without this catch the boot path swallows the
164
- // useful diagnostic and the operator sees only "Bundle failed".
165
- // AggregateError carries the per-file resolution list in .errors —
166
- // without serializing it the operator sees only "Bundle failed"
167
- // (cost a full Railway round-trip per guess on 2026-06-05).
168
78
  const subErrors =
169
79
  e instanceof AggregateError
170
80
  ? e.errors.map((se) => (se instanceof Error ? se.message : String(se)))
@@ -182,12 +92,6 @@ export async function buildHydrateBundle(
182
92
  }
183
93
 
184
94
  if (!result.success) {
185
- // Bun.build's `.logs` is the verbose-build-message list. Each log is an
186
- // object with at minimum `{ level, message, position }`. Some failures
187
- // surface with an empty logs array — happens when Bun bails before
188
- // resolution (e.g. missing entrypoint after symlink-walk). Always emit
189
- // the full JSON form via the logger so service-runtime's structured
190
- // error trail captures every field, not just a `.message`-joined string.
191
95
  log.error(`[build-client-assets] Bun.build failed`, {
192
96
  logs: result.logs.map((l) => ({
193
97
  level: (l as { level?: string }).level,
@@ -240,11 +144,6 @@ export async function buildHydrateBundle(
240
144
  );
241
145
  }
242
146
 
243
- // Bun emitted the hashed entry itself, so the name it wrote into every
244
- // chunk IS the name on disk. Read it back from the build's own output
245
- // list rather than reconstructing it — a reconstructed name is a second
246
- // copy of a fact the bundler already owns, which is the defect this
247
- // change removes (`a-mutating-verb-reads-back-what-it-claims`).
248
147
  const entryOutput = result.outputs.find(
249
148
  (o) => o.kind === "entry-point",
250
149
  );
@@ -255,12 +154,6 @@ export async function buildHydrateBundle(
255
154
  }
256
155
  const hashedName = entryOutput.path.split("/").pop() as string;
257
156
  const hashedPath = join(outDir, hashedName);
258
- // The bundler OWNS this name (naming.entry above). Read the release hash
259
- // out of it; a name that no longer matches the declared
260
- // hydrate-<hash>.js shape means the naming contract changed underneath
261
- // us — refuse rather than re-derive a second hash from file bytes
262
- // (`one-generator-pattern`): a fallback here would produce a release id
263
- // the URL does not carry.
264
157
  const hashMatch = /^hydrate-([A-Za-z0-9]+)\.js$/.exec(hashedName);
265
158
  if (!hashMatch) {
266
159
  throw new Error(
@@ -270,9 +163,6 @@ export async function buildHydrateBundle(
270
163
  const hash = hashMatch[1];
271
164
  removeStaleHashedFiles(outDir, HYDRATE_BASE, "js", hashedName);
272
165
 
273
- // Splitting emits one chunk per lazy import() alongside the entry. Keep the
274
- // chunks THIS build produced; drop stale ones so dist/client/ doesn't
275
- // accumulate hundreds of orphaned chunks per rebuild.
276
166
  const currentChunks = new Set(
277
167
  result.outputs
278
168
  .filter((o) => o.kind === "chunk")
@@ -280,10 +170,6 @@ export async function buildHydrateBundle(
280
170
  );
281
171
  await removeStaleChunks(outDir, currentChunks);
282
172
 
283
- // Bundle telemetry → logger (single-logger-entry-point → GlitchTip): the
284
- // initial-download size + lazy-chunk footprint are visible per build, so a
285
- // regression (splitting silently off, or a heavy dep landing in the entry)
286
- // surfaces as a number instead of a mystery "slow page".
287
173
  const entryBytes = (await Bun.file(hashedPath).stat()).size;
288
174
  let chunkBytes = 0;
289
175
  for (const name of currentChunks) {
@@ -298,19 +184,11 @@ export async function buildHydrateBundle(
298
184
  { entryBytes, chunkCount: currentChunks.size, chunkBytes },
299
185
  );
300
186
  if (externalizedBlocks.length > 0) {
301
- // The service boots; these blocks degrade at runtime (BlockErrorBoundary)
302
- // instead of crash-looping the whole service. The stale manifest still
303
- // needs regenerating — surfaced here AND by no-dead-block-imports-in-manifest.
304
187
  log.warn(
305
188
  `[build-client-assets] ${externalizedBlocks.length} unresolvable block import(s) externalized (degraded, not fatal) — regenerate this service's page-manifest: ${externalizedBlocks.join(", ")}`,
306
189
  { externalizedBlocks },
307
190
  );
308
191
  }
309
- // Upload source maps to GlitchTip (best-effort) + RETAIN the .map files
310
- // outside the served root so a failed upload retries next boot — source
311
- // never ships publicly. The currentOutputs set names the build's own
312
- // bundles (entry + chunks) so maps from an older build don't pollute
313
- // this release's cache and re-upload as stale artifacts.
314
192
  const currentOutputs = new Set(
315
193
  result.outputs
316
194
  .filter((o) => o.kind === "chunk")
@@ -320,10 +198,6 @@ export async function buildHydrateBundle(
320
198
  return { hydratePath: `/assets/${hashedName}`, sourceMaps };
321
199
  }
322
200
 
323
- // Remove hashed code-split chunks no longer referenced by the current entry.
324
- // Keyed off the build's own output list (the only authoritative current set),
325
- // never a name pattern guess — a build emits N `chunk-*.js`; the previous
326
- // build's are orphaned the moment the entry's hash changes.
327
201
  async function removeStaleChunks(
328
202
  outDir: string,
329
203
  keep: Set<string>,
@@ -35,13 +35,6 @@ export interface AssetManifest {
35
35
  sourceMaps?: SourceMapUploadResult;
36
36
  }
37
37
 
38
- /**
39
- * Read ONLY the tombstone state of the current manifest — the runtime
40
- * (read-html-template, ui-service) calls this to decide whether to surface a
41
- * build-failure signal. Returns null when the manifest is fresh (no
42
- * `buildFailed`). Decoupled from the full AssetManifest read so the runtime
43
- * never depends on the build internals.
44
- */
45
38
  export async function readClientBuildStatus(
46
39
  cwd = process.cwd(),
47
40
  ): Promise<{ failed: true; reason?: string; at?: string } | { failed: false }> {
@@ -93,17 +86,6 @@ export async function writeManifest(
93
86
  );
94
87
  }
95
88
 
96
- /**
97
- * Verify the CURRENT manifest against the files it names — the capability
98
- * probe the readiness flip gates on (`a-component-may-not-report-a-state-it-
99
- * has-not-verified`). A manifest is servable only when BOTH referenced assets
100
- * exist on disk. An absent or unreadable manifest, a first-boot tombstone
101
- * carrying no paths, and any manifest naming files that do not exist must all
102
- * read as NOT built, never as a legitimate bundle. Measured 2026-09-01:
103
- * proposals.scala.business served a client-facing proposal unstyled while
104
- * Railway reported the deploy SUCCESS, because "client build never ran" was
105
- * indistinguishable from "client build fine" at every layer that reports.
106
- */
107
89
  export async function verifyClientBuildAssets(
108
90
  cwd = process.cwd(),
109
91
  ): Promise<{ ok: true } | { ok: false; reason: string }> {
@@ -7,9 +7,6 @@ import { resolve } from "node:path";
7
7
  import { stat } from "node:fs/promises";
8
8
  import { getAppLogger } from "@teamscala/logger/app-loggers";
9
9
 
10
- // Directory-capable existence probe. Bun.file().exists() answers false for
11
- // directories, so the @source roots (directories by construction) were all
12
- // "absent" and the candidate scan returned nothing.
13
10
  async function existsStat(path: string): Promise<boolean> {
14
11
  try {
15
12
  await stat(path);
@@ -19,16 +16,6 @@ async function existsStat(path: string): Promise<boolean> {
19
16
  }
20
17
  }
21
18
 
22
- // Tailwind v4's low-level `compile()` does NOT scan content — `compiler.build( candidates)` only emits utilities for the candidate class names it is GIVEN.
23
- // `build([])` therefore emits ZERO content-driven utilities (only the static theme/base + @utility/@layer + imported package CSS). The workspace's own
24
- // blocks mostly use inline styles + hand-written `blk-*` package classes (no-tailwind-in-blocks / layout-properties-inline), so the missing utility
25
- // layer was invisible — until the Plate document editor (third-party shadcn components using raw `flex`/`p-1`/`bg-background/95`) rendered completely
26
- // unstyled (operator's tablet, 2026-06-13). The CLI we replaced (`b29e369` "in-process Bun compile") scanned content; the replacement dropped
27
- // the scan. This restores it: glob every resolved `@source` dir, extract candidate tokens, and feed them to `build()`. @tailwindcss/oxide's native
28
- // Scanner is NOT a dependency of the bare `tailwindcss` lib (only of the
29
- // integration packages), so we tokenize ourselves — over-extraction is safe
30
- // (build() ignores any token that isn't a valid utility). Enforced by
31
- // `css-build-scans-source-candidates`.
32
19
  const SCAN_EXTS = new Set([
33
20
  ".ts",
34
21
  ".tsx",
@@ -40,9 +27,6 @@ const SCAN_EXTS = new Set([
40
27
  ".md",
41
28
  ".mdx",
42
29
  ]);
43
- // Class-name candidate chars: identifiers, variant `:`, opacity `/`, arbitrary
44
- // values `[]()`, decimals `.`, important `!`, plus `#%,&-` seen in arbitrary
45
- // values and modifiers. Deliberately broad — invalid tokens are dropped by build().
46
30
  const CANDIDATE_RE = /[A-Za-z0-9_:/[\].%#!,&-]+/g;
47
31
 
48
32
  export async function scanSourceCandidates(
@@ -58,23 +42,7 @@ export async function scanSourceCandidates(
58
42
  } catch {
59
43
  continue;
60
44
  }
61
- // A resolved @source root absent on disk is benign: a published block
62
- // package ships compiled output, not its src/ tree, so an `@source` into
63
- // that package resolves to a non-existent dir. Skip silently rather than
64
- // warn on every UI-service boot (the log-flood class — the ENOENT whose
65
- // path rendered with a trailing null byte). Real errors below still warn.
66
- // The probe MUST be a stat, never Bun.file(root).exists(): root is a
67
- // DIRECTORY and Bun.file() on a directory reports exists() === false
68
- // (the same defect discover-blocks.ts measured 2026-09-01). That probe
69
- // skipped EVERY source, collapsed the candidate set to zero, and
70
- // `build([])` stripped every Tailwind utility from every kind:ui
71
- // service's stylesheet at once — the operator's "the css is totally
72
- // fucked" with a stylesheet that loads and 15k package rules intact.
73
45
  if (!await existsStat(root)) continue;
74
- // A root INSIDE node_modules is an explicit `@source` into an installed
75
- // block package (the app-css transform emits one per package) — scan it
76
- // fully. A root OUTSIDE node_modules is the service's own tree — scan it
77
- // but never descend into node_modules (the entire dependency graph).
78
46
  const rootInNodeModules = root.includes("/node_modules/");
79
47
  try {
80
48
  const glob = new Bun.Glob("**/*");
@@ -97,8 +65,6 @@ export async function scanSourceCandidates(
97
65
  if (matched) for (const t of matched) candidates.add(t);
98
66
  }
99
67
  } catch (err) {
100
- // A bad source dir never aborts the whole CSS build (the other
101
- // sources still contribute their utilities).
102
68
  log.warn(
103
69
  `[build-client-assets] candidate scan skipped ${root}: ${
104
70
  err instanceof Error ? err.message : String(err)
@@ -3,33 +3,6 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
- /**
7
- * Upload source maps to GlitchTip via the official `glitchtip-cli` debug-ID
8
- * flow (the documented first-class mechanism — `sourcemaps inject` then
9
- * `sourcemaps upload --release`; see telemetry.md `client-errors-report-to-glitchtip`
10
- * + https://glitchtip.com/documentation/cli). Debug IDs injected into the served
11
- * bundles + uploaded maps are the matching mechanism — robust vs URL-prefix
12
- * guessing.
13
- *
14
- * THE MAPS OUTLIVE THE UPLOAD ATTEMPT: `.map` files are MOVED out of the
15
- * served root (`dist/client/` — /assets/* would serve source publicly) into
16
- * the retained cache `dist/sourcemaps/<release>/` BEFORE anything else, and
17
- * the upload runs from the cache. A failed or skipped upload therefore
18
- * leaves the maps on disk for the next boot's retry (the fingerprint-skip
19
- * path in build-client-assets.ts re-runs the upload whenever the manifest
20
- * does not record `uploaded`) instead of destroying the only copy of a
21
- * release's maps on an unverified claim (`a-component-may-not-report-a-
22
- * state-it-has-not-verified`; measured: the pre-2026-09 flow deleted the
23
- * maps after an upload whose responses were never checked, so a missing
24
- * token or a 4xx permanently lost a release's maps while the boot log kept
25
- * saying "uploaded").
26
- *
27
- * Every upstream response is verified: the release-create call must return
28
- * 2xx/208 and each file upload must return 2xx, else the failure is named
29
- * (status + body head) in the result. Best-effort at the SERVICE level — a
30
- * failed upload never blocks boot — but never at the CLAIM level: the
31
- * result says what actually happened and the manifest records it.
32
- */
33
6
 
34
7
  import { mkdir, readdir, rename, rm } from "node:fs/promises";
35
8
  import { join } from "node:path";
@@ -41,35 +14,22 @@ export type SourceMapUploadStatus = "uploaded" | "failed" | "skipped";
41
14
 
42
15
  export interface SourceMapUploadResult {
43
16
  status: SourceMapUploadStatus;
44
- /** Maps verified accepted by GlitchTip (2xx), never assumed. */
45
17
  uploaded: number;
46
- /** Maps present for this upload pass (moved to the retained cache). */
47
18
  total: number;
48
- /** One named failure per non-accepted upload (status + body head). */
49
19
  failures: string[];
50
20
  at: string;
51
21
  }
52
22
 
53
23
  const UPLOAD_TIMEOUT_MS = 30_000;
54
24
 
55
- /** The retained, never-served home of a release's source maps. */
56
25
  export function sourceMapsCacheDir(cwd: string, release: string): string {
57
26
  return join(cwd, "dist", "sourcemaps", release);
58
27
  }
59
28
 
60
- /** The release (bare content hash) a manifest's hydratePath names. */
61
29
  export function releaseFromHydratePath(hydratePath: string): string | null {
62
30
  return /hydrate-([a-z0-9]+)\.js/.exec(hydratePath)?.[1] ?? null;
63
31
  }
64
32
 
65
- /**
66
- * Move every `.map` in `outDir` whose base filename is in `currentOutputs`
67
- * into the retained cache for `release`, then DELETE every other `.map` left
68
- * in `outDir` (stale maps from an older build whose release cache should
69
- * already hold a copy — or, on Railway ephemeral disk, was lost; either
70
- * way the served root must never hold one). Returns the names that were
71
- * moved into THIS release's cache.
72
- */
73
33
  async function moveMapsToCache(
74
34
  cwd: string,
75
35
  outDir: string,
@@ -112,7 +72,6 @@ function authEnv(): { token: string; url: string } | null {
112
72
  return { token, url };
113
73
  }
114
74
 
115
- /** Upload the maps ALREADY RETAINED in the cache for `release`. */
116
75
  export async function uploadCachedSourceMaps(
117
76
  cwd: string,
118
77
  release: string,
@@ -144,7 +103,6 @@ export async function uploadCachedSourceMaps(
144
103
  return uploadMapsFromCache(cacheDir, names, release, auth, log, at);
145
104
  }
146
105
 
147
- /** Upload source maps for the bundle just built into `outDir`. */
148
106
  export async function uploadSourceMaps(
149
107
  cwd: string,
150
108
  outDir: string,
@@ -22,9 +22,6 @@ export async function bunResolveStylesheet(
22
22
  return { path: r, base: dirname(r), content: await Bun.file(r).text() };
23
23
  }
24
24
  if (id.startsWith(".")) {
25
- // Relative import: resolve against the importing file's directory and
26
- // REQUIRE existence — a missing leaf is a build-breaking error naming
27
- // the specifier, never a silently-dropped subtree.
28
25
  const p = resolve(base, id);
29
26
  if (!(await Bun.file(p).exists())) {
30
27
  throw new Error(
@@ -33,28 +30,11 @@ export async function bunResolveStylesheet(
33
30
  }
34
31
  return { path: p, base: dirname(p), content: await Bun.file(p).text() };
35
32
  }
36
- // A BARE SPECIFIER IS A PACKAGE NAME PLUS AN OPTIONAL SUBPATH, and the two
37
- // must be separated before anything is looked up. `@teamscala/ui-foundation`
38
- // is a package; `@teamscala/ui-foundation/theme/styles.css` is that same
39
- // package plus an explicit file inside it. Treating the whole string as a
40
- // package name looks for node_modules/@teamscala/ui-foundation/theme/
41
- // styles.css/package.json, which cannot exist — so EVERY subpath import
42
- // fails and the styles bundle dies, taking the theme layer with it.
43
- //
44
- // A scoped name is the FIRST TWO segments (@scope/name); an unscoped name
45
- // is the first. Everything after that is a path inside the package and is
46
- // resolved directly — it names a file, so there is nothing to negotiate and
47
- // no style entry to consult.
48
33
  const segments = id.split("/");
49
34
  const nameLength = id.startsWith("@") ? 2 : 1;
50
35
  const packageName = segments.slice(0, nameLength).join("/");
51
36
  const subpath = segments.slice(nameLength).join("/");
52
37
 
53
- // Find node_modules/<packageName>/package.json by walking upward from the
54
- // importing file's dir then cwd. Bun.resolve is not used: it hands back
55
- // whatever the package's main is — for a normal JS package a .js entry,
56
- // not the declared stylesheet — and an `exports` allow-list blocks the
57
- // package.json subpath on exactly the CSS-only packages this must serve.
58
38
  const tried: string[] = [];
59
39
  for (const root of [base, cwd]) {
60
40
  let dir = root;
@@ -64,13 +44,6 @@ export async function bunResolveStylesheet(
64
44
  const pkgDir = dirname(pkgPath);
65
45
  const pkg = await Bun.file(pkgPath).json();
66
46
  if (subpath) {
67
- // THE EXPORTS MAP IS THE AUTHORITY ON WHERE A SUBPATH LIVES,
68
- // and it commonly REMAPS. `@teamscala/ui-foundation` publishes
69
- // "./theme/styles.css" -> "./src/theme/styles.css", so resolving
70
- // the subpath literally against the package root looks for a
71
- // file that has never existed there. Consult exports first and
72
- // fall back to the literal path only for a package that
73
- // declares none.
74
47
  const exported = pkg.exports?.[`./${subpath}`];
75
48
  const mapped: string | undefined =
76
49
  typeof exported === "string"
@@ -87,7 +60,6 @@ export async function bunResolveStylesheet(
87
60
  }
88
61
  return { path: r, base: dirname(r), content: await Bun.file(r).text() };
89
62
  }
90
- // No subpath: the package itself must declare its stylesheet.
91
63
  const entry: string | undefined = pkg.exports?.["."]?.style ?? pkg.style;
92
64
  if (!entry) {
93
65
  throw new Error(
@@ -108,16 +80,6 @@ export async function bunResolveStylesheet(
108
80
  );
109
81
  }
110
82
 
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
114
- // silently dropped (CSS spec: @import must precede all rules) OR — once hoisted —
115
- // blocked by CSP, leaving a console error on every page; either way the font
116
- // never loaded and the site fell back to a system font (scala-docs 2026-06-13).
117
- // So every remote @import is STRIPPED from the compiled bundle: a package can
118
- // never inject a CDN font/stylesheet, and the only font source is the app-owned
119
- // index.html link. Fixed once at the single CSS chokepoint; guarded by
120
- // `css-build-strips-remote-imports`.
121
83
  const REMOTE_IMPORT_RE =
122
84
  /^[ \t]*@import\s+(?:url\(\s*)?["']https?:\/\/[^"')]+["']\s*\)?[^;]*;[ \t]*\n?/gm;
123
85
 
@@ -144,16 +106,6 @@ export async function buildTailwindCss(
144
106
  await mkdir(outDir, { recursive: true });
145
107
 
146
108
  const cssSource = await Bun.file(entry).text();
147
- // base MUST be the ENTRY FILE's directory (src/), never the service root.
148
- // The generated app.css authors its @source dirs relative to itself
149
- // ("../node_modules/@teamscala/<pkg>/src"); Tailwind 4.3 resolves @source
150
- // against THIS base, so passing the service root aimed every scan one
151
- // level too high (workspace/node_modules — empty) and the candidate set
152
- // collapsed to zero: build([]) strips every Tailwind utility and every
153
- // kind:ui service renders with naked buttons/controls while the
154
- // stylesheet still loads with its full package-rule payload (measured
155
- // 2026-09-05, operator-visible fleet-wide). Package-specifier @imports are
156
- // unaffected — they resolve through loadStylesheet's Bun.resolve walk.
157
109
  const compiler = await compile(cssSource, {
158
110
  base: join(cwd, "src"),
159
111
  async loadStylesheet(id, base) {
@@ -166,8 +118,6 @@ export async function buildTailwindCss(
166
118
  },
167
119
  });
168
120
 
169
- // Scan every resolved @source for candidate class names — WITHOUT this the
170
- // build emits no content-driven utilities (the unstyled-editor bug above).
171
121
  const candidates = await scanSourceCandidates(compiler.sources, log);
172
122
  const cssOutput = stripRemoteImports(compiler.build(candidates));
173
123
  log.info(
@@ -3,23 +3,13 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
- /** Language segment of a deployable name. `bun` is the retired spelling. */
7
6
  const LANGUAGES = ["ts", "bun", "rust"] as const;
8
7
 
9
- /** Tier segment of a deployable name. */
10
8
  const TIERS = ["dev", "prod"] as const;
11
9
 
12
10
  type Language = (typeof LANGUAGES)[number];
13
11
  type Tier = (typeof TIERS)[number];
14
12
 
15
- /**
16
- * The capability half of a deployable slug.
17
- *
18
- * `scala-projects-ts-dev` -> `scala-projects`
19
- * `scala-projects-ts-prod` -> `scala-projects`
20
- * `scala-projects-bun-dev` -> `scala-projects` (retired spelling)
21
- * `scala-projects` -> `scala-projects` (already a capability name)
22
- */
23
13
  export function toCapabilitySlug(slug: string): string {
24
14
  const parts = slug.split("-");
25
15
  if (parts.length < 3) return slug;
@@ -4,20 +4,11 @@
4
4
  */
5
5
 
6
6
  export interface TitleContext {
7
- /** Per-page override (builda_pages.meta_title). Wins outright when set. */
8
7
  pageMetaTitle?: string | null;
9
- /** Resolved resource-template string (forms/quiz/gpt). Curated per-surface shape. */
10
8
  templateTitle?: string | null;
11
- /** builda_websites.name — the surface, the legacy site context (orgPrefix off). */
12
9
  websiteName?: string | null;
13
- /** The OWNING ORG's display name (brand.companyName, resolved from the
14
- * org the page's brand_org_path names). The title prefix when orgPrefix is
15
- * on — operator ruling 2026-08-17: a client-facing document's tab carries
16
- * the org's name, never the internal website label ("Public Pages"). */
17
10
  organisationName?: string | null;
18
- /** builda_pages.name — the page (e.g. "Dashboard"). */
19
11
  pageName?: string | null;
20
- /** Resource entity title (proposal/form/quiz/agent name) — preferred subject. */
21
12
  entityTitle?: string | null;
22
13
  }
23
14
 
@@ -30,36 +30,17 @@ export async function attachContentGateAccess(
30
30
  let email = "";
31
31
  let name = "";
32
32
  let phone = "";
33
- // Optional prospect segmentation a capture gate's composed form may post
34
- // (the quiz identity screen: company, LinkedIn, website, team size). Every
35
- // field is optional and every reader treats absence as "" — a gate that
36
- // never collects them (the GPT) is unaffected, and a form that collects
37
- // only some carries the rest as absent. Field names follow the response
38
- // columns the segmentation lands in (linkedin_url / team_size, snake case)
39
- // because the composed form posts exactly what its FormField rows name.
40
33
  let company = "";
41
34
  let linkedinUrl = "";
42
35
  let website = "";
43
36
  let teamSize = "";
44
37
  let bodyFailed = false;
45
- // The resource id may ride on the URL instead of the body. A gate whose
46
- // form is COMPOSED FROM BLOCKS has no place to put a hidden field — the
47
- // generic submit action collects the visible inputs and adds only the
48
- // static fields its row declares, and a row is shared by every page so it
49
- // cannot carry a per-page id. The gate therefore publishes its own access
50
- // URL with the id already on it (see quiz-gate onUnauthorized), which
51
- // keeps the id single-sourced from the resolved graph. A body field still
52
- // wins where one is posted (proposals, agreements, the GPT).
53
38
  const queryResourceId = String(c.req.query(config.recordIdField) ?? "").trim();
54
39
  try {
55
40
  const form = await c.req.parseBody();
56
41
  resourceId = String(form[config.recordIdField] ?? "").trim() || queryResourceId;
57
42
  email = String(form.email ?? "").trim().toLowerCase();
58
- // Optional captured display name (open-capture gates carry it in the
59
- // token; allow-list gates ignore it via signToken).
60
43
  name = String(form.name ?? "").trim();
61
- // Captured alongside the email by an open-capture gate's composed
62
- // form. It rides in the signed token rather than any client store.
63
44
  phone = String(form.phone ?? "").trim();
64
45
  company = String(form.company ?? "").trim();
65
46
  linkedinUrl = String(form.linkedin_url ?? "").trim();
@@ -74,9 +55,6 @@ export async function attachContentGateAccess(
74
55
 
75
56
  let matched = false;
76
57
  if (config.openCapture) {
77
- // Open lead-capture gate (the public GPT): any non-empty email is a
78
- // valid visitor — there is no allow-list to authorize against. We
79
- // already required a non-empty email above, so this is a match.
80
58
  matched = true;
81
59
  } else {
82
60
  try {
@@ -112,10 +90,6 @@ export async function attachContentGateAccess(
112
90
  `${config.cookieName}=${encodeURIComponent(token)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`,
113
91
  );
114
92
  if (config.entryCookieName) {
115
- // The ENTRY MARKER: it authorizes exactly the redirect this sets it
116
- // on — the gate requires it and the render it consumed clears it.
117
- // Max-Age matches the token only as a safety net; consumption is
118
- // what ends its life, never the clock.
119
93
  c.header(
120
94
  "Set-Cookie",
121
95
  `${config.entryCookieName}=1; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`,
@@ -3,33 +3,7 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
- /**
7
- * Registered gates, in evaluation order.
8
- *
9
- * ORDER IS LOAD-BEARING AND SO IT IS DECLARED, NOT INCIDENTAL. Proposals run
10
- * before agreements: an authorized proposal viewer has their gate block removed
11
- * from the resolved graph, after which the agreement gate finds no resource and
12
- * no-ops. Reversing these two changes behaviour on a page carrying both.
13
- *
14
- * Each gate still self-selects — `getResourceId` returns "" when the page is not
15
- * its kind — so listing a gate here never makes it apply to a page it does not
16
- * govern.
17
- */
18
6
 
19
- /**
20
- * Does this gate WITHHOLD content, or only identify the visitor?
21
- *
22
- * DERIVED FROM THE ACCESS CONFIG, NEVER DECLARED TWICE. `openCapture` already
23
- * states that any email is a valid visitor, which is precisely the case where
24
- * nothing is being withheld — the GPT page body is public and only its identity
25
- * is gated (see gptGateConfig.onUnauthorized, a documented no-op). A separate
26
- * `withholdsContent` flag would be a second copy of that fact, free to drift
27
- * from the one the access endpoint actually enforces (`offered-is-derived`).
28
- *
29
- * The distinction matters to the assistant: a seed-only gate must not count as
30
- * "governing" this page, or an anonymous visitor to a public GPT page would be
31
- * read as an unauthorized viewer and lose an assistant they are entitled to.
32
- */
33
7
 
34
8
  import type { AccessConfig, ContentGateConfig } from "./config-types.ts";
35
9
  import { agreementAccessConfig, agreementGateConfig } from "../agreement-gate.ts";
@@ -37,9 +11,7 @@ import { gptAccessConfig, gptGateConfig } from "../gpt-gate.ts";
37
11
  import { proposalAccessConfig, proposalGateConfig } from "../proposal-gate.ts";
38
12
  import { quizAccessConfig, quizGateConfig } from "../quiz-gate.ts";
39
13
 
40
- /** One gated content type: its SSR gate and its public access endpoint. */
41
14
  export interface ContentGateEntry {
42
- /** Stable identity for this gate — what a `page_route` will name to select it. */
43
15
  slug: string;
44
16
  gate: ContentGateConfig;
45
17
  access: AccessConfig;
@@ -56,7 +28,6 @@ export function withholdsContent(entry: ContentGateEntry): boolean {
56
28
  return entry.access.openCapture !== true;
57
29
  }
58
30
 
59
- /** The gates that can withhold content, in order — the assistant's authority. */
60
31
  export function contentWithholdingGates(): ContentGateEntry[] {
61
32
  return CONTENT_GATES.filter(withholdsContent);
62
33
  }