@teamscala/service-boot 0.23.523 → 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/agreement-lookup.ts +0 -7
- package/src/agreement-gate/authorized-emails.ts +0 -14
- package/src/agreement-gate/placeholder-strip.ts +0 -10
- package/src/agreement-gate/resolution-caches.ts +0 -5
- package/src/agreement-gate/settings.ts +0 -22
- package/src/agreement-gate/signing-session.ts +0 -8
- package/src/agreement-gate/signing-shell.ts +0 -9
- package/src/agreement-gate.ts +0 -52
- package/src/apply-logger-config.ts +1 -9
- package/src/asset-cache-key.ts +1 -5
- package/src/asset-optimize.ts +1 -7
- package/src/asset-proxy.ts +1 -10
- package/src/asset-social-card.ts +1 -9
- package/src/attach-agreement-magic.ts +0 -14
- package/src/attach-portal-members.ts +0 -17
- package/src/attach-proposal-accept.ts +1 -21
- package/src/attach-proposal-magic.ts +1 -13
- package/src/attach-transfers-upload.ts +0 -21
- package/src/auth/sign-out.ts +1 -44
- package/src/auth-gate/public-paths.ts +1 -96
- package/src/auth-gate.ts +1 -41
- package/src/bind-page-handler.ts +1 -83
- package/src/build-client-assets/build-lock.ts +1 -11
- package/src/build-client-assets/hashed-output.ts +1 -11
- package/src/build-client-assets/hydrate-bundle.ts +1 -137
- package/src/build-client-assets/manifest.ts +1 -30
- package/src/build-client-assets/source-candidates.ts +0 -51
- package/src/build-client-assets/sourcemaps-upload.ts +1 -52
- package/src/build-client-assets/styles-build.ts +1 -71
- package/src/build-client-assets.ts +1 -30
- package/src/capability-slug.ts +1 -45
- package/src/compose-page-title.ts +1 -34
- package/src/content-gate/access.ts +0 -34
- package/src/content-gate/catalog.ts +0 -52
- package/src/content-gate/config-types.ts +3 -90
- package/src/content-gate/gate-entry-clears.ts +0 -18
- package/src/content-gate/gate.ts +0 -27
- package/src/content-gate/parse-cookie.ts +0 -4
- package/src/content-page-floor.ts +0 -28
- package/src/csrf-protection.ts +1 -28
- package/src/error-page-logo.ts +2 -35
- package/src/expand-roster.ts +1 -25
- package/src/gate-ttls.ts +0 -12
- package/src/gpt-gate.ts +1 -19
- package/src/hono-error-handler.ts +0 -19
- package/src/install-harness.ts +1 -12
- package/src/install-logging.ts +1 -47
- package/src/load-page-manifest.ts +1 -29
- package/src/load-service-install-row.ts +42 -10
- package/src/module-not-found.ts +0 -17
- package/src/mount-orpc-router.ts +1 -94
- package/src/mount-server-packages.ts +1 -37
- package/src/mount.ts +1 -4
- package/src/mutable-app-ref.ts +0 -21
- package/src/orpc-contract-rows.json +289 -0
- package/src/orpc-openapi-document.ts +1 -19
- package/src/orpc-openapi-gate.ts +1 -23
- package/src/page-route-shell-types.ts +1 -8
- package/src/page-server-loader-graph.ts +0 -10
- package/src/page-server-orpc/server-client.ts +1 -23
- package/src/pg-boss-handler-registry.ts +1 -24
- package/src/pg-boss-mount.ts +1 -109
- package/src/proposal-authorized-emails.ts +0 -32
- package/src/proposal-gate.ts +1 -52
- package/src/quiz-gate.ts +1 -31
- package/src/read-html-template.ts +1 -49
- package/src/readiness-gate.ts +0 -8
- package/src/register-api-routes.ts +1 -11
- package/src/register-crash-handlers.ts +1 -6
- package/src/register-page-routes.ts +1 -12
- package/src/register-page-server-client.ts +1 -66
- package/src/register-registry-page-route/ai-chat-prefetch.ts +1 -24
- package/src/register-registry-page-route/apply-page-branding.ts +1 -100
- package/src/register-registry-page-route/captured-loader-config.ts +1 -12
- package/src/register-registry-page-route/graph-cache.ts +1 -38
- package/src/register-registry-page-route/live-loader-config.ts +1 -22
- package/src/register-registry-page-route/meta-title.ts +1 -36
- package/src/register-registry-page-route/page-data-shared.ts +1 -18
- package/src/register-registry-page-route/prefetch-org-id.ts +1 -24
- package/src/register-registry-page-route/proposal-approval-email-token.ts +1 -22
- package/src/register-registry-page-route/proposal-brand-stamping.ts +1 -31
- package/src/register-registry-page-route/public-shell-page-data.ts +1 -34
- package/src/register-registry-page-route/region-slots-page-data.ts +1 -8
- package/src/register-registry-page-route/resolve-page-data.ts +1 -10
- package/src/register-registry-page-route/resolve-panel-page.ts +1 -10
- package/src/register-registry-page-route/resolve-request-graph.ts +1 -68
- package/src/register-registry-page-route/run-content-gates.ts +3 -64
- package/src/register-registry-page-route/single-page-data.ts +1 -9
- package/src/register-registry-page-route/stamp-session-context.ts +1 -10
- package/src/register-registry-page-route/warn-uncovered-blocks.ts +1 -18
- package/src/register-registry-page-route.ts +1 -93
- package/src/register-static-assets.ts +1 -53
- package/src/register-ws-routes.ts +1 -21
- package/src/resolve-brand-favicon.ts +0 -22
- package/src/resolve-service-port.ts +1 -61
- package/src/resolve-theme-mode.ts +1 -30
- package/src/resolvers/app-header.ts +1 -20
- package/src/resolvers/fetch-org-brand.ts +1 -11
- package/src/resolvers/portal-route-paths.ts +1 -29
- package/src/resolvers/website-by-host-map.ts +1 -7
- package/src/resource-not-found.ts +0 -48
- package/src/run-boot-hook-list.ts +1 -13
- package/src/run-boot-hooks.ts +1 -19
- package/src/scala-tools-currency-watchdog.ts +1 -7
- package/src/security-headers.ts +1 -7
- package/src/select-service-routes.ts +1 -31
- package/src/server-error-page.ts +0 -18
- package/src/theme-fonts-link.ts +1 -21
- package/src/transfers/presigned-upload.ts +1 -27
- package/src/two-phase-full-app.ts +0 -82
- package/src/two-phase-minimal-app.ts +1 -111
- package/src/two-phase-readiness.ts +0 -6
- package/src/types.ts +0 -17
- package/src/ui-service.ts +2 -48
- package/src/ws-boot-self-test.ts +1 -14
- 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 -219
- package/src/is-react-element-type.test.ts +0 -45
- package/src/is-react-element-type.ts +0 -20
- package/src/page-server-orpc/hub-mcp-client.ts +0 -20
- 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 -374
|
@@ -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
|
}
|
|
@@ -1,26 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* THE DOCUMENT'S OWN FACTS, DERIVED WHERE THEY ARE KNOWN: the service
|
|
7
|
-
* names itself, and the request knows where it arrived. Deriving rather
|
|
8
|
-
* than configuring is what makes ONE document correct for the internal
|
|
9
|
-
* address the MCP gateway uses and for a public host at the same time
|
|
10
|
-
* (`information-exists-in-one-location` — the request is the only place
|
|
11
|
-
* the reachable address exists).
|
|
12
|
-
*
|
|
13
|
-
* The `/api/rpc` prefix is part of the answer, not an accident of the
|
|
14
|
-
* mount: it is the prefix the POST handler serves under, so a client that
|
|
15
|
-
* reads the document can call what the document describes.
|
|
3
|
+
* @status handwritten
|
|
16
4
|
*/
|
|
17
5
|
|
|
18
|
-
/**
|
|
19
|
-
* Both fallbacks are read through FALSYNESS, not `??`: a header that is
|
|
20
|
-
* present but empty named nothing, and `"" ?? "http"` would splice an
|
|
21
|
-
* empty scheme into the URL as `://host` — a document that serves and
|
|
22
|
-
* cannot be called from (caught by the row passing empty positions).
|
|
23
|
-
*/
|
|
24
6
|
import type { OpenApiDocumentOptions } from "@teamscala/orpc/openapi";
|
|
25
7
|
|
|
26
8
|
const FALLBACK_HOST = "localhost";
|
package/src/orpc-openapi-gate.ts
CHANGED
|
@@ -1,37 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* THE OpenAPI CONTRACT GATE: who may read the document.
|
|
7
|
-
*
|
|
8
|
-
* It refuses a PROXIED request even when the key is right. The guard
|
|
9
|
-
* `auth-helpers-fail-closed` holds a static key internal-only and never a
|
|
10
|
-
* public sole-gate, and names the remedy: also reject on proxy-header
|
|
11
|
-
* presence. Here the two are one fact from both sides — public ingress
|
|
12
|
-
* crosses the tunnel and injects those headers, an internal fetch carries
|
|
13
|
-
* neither, and the MCP gateway is the internal fetch.
|
|
14
|
-
*
|
|
15
|
-
* EVERY REFUSAL IS A REFUSAL: no key, no header, wrong key, proxied.
|
|
3
|
+
* @status handwritten
|
|
16
4
|
*/
|
|
17
5
|
|
|
18
|
-
/**
|
|
19
|
-
* The four request facts the gate reads, all optional: a fact the caller
|
|
20
|
-
* did not send is absent, never a value to trust. Truthiness reads absence
|
|
21
|
-
* and null identically, so a header is an assertion, not a hint.
|
|
22
|
-
*/
|
|
23
6
|
export interface OpenApiContractRequest {
|
|
24
|
-
/** The raw `authorization` header, absent when the caller sent none. */
|
|
25
7
|
authorization?: string | null;
|
|
26
|
-
/** The configured `SCALA_DEV_KEY`, absent when the service has none. */
|
|
27
8
|
key?: string | null;
|
|
28
|
-
/** `x-forwarded-proto`. Its presence means the request was proxied. */
|
|
29
9
|
forwardedProto?: string | null;
|
|
30
|
-
/** `x-forwarded-for`. Same meaning, checked so neither alone is load-bearing. */
|
|
31
10
|
forwardedFor?: string | null;
|
|
32
11
|
}
|
|
33
12
|
|
|
34
|
-
/** True only for an unproxied request presenting exactly the configured key. */
|
|
35
13
|
export function openApiContractAuthorised(request: OpenApiContractRequest): boolean {
|
|
36
14
|
if (!request.key) return false;
|
|
37
15
|
if (request.forwardedProto || request.forwardedFor) return false;
|
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @system
|
|
2
|
+
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Render mode literal shared across page route registration. The old
|
|
7
|
-
* shell-variant types (LegacyShell, PageSlugShell, RegionsShell,
|
|
8
|
-
* PublicShell, ConfigPageRoute) were removed when the Phase 2 deploy-time
|
|
9
|
-
* `service_install.page_routes[]` path was eliminated — all routing is
|
|
10
|
-
* now registry-driven `page_route` rows (Phase 1).
|
|
11
4
|
*/
|
|
12
5
|
|
|
13
6
|
export type RenderModeLiteral =
|
|
@@ -1,16 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* page-server-loader-graph.ts — binds the ONE loader-graph resolver
|
|
7
|
-
* (@teamscala/resolver-engine's createLoaderConfigResolver) to the page-server
|
|
8
|
-
* SSR client. The resolver used to be duplicated here with its own algorithm;
|
|
9
|
-
* the copies drifted (a non-UUID URL ref resolved absent on this surface and
|
|
10
|
-
* still threw into the render breaker on the fn-router surface), so the
|
|
11
|
-
* algorithm became one injected function and each surface binds its own
|
|
12
|
-
* client source. This module IS that binding for the SSR surface — the single
|
|
13
|
-
* place the page-server client and the loader-graph resolver meet.
|
|
14
4
|
*/
|
|
15
5
|
|
|
16
6
|
import { createLoaderConfigResolver } from "@teamscala/resolver-engine/loader-config/resolve-from-loader-config";
|
|
@@ -1,28 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @system
|
|
2
|
+
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Server-side ORPC client for in-process direct invocation, per the canonical
|
|
7
|
-
* Vite/Next/Nuxt SSR pattern documented at https://orpc.dev/docs/best-practices/optimize-ssr
|
|
8
|
-
*
|
|
9
|
-
* The host service (scala-install) must call `setServerClient(client)` once
|
|
10
|
-
* at boot, after createRouterClient(appRouter, { context: { prisma } }).
|
|
11
|
-
* Then page-server's createPageHandler reads `getServerClient()` per request
|
|
12
|
-
* — no HTTP loopback, just a typed function call.
|
|
13
|
-
*
|
|
14
|
-
* NO EMPTY-ROUTER SUBSTITUTE. This used to answer an unregistered read by
|
|
15
|
-
* fabricating `createRouterClient({}, …)` — a client with no models on it. That
|
|
16
|
-
* turned "boot never registered a client" into "every model lookup returns
|
|
17
|
-
* undefined", which surfaces far away as MissingLoaderModelCapabilityError on
|
|
18
|
-
* page render rather than as a boot failure. Measured 2026-08-20: quiz and forms
|
|
19
|
-
* served 500s on every data-backed page for hours while /health returned 200 and
|
|
20
|
-
* boot logged "ORPC serverClient registered", because the empty router is
|
|
21
|
-
* indistinguishable from a real one until something asks it for a model
|
|
22
|
-
* (`a-component-may-not-report-a-state-it-has-not-verified`).
|
|
23
|
-
*
|
|
24
|
-
* A missing client is now a thrown error naming the boot step that should have
|
|
25
|
-
* set it, so the failure is reported by the component that has the fault.
|
|
26
4
|
*/
|
|
27
5
|
|
|
28
6
|
import { createRouterClient } from "@orpc/server";
|
|
@@ -1,38 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @system
|
|
2
|
+
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* The pg-boss scheduled-job handler registry — `handler_slug` → handler
|
|
7
|
-
* function. Built on `@teamscala/generic-registry` `createRegistry` (the mandated
|
|
8
|
-
* primitive per `generic-registry-is-the-only-registry-pattern`); a hand-rolled
|
|
9
|
-
* Map or a hardcoded switch in service-boot is the prohibited alternative.
|
|
10
|
-
*
|
|
11
|
-
* Handlers live in packages and self-register at boot (a boot hook / package
|
|
12
|
-
* subscriber shape) by calling `registerScheduledJobHandler`. `resolveHandler`
|
|
13
|
-
* in `pg-boss-mount.ts` reads this registry to dispatch a firing `scheduled_job`
|
|
14
|
-
* row to its registered function.
|
|
15
4
|
*/
|
|
16
5
|
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
17
6
|
import { createRegistry } from "@teamscala/generic-registry/create-registry";
|
|
18
7
|
|
|
19
8
|
export type PgBossJobHandler = (ctx: {
|
|
20
9
|
config: ScheduledJobConfig;
|
|
21
|
-
/** Per-job data payload from a one-shot `boss.send()` — absent for scheduled jobs. */
|
|
22
10
|
jobData?: unknown;
|
|
23
11
|
}) => Promise<unknown> | unknown;
|
|
24
12
|
|
|
25
13
|
export const pgBossHandlerRegistry = createRegistry<PgBossJobHandler>(
|
|
26
14
|
"pg-boss-handlers",
|
|
27
15
|
);
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Register a handler for a `scheduled_job.handler_slug`. Idempotent on re-import
|
|
31
|
-
* (createRegistry.register overwrites silently — last registration wins).
|
|
32
|
-
*/
|
|
33
|
-
export function registerScheduledJobHandler(
|
|
34
|
-
slug: string,
|
|
35
|
-
handler: PgBossJobHandler,
|
|
36
|
-
): void {
|
|
37
|
-
pgBossHandlerRegistry.register(slug, handler);
|
|
38
|
-
}
|
package/src/pg-boss-mount.ts
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @system
|
|
2
|
+
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Mounts pg-boss for a backend service. Reads all scheduled_job registry
|
|
7
|
-
* entries scoped to the current service from the registry cache, registers
|
|
8
|
-
* pg-boss workers per queue_name, and schedules cron/interval jobs.
|
|
9
|
-
* Per Constitution Rule 6: pg-boss is the single mechanism for all
|
|
10
|
-
* application background work.
|
|
11
4
|
*/
|
|
12
5
|
|
|
13
6
|
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
@@ -21,25 +14,8 @@ import type { ScheduleOptions } from "pg-boss";
|
|
|
21
14
|
import { PgBoss } from "pg-boss";
|
|
22
15
|
import { pgBossHandlerRegistry } from "./pg-boss-handler-registry.ts";
|
|
23
16
|
|
|
24
|
-
// Handlers register via their package's own boot hook / subscriber import
|
|
25
|
-
// (package-subscriber shape) — no bundled copies. The mount resolves each
|
|
26
|
-
// row's handler_slug against this registry at dispatch time.
|
|
27
17
|
|
|
28
18
|
|
|
29
|
-
/**
|
|
30
|
-
* Module-level reference to the mounted PgBoss instance — set by mountPgBoss on
|
|
31
|
-
* successful start. Exposed via getPgBoss() so ORPC procedures (and other
|
|
32
|
-
* in-process callers) can submit one-shot jobs via boss.send() without a second
|
|
33
|
-
* PgBoss connection. Null before mount or when mountPgBoss found no scheduled
|
|
34
|
-
* jobs for this service (boss never created).
|
|
35
|
-
*/
|
|
36
|
-
let pgBossInstance: PgBoss | null = null;
|
|
37
|
-
|
|
38
|
-
/** Returns the mounted PgBoss instance, or null if pg-boss is not running on this service. */
|
|
39
|
-
export function getPgBoss(): PgBoss | null {
|
|
40
|
-
return pgBossInstance;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
19
|
export interface PgBossMountContext {
|
|
44
20
|
databaseUrl: string;
|
|
45
21
|
serviceSlug: string;
|
|
@@ -66,9 +42,6 @@ function belongsToService(
|
|
|
66
42
|
const config = entry.config as Record<string, unknown>;
|
|
67
43
|
const handlerSlug = config.handler_slug as string | undefined;
|
|
68
44
|
if (!handlerSlug) return false;
|
|
69
|
-
// Explicit row-driven targeting takes precedence — a universal boot-package
|
|
70
|
-
// handler (e.g. one in @teamscala/service-boot, which no service declares in
|
|
71
|
-
// server_packages) cannot be scoped by the server_package prefix match.
|
|
72
45
|
const targetService = config.service_slug as string | undefined;
|
|
73
46
|
if (targetService) return targetService === serviceSlug;
|
|
74
47
|
return packages.some(
|
|
@@ -120,50 +93,13 @@ export async function mountPgBoss(
|
|
|
120
93
|
const log = getAppLogger();
|
|
121
94
|
const jobs: MountedJob[] = [];
|
|
122
95
|
|
|
123
|
-
// pg-boss connects through pg, whose sslmode dialect differs from the
|
|
124
|
-
// shared URL's libpq spelling — route through the canonical rewrite.
|
|
125
|
-
// Config object (not a bare connectionString) so the internal pool is
|
|
126
|
-
// bounded + tagged for the ~220ms cross-internet dev→Railway RTT.
|
|
127
|
-
// POLL-ONLY, EXPLICITLY. pg-boss v12 dispatches by polling (SELECT .. FOR
|
|
128
|
-
// UPDATE SKIP LOCKED inside a transaction; its locks are
|
|
129
|
-
// pg_advisory_xact_lock — transaction-scoped, so a transaction pooler
|
|
130
|
-
// cannot leak them); LISTEN/NOTIFY is a supplier-documented latency hint
|
|
131
|
-
// whose registration dies on a transaction-pooler backend reassignment
|
|
132
|
-
// (measured 2026-09-14: the opted-in sync worker boss logged
|
|
133
|
-
// `LISTEN/NOTIFY channel registration was lost` every log window).
|
|
134
|
-
// attorney.js defaults useListenNotify to false in 12.29.0 (pinned),
|
|
135
|
-
// 12.30.0 (deployed) and 12.31.1 (latest) — all three read directly —
|
|
136
|
-
// and spelling it here keeps that contract explicit so a pg-boss bump
|
|
137
|
-
// cannot silently re-introduce a LISTEN dependency
|
|
138
|
-
// (close-the-session-mode-door, 2026-09-14). The connection dials the
|
|
139
|
-
// injected DATABASE_URL — the transaction pooler — reconciling the pg
|
|
140
|
-
// sslmode dialect; the mount adds no endpoint derivation of its own.
|
|
141
96
|
const boss = new PgBoss({
|
|
142
97
|
useListenNotify: false,
|
|
143
98
|
connectionString: pgSslCompatUrl(ctx.databaseUrl),
|
|
144
99
|
application_name: `pgboss:${ctx.serviceSlug}`,
|
|
145
|
-
// ONE connection, not three: pg-boss is a sibling pool that sits OUTSIDE
|
|
146
|
-
// the Prisma budget, and with ~46 declared holders the fleet saturated
|
|
147
|
-
// at 304/300 (2026-08-27) partly on 3-connection pg-boss pools that had
|
|
148
|
-
// no idle timeout. At max:1 jobs serialize into a queue — the sanctioned
|
|
149
|
-
// background degradation (`a-per-consumer-pool-max-is-not-a-bound-on-the-server`:
|
|
150
|
-
// background work degrades to a queue, interactive work degrades to an
|
|
151
|
-
// outage). The sibling total is declared in
|
|
152
|
-
// config/db-pool-sizing.perServiceOverhead (pg-boss 1; the LISTEN allowance is gone with poll-only).
|
|
153
100
|
max: 1,
|
|
154
|
-
// Generous connection-acquisition budget for the cross-internet link so
|
|
155
|
-
// a slow TLS handshake under sustained load doesn't terminate the conn.
|
|
156
101
|
connectionTimeoutMillis: 30_000,
|
|
157
102
|
});
|
|
158
|
-
// crash-isolation: pg-boss's manager emits 'error' on a worker-connection
|
|
159
|
-
// failure (a proxy-killed idle conn under sustained cross-internet writes).
|
|
160
|
-
// With NO listener, EventEmitter THROWS on emit('error'), the worker's
|
|
161
|
-
// promise rejects unhandled, and Bun exits the process — the scala-extract
|
|
162
|
-
// restart-storm of 2026-07-19. The listener makes it a logged, recovered
|
|
163
|
-
// event. This is the pg-boss-owned connection, the sibling of @teamscala/db's
|
|
164
|
-
// application-pool pool.on('error') handler (the app pool alone did not
|
|
165
|
-
// cover pg-boss's separate pool). Registered before start() so a boot-time
|
|
166
|
-
// error is caught too.
|
|
167
103
|
boss.on("error", (err) => {
|
|
168
104
|
log.warn(`[pg-boss:${ctx.serviceSlug}] recovered error event (process kept alive)`, {
|
|
169
105
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -172,21 +108,10 @@ export async function mountPgBoss(
|
|
|
172
108
|
boss.on("warning", (w) => {
|
|
173
109
|
log.warn(`[pg-boss:${ctx.serviceSlug}] warning`, { warning: String(w) });
|
|
174
110
|
});
|
|
175
|
-
// start() MUST precede work()/schedule() — it opens the DB connection;
|
|
176
|
-
// calling schedule() before start() crashes the timekeeper with
|
|
177
|
-
// "Database not opened. Call open() before executing SQL." (pg-boss v12)
|
|
178
111
|
await boss.start();
|
|
179
|
-
pgBossInstance = boss;
|
|
180
112
|
|
|
181
113
|
for (const entry of scheduledEntries) {
|
|
182
|
-
// crash-isolation: a malformed scheduled_job row degrades to "this job
|
|
183
|
-
// unavailable" (logged + event-log row), NEVER a boot-crash. A bare
|
|
184
|
-
// pg-boss assert reaching boot is the violation.
|
|
185
114
|
let config: ScheduledJobConfig;
|
|
186
|
-
// validateRow returns a RESULT WRAPPER ({ok:true,value}|{ok:false,issues}),
|
|
187
|
-
// never the config itself — measured 2026-09-16: assigning the wrapper as
|
|
188
|
-
// the config left queue_name/handler_slug undefined and silently skipped
|
|
189
|
-
// EVERY row on services holding db-validation 0.1.289+.
|
|
190
115
|
const validated = validateRow("scheduled_job", entry.config);
|
|
191
116
|
if (!validated.ok) {
|
|
192
117
|
const reason = validated.issues
|
|
@@ -217,19 +142,12 @@ export async function mountPgBoss(
|
|
|
217
142
|
const queueName = config.queue_name;
|
|
218
143
|
|
|
219
144
|
try {
|
|
220
|
-
// pg-boss v12 requires the queue to exist before work()/schedule()
|
|
221
|
-
// dispatch against it ("Queue X does not exist") — createQueue's
|
|
222
|
-
// underlying SQL is ON CONFLICT DO NOTHING, so calling it on every
|
|
223
|
-
// boot is a safe idempotent assert, never a duplicate-creation error.
|
|
224
145
|
await boss.createQueue(queueName);
|
|
225
146
|
await boss.work(queueName, async (jobs) => {
|
|
226
147
|
let result: unknown;
|
|
227
148
|
for (const job of jobs) {
|
|
228
149
|
result = await resolveHandler(config.handler_slug, config, job.data);
|
|
229
150
|
}
|
|
230
|
-
// pg-boss stores the work handler return as job output when
|
|
231
|
-
// exactly one job was processed (manager.js complete()). For
|
|
232
|
-
// batch/scheduled jobs the return is not stored.
|
|
233
151
|
return jobs.length === 1 ? result : undefined;
|
|
234
152
|
});
|
|
235
153
|
|
|
@@ -267,15 +185,6 @@ export async function mountPgBoss(
|
|
|
267
185
|
});
|
|
268
186
|
}
|
|
269
187
|
|
|
270
|
-
// A SUCCESSFUL mount emitted nothing until now, while every failure
|
|
271
|
-
// path above recorded an event — so the one state you most need to
|
|
272
|
-
// confirm ("is this job actually scheduled on this service?") was the
|
|
273
|
-
// only one with no evidence. Combined with resolveHandler THROWING
|
|
274
|
-
// into boss.work() rather than warning, that made "never scheduled"
|
|
275
|
-
// and "fired and threw" externally identical, and cost a
|
|
276
|
-
// scheduled_job relocation that had to be reverted undiagnosed on
|
|
277
|
-
// 2026-07-30. Emitting on success makes the mount set queryable
|
|
278
|
-
// (event-log-is-the-only-durable-event-log).
|
|
279
188
|
const mounted = jobs[jobs.length - 1];
|
|
280
189
|
recordEvent({
|
|
281
190
|
kind: "pgboss.job.mounted",
|
|
@@ -289,10 +198,6 @@ export async function mountPgBoss(
|
|
|
289
198
|
},
|
|
290
199
|
});
|
|
291
200
|
} catch (err) {
|
|
292
|
-
// crash-isolation for the mount path: a work()/schedule() failure
|
|
293
|
-
// (e.g. DB not open, queue conflict) degrades to "this job unavailable"
|
|
294
|
-
// (logged + event-log row), NEVER a boot-crash. Same treatment as the
|
|
295
|
-
// row-validation guard above.
|
|
296
201
|
const reason = err instanceof Error ? err.message : String(err);
|
|
297
202
|
log.error(
|
|
298
203
|
`[pg-boss-mount:${ctx.serviceSlug}] scheduled_job "${entry.slug}" failed to mount (work/schedule) — skipping`,
|
|
@@ -306,16 +211,6 @@ export async function mountPgBoss(
|
|
|
306
211
|
}
|
|
307
212
|
}
|
|
308
213
|
|
|
309
|
-
// Reconcile orphaned cron schedules: unschedule any pg-boss schedule that no longer corresponds to an ACTIVE registry row. Deactivating/deleting a
|
|
310
|
-
// scheduled_job (or sync_job_handler) previously left its cron lingering in pgboss.schedule, firing indefinitely — 2 stale web-extractor schedules
|
|
311
|
-
// found 2026-07-27, still scraping every 3/15min months after the job was meant to be one-off. Deactivation must actually stop the cron. A schedule is
|
|
312
|
-
// "backed" iff ANY active registry row's slug OR queue_name equals its name — this covers scheduled_job AND sync_job_handler (and any future
|
|
313
|
-
// schedule-owning type) without hardcoding the type list, so live sync jobs (zoho_assist_sync, trackabi, …) are never touched. Runs on every pg-boss
|
|
314
|
-
// mount and reconciles GLOBALLY: an orphan is removed regardless of which
|
|
315
|
-
// service created it, since a cron with no active backing row is drift by
|
|
316
|
-
// definition. A service with zero active scheduled jobs early-returns above
|
|
317
|
-
// and never reaches here — scala-loga (7 cron jobs) is the reliable
|
|
318
|
-
// reconciler, but any service-with-jobs that boots cleans the full set.
|
|
319
214
|
try {
|
|
320
215
|
const backedScheduleNames = new Set(
|
|
321
216
|
allEntries.flatMap((e) => {
|
|
@@ -342,9 +237,6 @@ export async function mountPgBoss(
|
|
|
342
237
|
});
|
|
343
238
|
}
|
|
344
239
|
} catch (err) {
|
|
345
|
-
// crash-isolation: a reconciliation failure degrades to "orphans not
|
|
346
|
-
// cleaned this boot" (logged), never a boot-crash — reconciled on the
|
|
347
|
-
// next successful mount.
|
|
348
240
|
log.warn(`[pg-boss:${ctx.serviceSlug}] schedule reconciliation failed`, {
|
|
349
241
|
error: err instanceof Error ? err.message : String(err),
|
|
350
242
|
});
|