@teamscala/service-boot 0.23.524 → 0.23.525
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 -9
- 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 +289 -0
- 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/graph-cache.ts +0 -17
- 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 +0 -57
- 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 -75
- 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/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
package/src/mount-orpc-router.ts
CHANGED
|
@@ -26,22 +26,12 @@ export async function mountOrpcRouter(
|
|
|
26
26
|
serviceSlug: string,
|
|
27
27
|
): Promise<OrpcMountResult | null> {
|
|
28
28
|
const log = getAppLogger();
|
|
29
|
-
// registerFnRouters() is retired: the row-composed bundle (orpc-bundle from
|
|
30
|
-
// api_route rows) is the sole fn source. The handwritten registration is
|
|
31
|
-
// fully shadowed by generated.fn (all families have rows) — keeping it
|
|
32
|
-
// would block generated-only procedures (auth, cacheAdmin, regions,
|
|
33
|
-
// realtime) from serving.
|
|
34
29
|
const routerPath = join(
|
|
35
30
|
process.cwd(),
|
|
36
31
|
"prisma/generated/orpc/routers/index.ts",
|
|
37
32
|
);
|
|
38
33
|
const schemaPath = join(process.cwd(), "prisma/schema.prisma");
|
|
39
34
|
|
|
40
|
-
// ORPC forward mode: when SCALA_ORPC_FORWARD_URL is set and there is no
|
|
41
|
-
// local bundle, /api/rpc/* proxies to the central ORPC surface instead of
|
|
42
|
-
// mounting a per-service router. This is the collapse's consolidation path —
|
|
43
|
-
// services shed their local bundle and the central surface serves all
|
|
44
|
-
// row-composed procedures.
|
|
45
35
|
const forwardUrl = process.env.SCALA_ORPC_FORWARD_URL;
|
|
46
36
|
if (!await Bun.file(routerPath).exists() && forwardUrl) {
|
|
47
37
|
const base = forwardUrl.replace(/\/+$/, "");
|
|
@@ -52,7 +42,7 @@ export async function mountOrpcRouter(
|
|
|
52
42
|
method: c.req.method,
|
|
53
43
|
headers: c.req.raw.headers,
|
|
54
44
|
body: c.req.raw.body,
|
|
55
|
-
// @ts-ignore
|
|
45
|
+
// @ts-ignore duplex is required for streaming bodies in Bun
|
|
56
46
|
duplex: "half",
|
|
57
47
|
});
|
|
58
48
|
return new Response(res.body, {
|
|
@@ -67,14 +57,6 @@ export async function mountOrpcRouter(
|
|
|
67
57
|
return { routeCount: 0 };
|
|
68
58
|
}
|
|
69
59
|
|
|
70
|
-
// No generated ORPC router is the EXPECTED state for daemon-only / boot-hook
|
|
71
|
-
// services (session-picker, dep-sync, etc.) that own no DB schema or
|
|
72
|
-
// api_routes — they serve no /api/rpc surface. Skip silently: importing a
|
|
73
|
-
// nonexistent module would throw into the catch below and surface a
|
|
74
|
-
// misleading "ORPC router not mounted" WARN on every boot. The catch is
|
|
75
|
-
// reserved for GENUINE mount failures (router present but fails to load).
|
|
76
|
-
// A service that SHOULD have a router but doesn't is a codegen gap caught by
|
|
77
|
-
// API verification, not a boot warning.
|
|
78
60
|
if (!await Bun.file(routerPath).exists()) {
|
|
79
61
|
if (await Bun.file(schemaPath).exists()) {
|
|
80
62
|
throw new Error(
|
|
@@ -91,12 +73,6 @@ export async function mountOrpcRouter(
|
|
|
91
73
|
const router = await getAppRouter();
|
|
92
74
|
if (!router || Object.keys(router).length === 0) return null;
|
|
93
75
|
|
|
94
|
-
// The mount error hook: every procedure error crossing this boundary is
|
|
95
|
-
// shaped by buildErrorReport (errorId, class, stack head) and recorded
|
|
96
|
-
// durably via event-log before ORPC swallows it into a stackless 500.
|
|
97
|
-
// The recorder is wired HERE because orpc is primitives tier and cannot
|
|
98
|
-
// import @teamscala/event-log across a same-tier edge — the boot layer
|
|
99
|
-
// owns that dependency.
|
|
100
76
|
const { procedureErrorReportInterceptor } = await import(
|
|
101
77
|
"@teamscala/orpc/procedure-error-report"
|
|
102
78
|
);
|
|
@@ -114,24 +90,12 @@ export async function mountOrpcRouter(
|
|
|
114
90
|
interceptors: [procedureErrorReportInterceptor()] as never,
|
|
115
91
|
});
|
|
116
92
|
|
|
117
|
-
// The bootloader injects the service Prisma client into ORPC's configured
|
|
118
|
-
// primitive. Reading it through that surface keeps this mount on the same
|
|
119
|
-
// dependency boundary as the generated procedures.
|
|
120
93
|
const { getPrisma: getConfiguredPrisma } = await import(
|
|
121
94
|
"@teamscala/orpc/configure"
|
|
122
95
|
);
|
|
123
96
|
|
|
124
|
-
// Coexistence (Prisma 8 migration): create the v8 chained client when
|
|
125
|
-
// the shared platform contract ships inside @teamscala/db. Absence is
|
|
126
|
-
// the EXPECTED state for services that have not migrated — db8 stays
|
|
127
|
-
// undefined and v7 procedures are untouched. One boot-time creation,
|
|
128
|
-
// closed over by the request handler (connects lazily on first query).
|
|
129
97
|
let db8: Db8Client | undefined;
|
|
130
98
|
try {
|
|
131
|
-
// Named-need address (wrong-database guard, 2026-09-27): the tenant
|
|
132
|
-
// plane by name through @teamscala/db's resolver, never an env key
|
|
133
|
-
// read at this call site. Null = the deploy wrote nothing for this
|
|
134
|
-
// plane, and db8 stays undefined on the existing absence path.
|
|
135
99
|
const databaseUrl = resolveDbUrlOrNull("tenant");
|
|
136
100
|
if (databaseUrl) {
|
|
137
101
|
const { createDbClientV8 } = await import(
|
|
@@ -141,9 +105,6 @@ export async function mountOrpcRouter(
|
|
|
141
105
|
connectionString: databaseUrl,
|
|
142
106
|
serviceName: serviceSlug,
|
|
143
107
|
})) as never;
|
|
144
|
-
// Register for db-core internals (registry loaders, raw fallbacks)
|
|
145
|
-
// routing v8-first via getDb8() — the same coexistence shape as
|
|
146
|
-
// the RPC context injection above.
|
|
147
108
|
const { registerDb8 } = await import("@teamscala/db/db8-registry");
|
|
148
109
|
registerDb8(db8 as never);
|
|
149
110
|
}
|
|
@@ -154,26 +115,9 @@ export async function mountOrpcRouter(
|
|
|
154
115
|
db8 = undefined;
|
|
155
116
|
}
|
|
156
117
|
|
|
157
|
-
// Bridge the composed router into tool-executor so in-process tool
|
|
158
|
-
// dispatch (chat agents' work_items/meetings/agreements/proposals
|
|
159
|
-
// tools, etc.) resolves against this service's ORPC router + Prisma
|
|
160
|
-
// client. Without this every ORPC tool call throws
|
|
161
|
-
// "app router provider not configured". Parallel to dev-mcp-boot's
|
|
162
|
-
// configureToolExecutor. Calls getAppRouter() fresh each time (not a
|
|
163
|
-
// boot-time capture) so the tool-executor always resolves the CURRENT
|
|
164
|
-
// memoized router — the tool-generator listener may regenerate the
|
|
165
|
-
// ORPC router and call clearAppRouterCache() after boot, producing a
|
|
166
|
-
// new router object that a captured closure would miss.
|
|
167
118
|
const { configure: configureToolExecutor } = await import(
|
|
168
119
|
"@teamscala/tool-executor/configure"
|
|
169
120
|
);
|
|
170
|
-
// The caller-identity and secret collaborators arrive here (the boot
|
|
171
|
-
// layer) — tool-executor cannot import its tier-2 siblings statically
|
|
172
|
-
// (vertical-dependency-only: a same-tier edge). Resolved eagerly at
|
|
173
|
-
// mount so the injected factories stay synchronous. The retired
|
|
174
|
-
// @teamscala/host-command-bus invoker injection no longer happens: the
|
|
175
|
-
// bus is retired, and host_command tools execute through the native
|
|
176
|
-
// in-process coding path inside tool-executor.
|
|
177
121
|
const [{ getCallerContext }, { decryptSecretConfig }] =
|
|
178
122
|
await Promise.all([
|
|
179
123
|
import("@teamscala/mcp-multi-session/caller-context"),
|
|
@@ -186,19 +130,12 @@ export async function mountOrpcRouter(
|
|
|
186
130
|
decryptSecretConfig: (raw) => decryptSecretConfig(raw as never),
|
|
187
131
|
});
|
|
188
132
|
|
|
189
|
-
// The fn capability primitives (foundation tier) cannot import the email
|
|
190
|
-
// client, so the boot layer injects the real sender — the same shape the
|
|
191
|
-
// bootloader already uses for getPrisma. Until configured, fn.workItems
|
|
192
|
-
// notification procedures degrade to { notified: 0 } with an error log
|
|
193
|
-
// (never throw).
|
|
194
133
|
try {
|
|
195
134
|
const [{ configureWorkItems }, { createEmailClient }] = await Promise.all([
|
|
196
135
|
import("@teamscala/db/work-items"),
|
|
197
136
|
import("@teamscala/email-client/client"),
|
|
198
137
|
]);
|
|
199
138
|
configureWorkItems({
|
|
200
|
-
// The injected contract is the template-send subset; the real
|
|
201
|
-
// client's send agrees at every call site ({to, template, vars}).
|
|
202
139
|
emailSender: createEmailClient() as never,
|
|
203
140
|
logger: log,
|
|
204
141
|
});
|
|
@@ -226,17 +163,7 @@ export async function mountOrpcRouter(
|
|
|
226
163
|
id: session.user.id,
|
|
227
164
|
name: session.user.name ?? undefined,
|
|
228
165
|
email: session.user.email,
|
|
229
|
-
// The active organisation lives on the SESSION
|
|
230
|
-
// (activeOrganizationId, set at sign-in by scala-otp's
|
|
231
|
-
// session.create hook), NOT on the user object — getSession's
|
|
232
|
-
// ResolvedSession.user carries no organisationId. Reading the
|
|
233
|
-
// user field left organisationId always null, so the generated
|
|
234
|
-
// router's org-scope gate 403'd every non-super-admin write
|
|
235
|
-
// ("Organization membership required") on the public-HTTP path.
|
|
236
166
|
organisationId: session.session.activeOrganizationId ?? null,
|
|
237
|
-
// better-auth's admin plugin issues role "admin"; the platform
|
|
238
|
-
// also uses "super-admin"/"super_admin". All three are platform
|
|
239
|
-
// super-admins for ORPC org-scope bypass.
|
|
240
167
|
isSuperAdmin:
|
|
241
168
|
role === "super_admin" ||
|
|
242
169
|
role === "super-admin" ||
|
|
@@ -260,10 +187,6 @@ export async function mountOrpcRouter(
|
|
|
260
187
|
context: { prisma, db8, headers: c.req.raw.headers, resolveUser },
|
|
261
188
|
});
|
|
262
189
|
if (!result?.matched) return c.notFound();
|
|
263
|
-
// Response-side signal only (a fleet-wide 500 spike with no
|
|
264
|
-
// traceback — measured 2026-09-04). The real error, its class and
|
|
265
|
-
// its stack are captured by the mount error hook above; this line
|
|
266
|
-
// stays for failures that reach the boundary without a throw.
|
|
267
190
|
if (result.response.status >= 500) {
|
|
268
191
|
log.error(
|
|
269
192
|
`[ORPC] ${c.req.method} ${c.req.path} → ${result.response.status}`,
|
|
@@ -273,16 +196,6 @@ export async function mountOrpcRouter(
|
|
|
273
196
|
},
|
|
274
197
|
);
|
|
275
198
|
|
|
276
|
-
// THE OpenAPI CONTRACT, served BESIDE the RPC — a GET on the same prefix,
|
|
277
|
-
// generated from the router object mounted below, so the document and the
|
|
278
|
-
// handler cannot describe different surfaces. Order is free: the methods
|
|
279
|
-
// never overlap (`app.post` matches POST alone).
|
|
280
|
-
//
|
|
281
|
-
// Gated by openApiContractAuthorised, which refuses a proxied request as
|
|
282
|
-
// well as a wrong key (guard auth-helpers-fail-closed), so only an
|
|
283
|
-
// internal caller — the MCP gateway — reaches this. Its own facts come
|
|
284
|
-
// from openApiContractOptions; both units carry the reasoning, and
|
|
285
|
-
// contract rows state their behaviour.
|
|
286
199
|
app.get("/api/rpc/openapi.json", async (c) => {
|
|
287
200
|
const header = (name: string) => c.req.header(name) ?? null;
|
|
288
201
|
if (
|
|
@@ -89,9 +89,6 @@ export async function mountServerPackages(
|
|
|
89
89
|
lastNotFound = err;
|
|
90
90
|
continue;
|
|
91
91
|
}
|
|
92
|
-
// Real error (syntax, runtime, transitive resolution failure inside the
|
|
93
|
-
// loaded module) — surface it directly. Swallowing here is what hides
|
|
94
|
-
// the true cause behind a misleading "cannot find <pkg>" further down.
|
|
95
92
|
throw new BootFatalError(
|
|
96
93
|
"mount-server-packages",
|
|
97
94
|
serviceSlug,
|
|
@@ -122,30 +119,6 @@ export async function mountServerPackages(
|
|
|
122
119
|
}
|
|
123
120
|
} catch (err) {
|
|
124
121
|
if (err instanceof BootFatalError) throw err;
|
|
125
|
-
// A PLUGIN THAT FAILS TO MOUNT IS UNAVAILABLE, NOT FATAL. `crash-isolation`
|
|
126
|
-
// states the law directly — "a failure in one package does not crash the
|
|
127
|
-
// host" — and names the plugin failure domain: "a broken plugin degrades
|
|
128
|
-
// to 'that plugin unavailable'". This arm used to wrap ANY mount error in
|
|
129
|
-
// a BootFatalError, so one plugin's missing config killed a whole service.
|
|
130
|
-
//
|
|
131
|
-
// MEASURED 2026-09-07: scala-agents-ui, the operator's fleet monitoring UI,
|
|
132
|
-
// booted through db, auth, routes and every other server_package, then died
|
|
133
|
-
// at @teamscala/opencode because OPENCODE_SERVER_URL was absent. systemd
|
|
134
|
-
// retried five times and gave up, agents.scala.business served 502, and the
|
|
135
|
-
// operator lost sight of his fleet — over one optional integration missing
|
|
136
|
-
// one variable, on a page that needs no opencode connection to render.
|
|
137
|
-
//
|
|
138
|
-
// THE ASYMMETRY DECIDES IT. A service that boots without one plugin still
|
|
139
|
-
// serves every route that plugin did not own, and the gap is loud in this
|
|
140
|
-
// log line and counted in the summary below. A service that refuses to boot
|
|
141
|
-
// serves NOTHING — including the surface that would have explained why.
|
|
142
|
-
//
|
|
143
|
-
// NOT-FOUND STAYS FATAL, deliberately: that arm is above and untouched. A
|
|
144
|
-
// package that LOADS and then throws is a broken plugin, which is this
|
|
145
|
-
// case. A package that cannot be resolved at all means the declared
|
|
146
|
-
// dependency set was never installed — an install-integrity failure, where
|
|
147
|
-
// booting a half-installed service would serve wrong results while looking
|
|
148
|
-
// healthy.
|
|
149
122
|
degraded.push(pkg);
|
|
150
123
|
log.error(
|
|
151
124
|
`[scala-install:${serviceSlug}] server_package "${pkg}" UNAVAILABLE — mount failed; ` +
|
|
@@ -158,8 +131,6 @@ export async function mountServerPackages(
|
|
|
158
131
|
`[scala-install:${serviceSlug}] Registered ${mountRegistry.handlers.size} handler(s), ` +
|
|
159
132
|
`${mountRegistry.wsRoutes.size} ws route(s), ` +
|
|
160
133
|
`${mountRegistry.bootHooks.size} boot hook(s)` +
|
|
161
|
-
// NAMED, NEVER COUNTED. A bare "1 degraded" sends the reader hunting for
|
|
162
|
-
// which one; the name is the whole value of the line.
|
|
163
134
|
(degraded.length > 0
|
|
164
135
|
? `, ${degraded.length} server_package(s) UNAVAILABLE: ${degraded.join(", ")}`
|
|
165
136
|
: ""),
|
package/src/mutable-app-ref.ts
CHANGED
|
@@ -7,18 +7,11 @@ import type { Hono } from "hono";
|
|
|
7
7
|
let currentApp: Hono | null = null;
|
|
8
8
|
let appLabel = "initial";
|
|
9
9
|
|
|
10
|
-
/**
|
|
11
|
-
* Create a new mutable app reference initialized with the provided app.
|
|
12
|
-
* This should be called once at boot time.
|
|
13
|
-
*/
|
|
14
10
|
export function initMutableAppRef(initialApp: Hono, label: string): void {
|
|
15
11
|
currentApp = initialApp;
|
|
16
12
|
appLabel = label;
|
|
17
13
|
}
|
|
18
14
|
|
|
19
|
-
/**
|
|
20
|
-
* Get the current app. Throws if the ref hasn't been initialized.
|
|
21
|
-
*/
|
|
22
15
|
export function getApp(): Hono {
|
|
23
16
|
if (!currentApp) {
|
|
24
17
|
throw new Error("Mutable app reference not initialized");
|
|
@@ -26,10 +19,6 @@ export function getApp(): Hono {
|
|
|
26
19
|
return currentApp;
|
|
27
20
|
}
|
|
28
21
|
|
|
29
|
-
/**
|
|
30
|
-
* Atomically swap the app to a new instance. This is called after the
|
|
31
|
-
* full app is built in the background. Returns the previous label for logging.
|
|
32
|
-
*/
|
|
33
22
|
export function swapApp(newApp: Hono, newLabel: string): string {
|
|
34
23
|
const oldLabel = appLabel;
|
|
35
24
|
currentApp = newApp;
|
|
@@ -120,6 +120,295 @@
|
|
|
120
120
|
"package": "@teamscala/service-boot",
|
|
121
121
|
"suite_type": "function_call",
|
|
122
122
|
"slug": "boot:openapi-contract-refuses-when-no-key-is-configured"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"args": [
|
|
126
|
+
{},
|
|
127
|
+
"quiz_token=good; quiz_token_entered=1",
|
|
128
|
+
1730000000000,
|
|
129
|
+
{
|
|
130
|
+
"cookieName": "quiz_token",
|
|
131
|
+
"verifyToken": {
|
|
132
|
+
"$fn": {
|
|
133
|
+
"returns": {
|
|
134
|
+
"name": "V",
|
|
135
|
+
"email": "v@example.com"
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
"onAuthorized": {
|
|
140
|
+
"$fn": {
|
|
141
|
+
"returns": null
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
"getResourceId": {
|
|
145
|
+
"$fn": {
|
|
146
|
+
"returns": "res-1"
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
"onUnauthorized": {
|
|
150
|
+
"$fn": {
|
|
151
|
+
"returns": null
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
"entryCookieName": "quiz_token_entered"
|
|
155
|
+
}
|
|
156
|
+
],
|
|
157
|
+
"note": "Converts gate-entry-marker.test.ts case 3, including the measured first-shipped-version failure (the clears read after the run() scope saw nothing and left the marker in the browser, measured live 2026-09-14): recording is asserted from the recorded call itself, which a post-scope read cannot fake.",
|
|
158
|
+
"label": "an opting gate with token AND marker authorizes and RECORDS the marker for clearing — one view, then the gate returns",
|
|
159
|
+
"expect": {
|
|
160
|
+
"email": "v@example.com",
|
|
161
|
+
"applies": true,
|
|
162
|
+
"authorized": true
|
|
163
|
+
},
|
|
164
|
+
"export": "runContentGate",
|
|
165
|
+
"module": "src/content-gate/gate.ts",
|
|
166
|
+
"package": "@teamscala/service-boot",
|
|
167
|
+
"fixtures": {
|
|
168
|
+
"stubs": [
|
|
169
|
+
{
|
|
170
|
+
"export": "recordGateEntryClear",
|
|
171
|
+
"module": "./gate-entry-clears.ts",
|
|
172
|
+
"record": "clears",
|
|
173
|
+
"returns": null
|
|
174
|
+
}
|
|
175
|
+
]
|
|
176
|
+
},
|
|
177
|
+
"outcomes": {
|
|
178
|
+
"clears": [
|
|
179
|
+
{
|
|
180
|
+
"count": 1
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"equals": [
|
|
184
|
+
{
|
|
185
|
+
"args": [
|
|
186
|
+
"quiz_token_entered"
|
|
187
|
+
]
|
|
188
|
+
}
|
|
189
|
+
]
|
|
190
|
+
}
|
|
191
|
+
]
|
|
192
|
+
},
|
|
193
|
+
"suite_type": "function_call",
|
|
194
|
+
"slug": "gate-entry-marker:marker-plus-token-authorizes-and-records-clear"
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
"args": [
|
|
198
|
+
{},
|
|
199
|
+
"quiz_token=bad; quiz_token_entered=1",
|
|
200
|
+
1730000000000,
|
|
201
|
+
{
|
|
202
|
+
"cookieName": "quiz_token",
|
|
203
|
+
"verifyToken": {
|
|
204
|
+
"$fn": {
|
|
205
|
+
"returns": null
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
"onAuthorized": {
|
|
209
|
+
"$fn": {
|
|
210
|
+
"returns": null
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
"getResourceId": {
|
|
214
|
+
"$fn": {
|
|
215
|
+
"returns": "res-1"
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
"onUnauthorized": {
|
|
219
|
+
"$fn": {
|
|
220
|
+
"returns": null
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
"entryCookieName": "quiz_token_entered"
|
|
224
|
+
}
|
|
225
|
+
],
|
|
226
|
+
"note": "Converts gate-entry-marker.test.ts case 4.",
|
|
227
|
+
"label": "marker present but token invalid stays unauthorized and records no clear",
|
|
228
|
+
"expect": {
|
|
229
|
+
"applies": true,
|
|
230
|
+
"authorized": false
|
|
231
|
+
},
|
|
232
|
+
"export": "runContentGate",
|
|
233
|
+
"module": "src/content-gate/gate.ts",
|
|
234
|
+
"package": "@teamscala/service-boot",
|
|
235
|
+
"fixtures": {
|
|
236
|
+
"stubs": [
|
|
237
|
+
{
|
|
238
|
+
"export": "recordGateEntryClear",
|
|
239
|
+
"module": "./gate-entry-clears.ts",
|
|
240
|
+
"record": "clears",
|
|
241
|
+
"returns": null
|
|
242
|
+
}
|
|
243
|
+
]
|
|
244
|
+
},
|
|
245
|
+
"outcomes": {
|
|
246
|
+
"clears": [
|
|
247
|
+
{
|
|
248
|
+
"count": 0
|
|
249
|
+
}
|
|
250
|
+
]
|
|
251
|
+
},
|
|
252
|
+
"suite_type": "function_call",
|
|
253
|
+
"slug": "gate-entry-marker:marker-without-valid-token-records-no-clear"
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"args": [
|
|
257
|
+
{},
|
|
258
|
+
"quiz_token=good",
|
|
259
|
+
1730000000000,
|
|
260
|
+
{
|
|
261
|
+
"cookieName": "quiz_token",
|
|
262
|
+
"verifyToken": {
|
|
263
|
+
"$fn": {
|
|
264
|
+
"returns": {
|
|
265
|
+
"name": "V",
|
|
266
|
+
"email": "v@example.com"
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
"onAuthorized": {
|
|
271
|
+
"$fn": {
|
|
272
|
+
"returns": null
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
"getResourceId": {
|
|
276
|
+
"$fn": {
|
|
277
|
+
"returns": "res-1"
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
"onUnauthorized": {
|
|
281
|
+
"$fn": {
|
|
282
|
+
"returns": null
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
],
|
|
287
|
+
"note": "Converts the hand-written gate-entry-marker.test.ts case 1: the entry marker is opt-in, so every non-opting service behaves exactly as before. Converted 2026-10-07 by the tests-out conversion.",
|
|
288
|
+
"label": "a gate that does NOT declare an entry marker authorizes on any load and records nothing for clearing",
|
|
289
|
+
"expect": {
|
|
290
|
+
"email": "v@example.com",
|
|
291
|
+
"applies": true,
|
|
292
|
+
"authorized": true
|
|
293
|
+
},
|
|
294
|
+
"export": "runContentGate",
|
|
295
|
+
"module": "src/content-gate/gate.ts",
|
|
296
|
+
"package": "@teamscala/service-boot",
|
|
297
|
+
"fixtures": {
|
|
298
|
+
"stubs": [
|
|
299
|
+
{
|
|
300
|
+
"export": "recordGateEntryClear",
|
|
301
|
+
"module": "./gate-entry-clears.ts",
|
|
302
|
+
"record": "clears",
|
|
303
|
+
"returns": null
|
|
304
|
+
}
|
|
305
|
+
]
|
|
306
|
+
},
|
|
307
|
+
"outcomes": {
|
|
308
|
+
"clears": [
|
|
309
|
+
{
|
|
310
|
+
"count": 0
|
|
311
|
+
}
|
|
312
|
+
]
|
|
313
|
+
},
|
|
314
|
+
"suite_type": "function_call",
|
|
315
|
+
"slug": "gate-entry-marker:non-opting-authorizes-any-load-records-nothing"
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
"args": [
|
|
319
|
+
{},
|
|
320
|
+
"quiz_token=good",
|
|
321
|
+
1730000000000,
|
|
322
|
+
{
|
|
323
|
+
"cookieName": "quiz_token",
|
|
324
|
+
"verifyToken": {
|
|
325
|
+
"$fn": {
|
|
326
|
+
"returns": {
|
|
327
|
+
"name": "V",
|
|
328
|
+
"email": "v@example.com"
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
"onAuthorized": {
|
|
333
|
+
"$fn": {
|
|
334
|
+
"returns": null
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
"getResourceId": {
|
|
338
|
+
"$fn": {
|
|
339
|
+
"returns": "res-1"
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
"onUnauthorized": {
|
|
343
|
+
"$fn": {
|
|
344
|
+
"returns": null
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
"entryCookieName": "quiz_token_entered"
|
|
348
|
+
}
|
|
349
|
+
],
|
|
350
|
+
"note": "Converts gate-entry-marker.test.ts case 2: the marker, not the token alone, is what skips the gate — the mechanism measured live 2026-09-14 when the quiz_token cookie alone let a refresh past the identity gate.",
|
|
351
|
+
"label": "an opting gate authorizes only the request carrying the marker — a token WITHOUT it is unauthorized, so a refresh shows the gate",
|
|
352
|
+
"expect": {
|
|
353
|
+
"applies": true,
|
|
354
|
+
"authorized": false
|
|
355
|
+
},
|
|
356
|
+
"export": "runContentGate",
|
|
357
|
+
"module": "src/content-gate/gate.ts",
|
|
358
|
+
"package": "@teamscala/service-boot",
|
|
359
|
+
"fixtures": {
|
|
360
|
+
"stubs": [
|
|
361
|
+
{
|
|
362
|
+
"export": "recordGateEntryClear",
|
|
363
|
+
"module": "./gate-entry-clears.ts",
|
|
364
|
+
"record": "clears",
|
|
365
|
+
"returns": null
|
|
366
|
+
}
|
|
367
|
+
]
|
|
368
|
+
},
|
|
369
|
+
"outcomes": {
|
|
370
|
+
"clears": [
|
|
371
|
+
{
|
|
372
|
+
"count": 0
|
|
373
|
+
}
|
|
374
|
+
]
|
|
375
|
+
},
|
|
376
|
+
"suite_type": "function_call",
|
|
377
|
+
"slug": "gate-entry-marker:opting-gate-without-marker-is-unauthorized"
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
"args": [],
|
|
381
|
+
"note": "Converts the pairing half of gate-entry-marker.test.ts: gate and access endpoint must declare the SAME marker name, or the endpoint's redirect carries a cookie the gate never reads and the one-view never happens. Converted 2026-10-07.",
|
|
382
|
+
"label": "the quiz access endpoint opts into the SAME entry marker as the gate",
|
|
383
|
+
"expect": {
|
|
384
|
+
"export_shape": {
|
|
385
|
+
"member_equals": {
|
|
386
|
+
"entryCookieName": "quiz_token_entered"
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
"export": "quizAccessConfig",
|
|
391
|
+
"module": "src/quiz-gate.ts",
|
|
392
|
+
"package": "@teamscala/service-boot",
|
|
393
|
+
"suite_type": "function_call",
|
|
394
|
+
"slug": "quiz-gate:access-endpoint-shares-the-entry-marker"
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
"args": [],
|
|
398
|
+
"note": "Converts the pairing half of gate-entry-marker.test.ts: the quiz SSR gate carries the marker name, so a refresh returns to the identity gate (operator ruling 2026-09-14). Converted 2026-10-07.",
|
|
399
|
+
"label": "the quiz gate opts into the one-view entry marker",
|
|
400
|
+
"expect": {
|
|
401
|
+
"export_shape": {
|
|
402
|
+
"member_equals": {
|
|
403
|
+
"entryCookieName": "quiz_token_entered"
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
"export": "quizGateConfig",
|
|
408
|
+
"module": "src/quiz-gate.ts",
|
|
409
|
+
"package": "@teamscala/service-boot",
|
|
410
|
+
"suite_type": "function_call",
|
|
411
|
+
"slug": "quiz-gate:gate-declares-the-one-view-entry-marker"
|
|
123
412
|
}
|
|
124
413
|
]
|
|
125
414
|
}
|
|
@@ -3,12 +3,6 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Both fallbacks are read through FALSYNESS, not `??`: a header that is
|
|
8
|
-
* present but empty named nothing, and `"" ?? "http"` would splice an
|
|
9
|
-
* empty scheme into the URL as `://host` — a document that serves and
|
|
10
|
-
* cannot be called from (caught by the row passing empty positions).
|
|
11
|
-
*/
|
|
12
6
|
import type { OpenApiDocumentOptions } from "@teamscala/orpc/openapi";
|
|
13
7
|
|
|
14
8
|
const FALLBACK_HOST = "localhost";
|
package/src/orpc-openapi-gate.ts
CHANGED
|
@@ -3,23 +3,13 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* The four request facts the gate reads, all optional: a fact the caller
|
|
8
|
-
* did not send is absent, never a value to trust. Truthiness reads absence
|
|
9
|
-
* and null identically, so a header is an assertion, not a hint.
|
|
10
|
-
*/
|
|
11
6
|
export interface OpenApiContractRequest {
|
|
12
|
-
/** The raw `authorization` header, absent when the caller sent none. */
|
|
13
7
|
authorization?: string | null;
|
|
14
|
-
/** The configured `SCALA_DEV_KEY`, absent when the service has none. */
|
|
15
8
|
key?: string | null;
|
|
16
|
-
/** `x-forwarded-proto`. Its presence means the request was proxied. */
|
|
17
9
|
forwardedProto?: string | null;
|
|
18
|
-
/** `x-forwarded-for`. Same meaning, checked so neither alone is load-bearing. */
|
|
19
10
|
forwardedFor?: string | null;
|
|
20
11
|
}
|
|
21
12
|
|
|
22
|
-
/** True only for an unproxied request presenting exactly the configured key. */
|
|
23
13
|
export function openApiContractAuthorised(request: OpenApiContractRequest): boolean {
|
|
24
14
|
if (!request.key) return false;
|
|
25
15
|
if (request.forwardedProto || request.forwardedFor) return false;
|
|
@@ -7,21 +7,9 @@ import { createRegistry } from "@teamscala/generic-registry/create-registry";
|
|
|
7
7
|
|
|
8
8
|
export type PgBossJobHandler = (ctx: {
|
|
9
9
|
config: ScheduledJobConfig;
|
|
10
|
-
/** Per-job data payload from a one-shot `boss.send()` — absent for scheduled jobs. */
|
|
11
10
|
jobData?: unknown;
|
|
12
11
|
}) => Promise<unknown> | unknown;
|
|
13
12
|
|
|
14
13
|
export const pgBossHandlerRegistry = createRegistry<PgBossJobHandler>(
|
|
15
14
|
"pg-boss-handlers",
|
|
16
15
|
);
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Register a handler for a `scheduled_job.handler_slug`. Idempotent on re-import
|
|
20
|
-
* (createRegistry.register overwrites silently — last registration wins).
|
|
21
|
-
*/
|
|
22
|
-
export function registerScheduledJobHandler(
|
|
23
|
-
slug: string,
|
|
24
|
-
handler: PgBossJobHandler,
|
|
25
|
-
): void {
|
|
26
|
-
pgBossHandlerRegistry.register(slug, handler);
|
|
27
|
-
}
|