@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.
- package/package.json +1 -11
- package/src/agreement-gate/authorized-emails.ts +0 -7
- package/src/agreement-gate/placeholder-strip.ts +0 -2
- package/src/agreement-gate/settings.ts +0 -15
- package/src/agreement-gate.ts +0 -34
- package/src/attach-proposal-accept.ts +1 -1
- package/src/attach-proposal-magic.ts +1 -1
- package/src/auth/sign-out.ts +0 -2
- package/src/auth-gate/public-paths.ts +0 -86
- package/src/auth-gate.ts +0 -19
- package/src/bind-page-handler.ts +0 -76
- package/src/build-client-assets/hydrate-bundle.ts +0 -126
- package/src/build-client-assets/manifest.ts +0 -18
- package/src/build-client-assets/source-candidates.ts +0 -34
- package/src/build-client-assets/sourcemaps-upload.ts +0 -42
- package/src/build-client-assets/styles-build.ts +0 -50
- package/src/capability-slug.ts +0 -10
- package/src/compose-page-title.ts +0 -9
- package/src/content-gate/access.ts +0 -26
- package/src/content-gate/catalog.ts +0 -29
- package/src/content-gate/config-types.ts +3 -84
- package/src/content-gate/gate.ts +0 -20
- package/src/content-page-floor.ts +0 -11
- package/src/error-page-logo.ts +2 -12
- package/src/expand-roster.ts +0 -9
- package/src/gpt-gate.ts +1 -1
- package/src/hono-error-handler.ts +0 -4
- package/src/install-logging.ts +0 -32
- package/src/load-page-manifest.ts +0 -6
- package/src/load-service-install-row.ts +41 -4
- package/src/module-not-found.ts +0 -11
- package/src/mount-orpc-router.ts +1 -88
- package/src/mount-server-packages.ts +0 -29
- package/src/mutable-app-ref.ts +0 -11
- package/src/orpc-contract-rows.json +4102 -61
- package/src/orpc-openapi-document.ts +0 -6
- package/src/orpc-openapi-gate.ts +0 -10
- package/src/pg-boss-handler-registry.ts +0 -12
- package/src/pg-boss-mount.ts +0 -101
- package/src/proposal-gate.ts +1 -41
- package/src/quiz-gate.ts +1 -9
- package/src/register-page-server-client.ts +0 -53
- package/src/register-registry-page-route/ai-chat-prefetch.ts +0 -18
- package/src/register-registry-page-route/apply-page-branding.ts +0 -90
- package/src/register-registry-page-route/captured-loader-config.ts +0 -1
- package/src/register-registry-page-route/live-loader-config.ts +0 -16
- package/src/register-registry-page-route/meta-title.ts +0 -24
- package/src/register-registry-page-route/page-data-shared.ts +0 -6
- package/src/register-registry-page-route/prefetch-org-id.ts +0 -17
- package/src/register-registry-page-route/proposal-approval-email-token.ts +0 -11
- package/src/register-registry-page-route/proposal-brand-stamping.ts +0 -20
- package/src/register-registry-page-route/public-shell-page-data.ts +0 -26
- package/src/register-registry-page-route/resolve-request-graph.ts +2 -155
- package/src/register-registry-page-route/run-content-gates.ts +2 -54
- package/src/register-registry-page-route/warn-uncovered-blocks.ts +0 -10
- package/src/register-registry-page-route.ts +0 -85
- package/src/register-static-assets.ts +0 -45
- package/src/resolve-brand-favicon.ts +0 -9
- package/src/resolve-service-port.ts +0 -11
- package/src/resolve-theme-mode.ts +0 -8
- package/src/resolvers/app-header.ts +0 -1
- package/src/resolvers/fetch-org-brand.ts +0 -1
- package/src/resolvers/portal-route-paths.ts +0 -17
- package/src/resource-not-found.ts +0 -24
- package/src/select-service-routes.ts +0 -14
- package/src/theme-fonts-link.ts +0 -5
- package/src/two-phase-full-app.ts +0 -76
- package/src/two-phase-minimal-app.ts +1 -105
- package/src/types.ts +0 -14
- package/src/ui-service.ts +1 -42
- package/src/content-gate/gate-entry-marker.test.ts +0 -111
- package/src/content-gate/token.segmentation.test.ts +0 -76
- package/src/content-gate/token.test.ts +0 -65
- package/src/content-gate/token.ts +0 -197
- package/src/is-react-element-type.test.ts +0 -45
- package/src/is-react-element-type.ts +0 -10
- package/src/page-server-orpc/hub-mcp-client.ts +0 -14
- package/src/pg-boss-mount.test.ts +0 -101
- package/src/register-registry-page-route/graph-cache.test.ts +0 -81
- package/src/register-registry-page-route/graph-cache.ts +0 -71
- package/src/resource-chat-inject.page-assistant.test.ts +0 -145
- package/src/resource-chat-inject.test.ts +0 -240
- 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(
|
package/src/capability-slug.ts
CHANGED
|
@@ -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
|
}
|