@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,39 +3,6 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Build the minimal tool-only app. This excludes the expensive operations:
|
|
8
|
-
* - Client asset building (Vite build)
|
|
9
|
-
* - Boot hooks (may have expensive initialization)
|
|
10
|
-
* - Scheduled jobs (can start later)
|
|
11
|
-
* - pg-boss (can start later)
|
|
12
|
-
*
|
|
13
|
-
* CRITICAL: All route registration MUST happen here before serving, because
|
|
14
|
-
* Hono's RegExpRouter compiles on the first request and becomes immutable.
|
|
15
|
-
* Once compiled, no new routes can be added. So we include:
|
|
16
|
-
* - Block discovery and page routes (registers all page paths)
|
|
17
|
-
* - All other routes (API, WS, data, static assets)
|
|
18
|
-
*
|
|
19
|
-
* The minimal app includes everything needed for MCP tools and ORPC:
|
|
20
|
-
* - Error handling, readiness gate, health check
|
|
21
|
-
* - Security headers, CORS, compression, timing
|
|
22
|
-
* - Content gate access, auth gates, static assets
|
|
23
|
-
* - ORPC router, API routes, WS routes, data routes, page routes
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
// Fire independent I/O + compilation NOW, overlapping with the gate chain
|
|
27
|
-
// below. These three touch NO app route and have no ordering dependency on
|
|
28
|
-
// the gate/mount sequence, so they run concurrently with it:
|
|
29
|
-
// - preloadServerPackages: warms Bun's module cache (CPU-bound compilation
|
|
30
|
-
// that overlaps the gate chain's cross-internet DB I/O — same speculative
|
|
31
|
-
// pattern as create-boot-pipeline's preloads). mountServerPackages later
|
|
32
|
-
// hits the cache (instant imports), paying only the ~33ms of mount work.
|
|
33
|
-
// - initGateTtls: loads config rows into module-level vars. The TTL getters
|
|
34
|
-
// are read at REQUEST time (inside route-handler closures), not at boot,
|
|
35
|
-
// so this only needs to finish before serving — safe to run in parallel.
|
|
36
|
-
// - discoverBlocks: loads the page-manifest (no app mutation, no gate
|
|
37
|
-
// dependency). Its result is consumed by registerPageRoutes much later.
|
|
38
|
-
|
|
39
6
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
40
7
|
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
41
8
|
import { createTimer } from "@teamscala/timing/create-timer";
|
|
@@ -55,7 +22,7 @@ import { attachPortalMembers } from "./attach-portal-members.ts";
|
|
|
55
22
|
import { attachTransfersUpload } from "./attach-transfers-upload.ts";
|
|
56
23
|
import { attachAuthGate } from "./auth-gate.ts";
|
|
57
24
|
import { csrfProtection } from "./csrf-protection.ts";
|
|
58
|
-
import { attachRequestLogger } from "
|
|
25
|
+
import { attachRequestLogger } from "@teamscala/logger/install-logging";
|
|
59
26
|
import type { ServiceInstallConfig } from "./load-service-install-row.ts";
|
|
60
27
|
import { mountOrpcRouter } from "./mount-orpc-router.ts";
|
|
61
28
|
import { mountServerPackages, preloadServerPackages } from "./mount-server-packages.ts";
|
|
@@ -101,13 +68,6 @@ export async function buildMinimalApp(
|
|
|
101
68
|
|
|
102
69
|
const app = new Hono();
|
|
103
70
|
|
|
104
|
-
// Per-step instrumentation inside the boot-ui-service phase. Same
|
|
105
|
-
// createTimer + wall-clock convention as create-boot-pipeline's runPhase
|
|
106
|
-
// (timing-is-the-only-timing, per-phase-instrumentation) — NOT a second
|
|
107
|
-
// telemetry surface. The boot-ui-service phase is 60% of boot; this
|
|
108
|
-
// breakdown localises the cost so optimisation targets the real hotspot
|
|
109
|
-
// instead of guessing. Spans flow to GlitchTip (timing's trace-emitter is
|
|
110
|
-
// wired by the time we reach here); the breakdown line is `pm2 logs`-visible.
|
|
111
71
|
const stepTimings: Record<string, number> = {};
|
|
112
72
|
async function runStep<T>(step: string, fn: () => Promise<T>): Promise<T> {
|
|
113
73
|
const timer = createTimer(`${serviceSlug}:boot.minimal.${step}`);
|
|
@@ -119,7 +79,6 @@ export async function buildMinimalApp(
|
|
|
119
79
|
}
|
|
120
80
|
}
|
|
121
81
|
|
|
122
|
-
// Error handlers
|
|
123
82
|
app.onError(createRequestErrorHandler("service-boot:minimal"));
|
|
124
83
|
|
|
125
84
|
app.notFound(async (c) => {
|
|
@@ -131,7 +90,6 @@ export async function buildMinimalApp(
|
|
|
131
90
|
return c.json({ error: "Not Found" }, 404);
|
|
132
91
|
});
|
|
133
92
|
|
|
134
|
-
// Core middleware
|
|
135
93
|
app.use("*", readinessGate);
|
|
136
94
|
app.get("/health", healthHandler);
|
|
137
95
|
app.use("*", injectSecurityHeaders);
|
|
@@ -151,19 +109,7 @@ export async function buildMinimalApp(
|
|
|
151
109
|
);
|
|
152
110
|
app.use("*", compress());
|
|
153
111
|
app.use("*", timing());
|
|
154
|
-
// CSRF (`security-csrf-protection`): state-changing form-element requests
|
|
155
|
-
// must carry a trusted Origin — derived set (managed base domain +
|
|
156
|
-
// builda_websites custom domains via isTrustedAuthOrigin), never an
|
|
157
|
-
// allowlist beside better-auth's. Registered after security headers, before
|
|
158
|
-
// every route + gate.
|
|
159
112
|
app.use("*", csrfProtection());
|
|
160
|
-
// GLOBAL body limit (`security-body-limit`): every request body is bounded
|
|
161
|
-
// BEFORE any handler buffers it — a per-route file.size check AFTER
|
|
162
|
-
// formData() has buffered is a report, not a limit. 30MB sits above the
|
|
163
|
-
// 25MB transfers-upload maximum; routes wanting a smaller bound mount their
|
|
164
|
-
// own bodyLimit BEFORE this one (Hono runs matching middleware in
|
|
165
|
-
// registration order, and /api/rpc registers its 10MB limit in
|
|
166
|
-
// mount-orpc-router).
|
|
167
113
|
app.use(
|
|
168
114
|
"*",
|
|
169
115
|
bodyLimit({
|
|
@@ -180,20 +126,8 @@ export async function buildMinimalApp(
|
|
|
180
126
|
);
|
|
181
127
|
const ttlPromise = runStep("init-gate-ttls", () => initGateTtls());
|
|
182
128
|
|
|
183
|
-
// Public access endpoints — MUST be registered before the auth gate (Hono
|
|
184
|
-
// dispatches middleware + handlers in registration order; the content-gate
|
|
185
|
-
// POST routes and magic-link endpoints are public and must be reachable
|
|
186
|
-
// without a session, so they precede the auth gate's app.use("*") wildcard).
|
|
187
|
-
// These are mutually independent — distinct paths, no shared mutable state
|
|
188
|
-
// beyond the Hono app's synchronous route array — so they parallelise.
|
|
189
129
|
await runStep("public-routes", () =>
|
|
190
130
|
Promise.all([
|
|
191
|
-
// EVERY REGISTERED GATE'S ACCESS ENDPOINT (content-gate/catalog.ts).
|
|
192
|
-
// The same catalog the SSR gate sequence iterates, so the endpoint that
|
|
193
|
-
// MINTS a gate's token and the gate that VERIFIES it cannot disagree
|
|
194
|
-
// about which gates exist — this list and that one were two copies of
|
|
195
|
-
// one fact, and a gate added to one and missed in the other is a page
|
|
196
|
-
// that gates content with no way to unlock it.
|
|
197
131
|
...CONTENT_GATES.map((entry) =>
|
|
198
132
|
attachContentGateAccess(app, serviceSlug, entry.access),
|
|
199
133
|
),
|
|
@@ -205,27 +139,14 @@ export async function buildMinimalApp(
|
|
|
205
139
|
]),
|
|
206
140
|
);
|
|
207
141
|
|
|
208
|
-
// Auth gate — registers /api/auth/* + the session-check wildcard middleware.
|
|
209
|
-
// MUST follow the public routes above (load-bearing Hono registration order)
|
|
210
|
-
// and MUST precede mountServerPackages: server-package daemon routes (e.g.
|
|
211
|
-
// cli-session's /rpc/*) are subject to the session gate, which requires the
|
|
212
|
-
// gate's wildcard registered first to apply. Running these concurrently would
|
|
213
|
-
// make that ordering non-deterministic — so the gate stays serial here.
|
|
214
142
|
await runStep("attach-auth-gate", () =>
|
|
215
143
|
attachAuthGate(app, config, serviceSlug),
|
|
216
144
|
);
|
|
217
145
|
registerStaticAssets(app, serviceSlug, config.brand_org);
|
|
218
146
|
|
|
219
|
-
// Server packages — the cold imports were preloaded above and are now in
|
|
220
|
-
// Bun's module cache, so this pays only the mount work (~33ms, not ~2.8s of
|
|
221
|
-
// serial compilation). Route registration happens HERE (after the auth gate),
|
|
222
|
-
// preserving the gate-vs-daemon-route ordering.
|
|
223
147
|
const mountRegistry = await runStep("mount-server-packages", () =>
|
|
224
148
|
mountServerPackages(app, config, serviceSlug, getSharedDb),
|
|
225
149
|
);
|
|
226
|
-
// Ensure the speculative preload finished before proceeding (it populates
|
|
227
|
-
// the same cache mountServerPackages just consumed; awaiting now is a no-op
|
|
228
|
-
// if the preload already settled, and makes the ordering guarantee explicit).
|
|
229
150
|
await preloadPromise;
|
|
230
151
|
const apiRouteCount = registerApiRoutes(app, config, mountRegistry, serviceSlug);
|
|
231
152
|
|
|
@@ -233,17 +154,10 @@ export async function buildMinimalApp(
|
|
|
233
154
|
await runStep("mount-orpc-router", () => mountOrpcRouter(app, serviceSlug));
|
|
234
155
|
}
|
|
235
156
|
|
|
236
|
-
// Register the in-process ORPC server client BEFORE discoverBlocks — it
|
|
237
|
-
// calls getServerClient() which needs the client set here. Must follow
|
|
238
|
-
// mount-orpc-router (which builds the router getAppRouter uses).
|
|
239
|
-
// Previously ran inside register-page-routes (too late — discoverBlocks
|
|
240
|
-
// fired before it, hitting the empty-router fallback).
|
|
241
157
|
await runStep("register-page-server-client", () =>
|
|
242
158
|
registerPageServerClient(serviceSlug),
|
|
243
159
|
);
|
|
244
160
|
|
|
245
|
-
// NOW load the generated manifest (getServerClient is set). Awaited below
|
|
246
|
-
// before register-page-routes consumes the result.
|
|
247
161
|
const discoverPromise = runStep("load-page-manifest", () =>
|
|
248
162
|
loadPageManifest(serviceSlug),
|
|
249
163
|
);
|
|
@@ -252,14 +166,8 @@ export async function buildMinimalApp(
|
|
|
252
166
|
registerWsRoutes(app, config, mountRegistry, serviceSlug, upgradeWebSocket),
|
|
253
167
|
);
|
|
254
168
|
|
|
255
|
-
// Resolve the deferred TTL load (started at the top — should be long done,
|
|
256
|
-
// overlapped with the gate chain + mount work). The getters are read at
|
|
257
|
-
// request time, but the cache must be populated before the first request.
|
|
258
169
|
await ttlPromise;
|
|
259
170
|
|
|
260
|
-
// CRITICAL: Block discovery and page routes MUST be registered BEFORE serving,
|
|
261
|
-
// because Hono's RegExpRouter compiles on first request and becomes immutable.
|
|
262
|
-
// We defer only client asset building (expensive Vite build) to Phase 2.
|
|
263
171
|
let components: Awaited<ReturnType<typeof loadPageManifest>>;
|
|
264
172
|
try {
|
|
265
173
|
components = await discoverPromise;
|
|
@@ -276,15 +184,10 @@ export async function buildMinimalApp(
|
|
|
276
184
|
);
|
|
277
185
|
|
|
278
186
|
|
|
279
|
-
// Run boot hooks BEFORE serving - some hooks may register routes and must
|
|
280
|
-
// run before Hono's matcher is compiled by the first request
|
|
281
187
|
await runStep("run-boot-hooks", () =>
|
|
282
188
|
runBootHookList(config, mountRegistry, serviceSlug),
|
|
283
189
|
);
|
|
284
190
|
|
|
285
|
-
// Register page routes with a dynamic handler that delegates to the real handler once Phase 2 completes.
|
|
286
|
-
// All route paths must be registered before serving to avoid Hono
|
|
287
|
-
// RegExpRouter compilation issues (matcher becomes immutable).
|
|
288
191
|
const registeredPageRoutes = await runStep("register-page-routes", () =>
|
|
289
192
|
registerPageRoutes({
|
|
290
193
|
app,
|
|
@@ -299,10 +202,6 @@ export async function buildMinimalApp(
|
|
|
299
202
|
const steps = Object.entries(stepTimings)
|
|
300
203
|
.map(([step, ms]) => `${step}=${ms}ms`)
|
|
301
204
|
.join(" ");
|
|
302
|
-
// NOTE: several steps run concurrently (speculative preload, init-gate-ttls,
|
|
303
|
-
// discover-blocks overlap the gate chain), so the step sum exceeds the
|
|
304
|
-
// wall-clock buildMinimalApp duration. Compare to the boot-ui-service phase
|
|
305
|
-
// timing in the Boot complete line for the true wall-clock cost.
|
|
306
205
|
log.info(
|
|
307
206
|
`[two-phase-boot:${serviceSlug}] buildMinimalApp breakdown (steps overlap) — ${steps}`,
|
|
308
207
|
);
|
|
@@ -322,9 +221,6 @@ export async function buildMinimalApp(
|
|
|
322
221
|
databaseUrl,
|
|
323
222
|
blockPackages,
|
|
324
223
|
components,
|
|
325
|
-
// The generated manifest IS the loaded set — every entry it exports is a
|
|
326
|
-
// block this service renders, so there is no "skipped" half any more. A
|
|
327
|
-
// row that cannot produce a component fails codegen, not boot.
|
|
328
224
|
loaded: Object.keys(components),
|
|
329
225
|
skipped: [] as string[],
|
|
330
226
|
};
|
package/src/types.ts
CHANGED
|
@@ -8,9 +8,6 @@ type RendererComponent = (props: Record<string, unknown>) => unknown;
|
|
|
8
8
|
export interface RendererComponentEntry {
|
|
9
9
|
render: RendererComponent;
|
|
10
10
|
fields?: Record<string, { type: string }>;
|
|
11
|
-
/** block_schema.defaults, carried onto the entry by discoverBlocks so
|
|
12
|
-
* buildPuckConfig merges them under authored props at SSR
|
|
13
|
-
* (blocks-fully-programmable). */
|
|
14
11
|
defaultProps?: Record<string, unknown>;
|
|
15
12
|
}
|
|
16
13
|
|
|
@@ -31,18 +28,7 @@ export interface PageHandlerConfig {
|
|
|
31
28
|
providers?: string[];
|
|
32
29
|
loadData: (request: Request) => Promise<unknown>;
|
|
33
30
|
renderMode?: "public" | "portals" | "utility";
|
|
34
|
-
/**
|
|
35
|
-
* Owning service slug — used by the page-server render catch to resolve
|
|
36
|
-
* the branded logo on the render-error page. Mirrors service-runtime's
|
|
37
|
-
* PageHandlerConfig.
|
|
38
|
-
*/
|
|
39
31
|
serviceSlug?: string;
|
|
40
|
-
/**
|
|
41
|
-
* Redirect a signed-in user's `/` to their org-scoped portal dashboard.
|
|
42
|
-
* Set only for services serving a `/*` catchall page_route (the portal);
|
|
43
|
-
* single-site portal-mode services serve their real page at `/`.
|
|
44
|
-
* Mirrors service-runtime's PageHandlerConfig (1.15.82).
|
|
45
|
-
*/
|
|
46
32
|
portalHomeRedirect?: boolean;
|
|
47
33
|
}
|
|
48
34
|
|
package/src/ui-service.ts
CHANGED
|
@@ -17,32 +17,7 @@
|
|
|
17
17
|
* place and is NOT undone.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
// the same one the backend branch uses. The registry wins wherever it has an
|
|
22
|
-
// opinion and a contradicting PORT env is reported, never silently obeyed —
|
|
23
|
-
// full reasoning in that file.
|
|
24
|
-
//
|
|
25
|
-
// This branch previously took `process.env.PORT` unconditionally, which is
|
|
26
|
-
// how scala-quiz (declared 5203) booted onto session-picker's 3021 from a
|
|
27
|
-
// leaked env and silently split that daemon's traffic (2026-07-31). On the
|
|
28
|
-
// dev host the registry wins a conflict (below). On Railway the injected PORT
|
|
29
|
-
// wins instead — Railway healthchecks exactly that port, and the row's port
|
|
30
|
-
// is the service's LOCAL dev port (e.g. portal 5200), so binding it on Railway
|
|
31
|
-
// makes the healthcheck probe a dead port and the deploy fails (`isRailway`
|
|
32
|
-
// path in the resolver).
|
|
33
|
-
|
|
34
|
-
// NO-SILENT-REVERTS — surface the failed client build to OBSERVABILITY
|
|
35
|
-
// (event-log → GlitchTip, per event-log-is-the-only-durable-event-log).
|
|
36
|
-
// The minimal-app fallback keeps serving SSR from the LAST-successful
|
|
37
|
-
// manifest (now a tombstone, see build-client-assets.ts), so the page
|
|
38
|
-
// looks "up" while the client bundle is stale. Without this signal the
|
|
39
|
-
// failure is buried in boot stderr and a published fix that reached
|
|
40
|
-
// node_modules but failed to bundle ships invisibly stale (the
|
|
41
|
-
// scala-agents 2026-07-18 incident: ui-foundation@3.4.5 installed but
|
|
42
|
-
// served pre-fix bytes across restarts while node_modules was
|
|
43
|
-
// mid-install). The boot.client-build-failed event routes into
|
|
44
|
-
// event-log's 7-day retention → surfaces in GlitchTip as an incident
|
|
45
|
-
// trail the operator actually watches.
|
|
20
|
+
|
|
46
21
|
|
|
47
22
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
48
23
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
@@ -91,7 +66,6 @@ export async function bootUiService(
|
|
|
91
66
|
const log = getAppLogger();
|
|
92
67
|
const startTime = Date.now();
|
|
93
68
|
|
|
94
|
-
// Phase 1: Build minimal tool-only app
|
|
95
69
|
const minimalAppResult = await buildMinimalApp(
|
|
96
70
|
config,
|
|
97
71
|
serviceSlug,
|
|
@@ -102,12 +76,6 @@ export async function bootUiService(
|
|
|
102
76
|
|
|
103
77
|
const isRailway = Boolean(process.env.RAILWAY_ENVIRONMENT);
|
|
104
78
|
const envPort = process.env.PORT ? Number(process.env.PORT) : undefined;
|
|
105
|
-
// The APP binds its PRIVATE port (app_port) when present — for scale-to-zero
|
|
106
|
-
// services `port` is the ACTIVATOR's ingress (what callers resolve), not the
|
|
107
|
-
// app's bind. Falling back to `port` keeps RESIDENT + unmigrated services
|
|
108
|
-
// unchanged. Without this, a migrated row (port=activator) would make the app
|
|
109
|
-
// bind the activator's port → SO_REUSEPORT silent load-balancing (the
|
|
110
|
-
// service-ports-from-registry trap: clean boot, two owners in ss -ltnp).
|
|
111
79
|
const declaredAppPort = config.app_port ?? config.port;
|
|
112
80
|
const resolution = resolveServicePort(envPort, declaredAppPort, isRailway);
|
|
113
81
|
if ("error" in resolution) {
|
|
@@ -129,21 +97,17 @@ export async function bootUiService(
|
|
|
129
97
|
}
|
|
130
98
|
const port = requireValidPort(resolution.port, serviceSlug);
|
|
131
99
|
|
|
132
|
-
// service-runtime owns the TCP listener. service-boot only composes the app
|
|
133
|
-
// and starts the two-phase build; the runtime binds the returned app.
|
|
134
100
|
const minimalReadyTime = Date.now();
|
|
135
101
|
const minimalDuration = minimalReadyTime - startTime;
|
|
136
102
|
log.info(
|
|
137
103
|
`[ui-service-boot:${serviceSlug}] Minimal app composed on port ${port} in ${minimalDuration}ms`,
|
|
138
104
|
);
|
|
139
105
|
|
|
140
|
-
// FLIP READY SWITCH: minimal app is ready, MCP tools can connect
|
|
141
106
|
setReady();
|
|
142
107
|
log.info(
|
|
143
108
|
`[ui-service-boot:${serviceSlug}] Ready gate flipped - MCP tools can now connect (full app building in background)`,
|
|
144
109
|
);
|
|
145
110
|
|
|
146
|
-
// Phase 2: Build full app in background (non-blocking)
|
|
147
111
|
;(async () => {
|
|
148
112
|
try {
|
|
149
113
|
const PHASE2_TIMEOUT_MS = 120_000;
|
|
@@ -159,7 +123,6 @@ export async function bootUiService(
|
|
|
159
123
|
const fullBuildTime = Date.now();
|
|
160
124
|
const fullDuration = fullBuildTime - startTime;
|
|
161
125
|
|
|
162
|
-
// Swap the app reference atomically
|
|
163
126
|
swapToFullApp(fullAppResult.app, serviceSlug);
|
|
164
127
|
|
|
165
128
|
log.info(
|
|
@@ -178,13 +141,9 @@ export async function bootUiService(
|
|
|
178
141
|
at: new Date().toISOString(),
|
|
179
142
|
},
|
|
180
143
|
});
|
|
181
|
-
// Continue with minimal app - better than crashing
|
|
182
144
|
}
|
|
183
145
|
})();
|
|
184
146
|
|
|
185
|
-
// Return result compatible with the existing UiServiceBootResult interface
|
|
186
|
-
// Note: blocks/routes will be empty initially since they're built in the background.
|
|
187
|
-
// The service is operational for MCP tools.
|
|
188
147
|
return {
|
|
189
148
|
kind: "ui",
|
|
190
149
|
blocks: [], // Will be populated when full app builds
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Locks the OPT-IN gate-entry marker. A gate that does NOT declare
|
|
7
|
-
* entryCookieName must behave exactly as before — a valid token authorizes on
|
|
8
|
-
* any load and nothing is recorded for clearing (condition 1: every non-opting
|
|
9
|
-
* service unchanged). An opting gate authorizes ONLY the request that carries
|
|
10
|
-
* the marker (the identity submit's own redirect) and returns the marker name
|
|
11
|
-
* so the page Response clears it — one view, then the gate is presented again.
|
|
12
|
-
*
|
|
13
|
-
* The clears are asserted from the CALL'S RETURN VALUE: the store lives only
|
|
14
|
-
* inside the run() scope, and the first shipped version read it afterwards,
|
|
15
|
-
* saw nothing, and left the marker in the browser (measured live 2026-09-14).
|
|
16
|
-
*
|
|
17
|
-
* Operator ruling 2026-09-14 (refresh must return to the identity gate),
|
|
18
|
-
* mechanism measured live: the quiz_token cookie alone was the carrier that
|
|
19
|
-
* skipped the gate on reload.
|
|
20
|
-
*/
|
|
21
|
-
import { describe, expect, test } from "bun:test";
|
|
22
|
-
import type { ContentGateConfig } from "./config-types.ts";
|
|
23
|
-
import { runContentGate } from "./gate.ts";
|
|
24
|
-
import { withGateEntryClears } from "./gate-entry-clears.ts";
|
|
25
|
-
|
|
26
|
-
function makeConfig(entryCookieName?: string) {
|
|
27
|
-
const calls = { authorized: 0, unauthorized: 0 };
|
|
28
|
-
const cfg: ContentGateConfig = {
|
|
29
|
-
cookieName: "quiz_token",
|
|
30
|
-
entryCookieName,
|
|
31
|
-
getResourceId: () => "res-1",
|
|
32
|
-
verifyToken: (token) =>
|
|
33
|
-
token === "good" ? { email: "v@example.com", name: "V" } : null,
|
|
34
|
-
onAuthorized: () => {
|
|
35
|
-
calls.authorized++;
|
|
36
|
-
},
|
|
37
|
-
onUnauthorized: () => {
|
|
38
|
-
calls.unauthorized++;
|
|
39
|
-
},
|
|
40
|
-
};
|
|
41
|
-
return { cfg, calls };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
describe("gate entry marker is opt-in and one-view", () => {
|
|
45
|
-
test("non-opting gate: a valid token authorizes on any load, nothing cleared — behaviour unchanged", async () => {
|
|
46
|
-
const { cfg, calls } = makeConfig();
|
|
47
|
-
const { clears } = await withGateEntryClears(async () => {
|
|
48
|
-
const out = await runContentGate({}, "quiz_token=good", Date.now(), cfg);
|
|
49
|
-
expect(out.authorized).toBe(true);
|
|
50
|
-
expect(calls.authorized).toBe(1);
|
|
51
|
-
});
|
|
52
|
-
expect(clears).toEqual([]);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("opting gate: token WITHOUT the marker is unauthorized — a refresh shows the gate", async () => {
|
|
56
|
-
const { cfg, calls } = makeConfig("quiz_token_entered");
|
|
57
|
-
const { clears } = await withGateEntryClears(async () => {
|
|
58
|
-
const out = await runContentGate({}, "quiz_token=good", Date.now(), cfg);
|
|
59
|
-
expect(out.authorized).toBe(false);
|
|
60
|
-
expect(calls.unauthorized).toBe(1);
|
|
61
|
-
expect(calls.authorized).toBe(0);
|
|
62
|
-
});
|
|
63
|
-
expect(clears).toEqual([]);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("opting gate: token + marker authorizes and RETURNS the marker for clearing — one view", async () => {
|
|
67
|
-
const { cfg, calls } = makeConfig("quiz_token_entered");
|
|
68
|
-
const { clears } = await withGateEntryClears(async () => {
|
|
69
|
-
const out = await runContentGate(
|
|
70
|
-
{},
|
|
71
|
-
"quiz_token=good; quiz_token_entered=1",
|
|
72
|
-
Date.now(),
|
|
73
|
-
cfg,
|
|
74
|
-
);
|
|
75
|
-
expect(out.authorized).toBe(true);
|
|
76
|
-
expect(calls.authorized).toBe(1);
|
|
77
|
-
});
|
|
78
|
-
expect(clears).toEqual(["quiz_token_entered"]);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("opting gate: marker present but token invalid stays unauthorized and clears nothing", async () => {
|
|
82
|
-
const { cfg, calls } = makeConfig("quiz_token_entered");
|
|
83
|
-
const { clears } = await withGateEntryClears(async () => {
|
|
84
|
-
const out = await runContentGate(
|
|
85
|
-
{},
|
|
86
|
-
"quiz_token=bad; quiz_token_entered=1",
|
|
87
|
-
Date.now(),
|
|
88
|
-
cfg,
|
|
89
|
-
);
|
|
90
|
-
expect(out.authorized).toBe(false);
|
|
91
|
-
expect(calls.unauthorized).toBe(1);
|
|
92
|
-
});
|
|
93
|
-
expect(clears).toEqual([]);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test("two gates in one request each report their own marker, drained once", async () => {
|
|
97
|
-
const first = makeConfig("gate_a_entered");
|
|
98
|
-
const second = makeConfig("gate_b_entered");
|
|
99
|
-
const { clears } = await withGateEntryClears(async () => {
|
|
100
|
-
await runContentGate({}, "quiz_token=good; gate_a_entered=1", Date.now(), first.cfg);
|
|
101
|
-
await runContentGate({}, "quiz_token=good; gate_b_entered=1", Date.now(), second.cfg);
|
|
102
|
-
});
|
|
103
|
-
expect(clears).toEqual(["gate_a_entered", "gate_b_entered"]);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test("the quiz gate and its access endpoint both opt in on the same marker name", async () => {
|
|
107
|
-
const { quizAccessConfig, quizGateConfig } = await import("../quiz-gate.ts");
|
|
108
|
-
expect(quizGateConfig.entryCookieName).toBe("quiz_token_entered");
|
|
109
|
-
expect(quizAccessConfig.entryCookieName).toBe("quiz_token_entered");
|
|
110
|
-
});
|
|
111
|
-
});
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Segmentation carriage in the content-gate token. The quiz's composed gate
|
|
7
|
-
* screen captures company/linkedin_url/website/team_size alongside the
|
|
8
|
-
* identity; the token carries them to the next render so the submit can put
|
|
9
|
-
* all seven fields on the ONE response row with no client storage. The
|
|
10
|
-
* generated `token.test.ts` owns the identity/binding suite; these are the
|
|
11
|
-
* segmentation-specific cases (including the backward-compatibility contract:
|
|
12
|
-
* a token minted before `s` existed verifies identically).
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
process.env.AUTH_SECRET = "local-test-fixture-entropy-0001";
|
|
16
|
-
|
|
17
|
-
import { describe, expect, test } from "bun:test";
|
|
18
|
-
import { signAccessToken, verifyAccessTokenIdentity } from "./token.ts";
|
|
19
|
-
|
|
20
|
-
const NOW = 1_000_000_000_000;
|
|
21
|
-
const TTL = 60_000;
|
|
22
|
-
|
|
23
|
-
function decodePayload(token: string): Record<string, unknown> {
|
|
24
|
-
return JSON.parse(Buffer.from(token.split(".")[0], "base64url").toString());
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
describe("content-gate token — segmentation carriage", () => {
|
|
28
|
-
test("round-trips the four captured fields", () => {
|
|
29
|
-
const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, "Ann", "+614", {
|
|
30
|
-
company: "Acme",
|
|
31
|
-
linkedinUrl: "https://www.linkedin.com/in/ann",
|
|
32
|
-
website: "https://acme.test",
|
|
33
|
-
teamSize: "Me + team of 2–5",
|
|
34
|
-
});
|
|
35
|
-
const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
|
|
36
|
-
expect(id?.email).toBe("a@b.com");
|
|
37
|
-
expect(id?.name).toBe("Ann");
|
|
38
|
-
expect(id?.phone).toBe("+614");
|
|
39
|
-
expect(id?.segmentation).toEqual({
|
|
40
|
-
company: "Acme",
|
|
41
|
-
linkedinUrl: "https://www.linkedin.com/in/ann",
|
|
42
|
-
website: "https://acme.test",
|
|
43
|
-
teamSize: "Me + team of 2–5",
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("BACKWARD COMPATIBILITY: a pre-widening token (no s key) verifies with segmentation undefined — never an error", () => {
|
|
48
|
-
// Mint with the old shape by omitting segmentation entirely, then assert
|
|
49
|
-
// the exact contract a deploy-window survivor must satisfy.
|
|
50
|
-
const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, "Ann", "+614");
|
|
51
|
-
expect(decodePayload(t as string).s).toBeUndefined();
|
|
52
|
-
const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
|
|
53
|
-
expect(id).toEqual({ email: "a@b.com", name: "Ann", phone: "+614", segmentation: undefined });
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("empty/whitespace segmentation values produce no s key at all", () => {
|
|
57
|
-
const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, undefined, undefined, {
|
|
58
|
-
company: " ",
|
|
59
|
-
linkedinUrl: "",
|
|
60
|
-
website: undefined,
|
|
61
|
-
teamSize: undefined,
|
|
62
|
-
});
|
|
63
|
-
expect(decodePayload(t as string).s).toBeUndefined();
|
|
64
|
-
const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
|
|
65
|
-
expect(id?.segmentation).toBeUndefined();
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test("values are trimmed on sign", () => {
|
|
69
|
-
const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, undefined, undefined, {
|
|
70
|
-
company: " Acme ",
|
|
71
|
-
teamSize: "Just me",
|
|
72
|
-
});
|
|
73
|
-
const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
|
|
74
|
-
expect(id?.segmentation).toEqual({ company: "Acme", teamSize: "Just me" });
|
|
75
|
-
});
|
|
76
|
-
});
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
// @system codegen
|
|
2
|
-
// @status generated
|
|
3
|
-
// @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
|
|
4
|
-
//
|
|
5
|
-
// This suite's assertions are OWNED by the codegen band: the band module
|
|
6
|
-
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
|
-
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
|
-
// with it into the band.
|
|
9
|
-
|
|
10
|
-
process.env.AUTH_SECRET = "test-secret-at-least-16-chars-long";
|
|
11
|
-
|
|
12
|
-
import { describe, expect, test } from "bun:test";
|
|
13
|
-
import {
|
|
14
|
-
signAccessToken,
|
|
15
|
-
verifyAccessToken,
|
|
16
|
-
verifyAccessTokenIdentity,
|
|
17
|
-
} from "./token.ts";
|
|
18
|
-
|
|
19
|
-
const NOW = 1_000_000_000_000;
|
|
20
|
-
const TTL = 60_000;
|
|
21
|
-
|
|
22
|
-
describe("content-gate token — identity", () => {
|
|
23
|
-
test("signs + verifies email AND name (GPT capture)", () => {
|
|
24
|
-
const t = signAccessToken("gpt_token", "gpt-1", "A@B.com", NOW, TTL, "Ann");
|
|
25
|
-
expect(t).toBeTruthy();
|
|
26
|
-
const id = verifyAccessTokenIdentity(t, "gpt_token", "gpt-1", NOW + 1);
|
|
27
|
-
expect(id).toEqual({ email: "a@b.com", name: "Ann" });
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("name omitted → identity has email, name undefined", () => {
|
|
31
|
-
const t = signAccessToken("gpt_token", "gpt-1", "a@b.com", NOW, TTL);
|
|
32
|
-
const id = verifyAccessTokenIdentity(t, "gpt_token", "gpt-1", NOW + 1);
|
|
33
|
-
expect(id).toEqual({ email: "a@b.com", name: undefined });
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("email-only verifyAccessToken still works (proposals/agreements)", () => {
|
|
37
|
-
const t = signAccessToken("prop_token", "p-1", "a@b.com", NOW, TTL);
|
|
38
|
-
expect(verifyAccessToken(t, "prop_token", "p-1", NOW + 1)).toBe("a@b.com");
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe("content-gate token — binding (withholds content)", () => {
|
|
43
|
-
const t = signAccessToken("gpt_token", "gpt-1", "a@b.com", NOW, TTL, "Ann");
|
|
44
|
-
|
|
45
|
-
test("wrong gate (cookieName) → null", () => {
|
|
46
|
-
expect(verifyAccessTokenIdentity(t, "prop_token", "gpt-1", NOW + 1)).toBeNull();
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("wrong resource → null", () => {
|
|
50
|
-
expect(verifyAccessTokenIdentity(t, "gpt_token", "gpt-2", NOW + 1)).toBeNull();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("expired → null", () => {
|
|
54
|
-
expect(verifyAccessTokenIdentity(t, "gpt_token", "gpt-1", NOW + TTL + 1)).toBeNull();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("tampered signature → null", () => {
|
|
58
|
-
const bad = `${t?.split(".")[0]}.deadbeef`;
|
|
59
|
-
expect(verifyAccessTokenIdentity(bad, "gpt_token", "gpt-1", NOW + 1)).toBeNull();
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("missing token → null", () => {
|
|
63
|
-
expect(verifyAccessTokenIdentity(undefined, "gpt_token", "gpt-1", NOW)).toBeNull();
|
|
64
|
-
});
|
|
65
|
-
});
|