@teamscala/service-boot 0.23.518 → 0.23.519
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
CHANGED
|
@@ -1,36 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_generated": {
|
|
3
|
-
"system": "
|
|
3
|
+
"system": "service-boot",
|
|
4
4
|
"status": "generated",
|
|
5
5
|
"edit": "edit src/ — dependencies and exports derive from imports; everything else preserved"
|
|
6
6
|
},
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"@orpc/server": "1.15.4",
|
|
9
|
-
"@teamscala/agreement-generation": "
|
|
10
|
-
"@teamscala/auth": "
|
|
11
|
-
"@teamscala/cache": "
|
|
12
|
-
"@teamscala/db": "
|
|
13
|
-
"@teamscala/db-validation": "
|
|
14
|
-
"@teamscala/document-signing": "
|
|
15
|
-
"@teamscala/email-client": "
|
|
16
|
-
"@teamscala/encryption": "
|
|
17
|
-
"@teamscala/event-log": "
|
|
18
|
-
"@teamscala/file-lock": "
|
|
19
|
-
"@teamscala/formatters": "
|
|
20
|
-
"@teamscala/generic-registry": "
|
|
21
|
-
"@teamscala/hono-boundary": "
|
|
22
|
-
"@teamscala/http": "
|
|
23
|
-
"@teamscala/logger": "
|
|
24
|
-
"@teamscala/mcp-multi-session": "
|
|
25
|
-
"@teamscala/object-storage": "
|
|
26
|
-
"@teamscala/orpc": "
|
|
27
|
-
"@teamscala/os": "
|
|
28
|
-
"@teamscala/
|
|
29
|
-
"@teamscala/
|
|
30
|
-
"@teamscala/resolver-engine": "
|
|
31
|
-
"@teamscala/timing": "
|
|
32
|
-
"@teamscala/tool-executor": "
|
|
33
|
-
"@teamscala/watchdog": "
|
|
9
|
+
"@teamscala/agreement-generation": "1.0.17",
|
|
10
|
+
"@teamscala/auth": "1.102.11",
|
|
11
|
+
"@teamscala/cache": "1.0.79",
|
|
12
|
+
"@teamscala/db": "1.21.43765",
|
|
13
|
+
"@teamscala/db-validation": "0.1.386",
|
|
14
|
+
"@teamscala/document-signing": "1.0.8",
|
|
15
|
+
"@teamscala/email-client": "0.1.7",
|
|
16
|
+
"@teamscala/encryption": "0.3.100",
|
|
17
|
+
"@teamscala/event-log": "0.1.30",
|
|
18
|
+
"@teamscala/file-lock": "0.0.4",
|
|
19
|
+
"@teamscala/formatters": "0.2.95",
|
|
20
|
+
"@teamscala/generic-registry": "1.0.14",
|
|
21
|
+
"@teamscala/hono-boundary": "0.0.1",
|
|
22
|
+
"@teamscala/http": "2.1.109",
|
|
23
|
+
"@teamscala/logger": "1.1.203",
|
|
24
|
+
"@teamscala/mcp-multi-session": "1.3.150",
|
|
25
|
+
"@teamscala/object-storage": "1.2.201",
|
|
26
|
+
"@teamscala/orpc": "2.6.121",
|
|
27
|
+
"@teamscala/os": "1.15.267",
|
|
28
|
+
"@teamscala/publish-registry": "0.1.5",
|
|
29
|
+
"@teamscala/puck-boundary": "0.0.3",
|
|
30
|
+
"@teamscala/resolver-engine": "1.1.210",
|
|
31
|
+
"@teamscala/timing": "1.0.51",
|
|
32
|
+
"@teamscala/tool-executor": "4.1.171",
|
|
33
|
+
"@teamscala/watchdog": "1.0.55",
|
|
34
34
|
"hono": "4.13.9",
|
|
35
35
|
"pg-boss": "12.35.0",
|
|
36
36
|
"tailwindcss": "4.3.3",
|
|
@@ -82,6 +82,8 @@
|
|
|
82
82
|
"./auth-gate.ts": "./src/auth-gate.ts",
|
|
83
83
|
"./auth-gate/public-paths": "./src/auth-gate/public-paths.ts",
|
|
84
84
|
"./auth-gate/public-paths.ts": "./src/auth-gate/public-paths.ts",
|
|
85
|
+
"./auth/sign-out": "./src/auth/sign-out.ts",
|
|
86
|
+
"./auth/sign-out.ts": "./src/auth/sign-out.ts",
|
|
85
87
|
"./bind-page-handler": "./src/bind-page-handler.ts",
|
|
86
88
|
"./bind-page-handler.ts": "./src/bind-page-handler.ts",
|
|
87
89
|
"./build-client-assets": "./src/build-client-assets.ts",
|
|
@@ -150,6 +152,10 @@
|
|
|
150
152
|
"./mount-server-packages.ts": "./src/mount-server-packages.ts",
|
|
151
153
|
"./mutable-app-ref": "./src/mutable-app-ref.ts",
|
|
152
154
|
"./mutable-app-ref.ts": "./src/mutable-app-ref.ts",
|
|
155
|
+
"./orpc-openapi-document": "./src/orpc-openapi-document.ts",
|
|
156
|
+
"./orpc-openapi-document.ts": "./src/orpc-openapi-document.ts",
|
|
157
|
+
"./orpc-openapi-gate": "./src/orpc-openapi-gate.ts",
|
|
158
|
+
"./orpc-openapi-gate.ts": "./src/orpc-openapi-gate.ts",
|
|
153
159
|
"./page-route-shell-types": "./src/page-route-shell-types.ts",
|
|
154
160
|
"./page-route-shell-types.ts": "./src/page-route-shell-types.ts",
|
|
155
161
|
"./page-server-loader-graph": "./src/page-server-loader-graph.ts",
|
|
@@ -256,6 +262,8 @@
|
|
|
256
262
|
"./server-error-page.ts": "./src/server-error-page.ts",
|
|
257
263
|
"./theme-fonts-link": "./src/theme-fonts-link.ts",
|
|
258
264
|
"./theme-fonts-link.ts": "./src/theme-fonts-link.ts",
|
|
265
|
+
"./transfers/presigned-upload": "./src/transfers/presigned-upload.ts",
|
|
266
|
+
"./transfers/presigned-upload.ts": "./src/transfers/presigned-upload.ts",
|
|
259
267
|
"./two-phase-full-app": "./src/two-phase-full-app.ts",
|
|
260
268
|
"./two-phase-full-app.ts": "./src/two-phase-full-app.ts",
|
|
261
269
|
"./two-phase-minimal-app": "./src/two-phase-minimal-app.ts",
|
|
@@ -292,5 +300,5 @@
|
|
|
292
300
|
"repository": "https://github.com/teamgbg/service-boot",
|
|
293
301
|
"sideEffects": false,
|
|
294
302
|
"type": "module",
|
|
295
|
-
"version": "0.23.
|
|
303
|
+
"version": "0.23.519"
|
|
296
304
|
}
|
|
@@ -135,4 +135,13 @@ export const PUBLIC_INFRA_PATHS = new Set([
|
|
|
135
135
|
// page). It MUST be exempt: an authed-but-not-allowed viewer is redirected
|
|
136
136
|
// here, so gating it would re-reject and loop. It renders no sensitive data.
|
|
137
137
|
"/unauthorised",
|
|
138
|
+
// The OpenAPI contract for the ORPC router — a service-to-service surface
|
|
139
|
+
// with its OWN Bearer auth (`Authorization: Bearer SCALA_DEV_KEY`,
|
|
140
|
+
// validated by the mount-orpc-router handler, fail-closed), exactly like
|
|
141
|
+
// /mcp and /api/fleet/lane-event above. The MCP gateway fetches it at boot
|
|
142
|
+
// (SCALA_OPENAPI_SPEC_URL) with no session cookie; without this exemption
|
|
143
|
+
// the page auth-gate 302s the fetch to /sign-in and the gateway reads an
|
|
144
|
+
// HTML sign-in page where the JSON document belongs (observed probing
|
|
145
|
+
// scala-portal.internal:5200/api/rpc/openapi.json on 2026-09-30).
|
|
146
|
+
"/api/rpc/openapi.json",
|
|
138
147
|
]);
|
package/src/mount-orpc-router.ts
CHANGED
|
@@ -15,6 +15,8 @@ import type { Db8Client } from "@teamscala/db/db8-registry";
|
|
|
15
15
|
import { resolveDbUrlOrNull } from "@teamscala/db/db-url";
|
|
16
16
|
import type { Hono } from "hono";
|
|
17
17
|
import { bodyLimit } from "hono/body-limit";
|
|
18
|
+
import { openApiContractAuthorised } from "./orpc-openapi-gate.ts";
|
|
19
|
+
import { openApiContractOptions } from "./orpc-openapi-document.ts";
|
|
18
20
|
|
|
19
21
|
export interface OrpcMountResult {
|
|
20
22
|
routeCount: number;
|
|
@@ -277,6 +279,40 @@ export async function mountOrpcRouter(
|
|
|
277
279
|
},
|
|
278
280
|
);
|
|
279
281
|
|
|
282
|
+
// THE OpenAPI CONTRACT, served BESIDE the RPC — a GET on the same prefix,
|
|
283
|
+
// generated from the router object mounted below, so the document and the
|
|
284
|
+
// handler cannot describe different surfaces. Order is free: the methods
|
|
285
|
+
// never overlap (`app.post` matches POST alone).
|
|
286
|
+
//
|
|
287
|
+
// Gated by openApiContractAuthorised, which refuses a proxied request as
|
|
288
|
+
// well as a wrong key (guard auth-helpers-fail-closed), so only an
|
|
289
|
+
// internal caller — the MCP gateway — reaches this. Its own facts come
|
|
290
|
+
// from openApiContractOptions; both units carry the reasoning, and
|
|
291
|
+
// contract rows state their behaviour.
|
|
292
|
+
app.get("/api/rpc/openapi.json", async (c) => {
|
|
293
|
+
const header = (name: string) => c.req.header(name) ?? null;
|
|
294
|
+
if (
|
|
295
|
+
!openApiContractAuthorised({
|
|
296
|
+
authorization: header("authorization"),
|
|
297
|
+
key: process.env.SCALA_DEV_KEY,
|
|
298
|
+
forwardedProto: header("x-forwarded-proto"),
|
|
299
|
+
forwardedFor: header("x-forwarded-for"),
|
|
300
|
+
})
|
|
301
|
+
) {
|
|
302
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
303
|
+
}
|
|
304
|
+
const { buildOpenApiDocument } = await import("@teamscala/orpc/openapi");
|
|
305
|
+
const document = await buildOpenApiDocument(
|
|
306
|
+
router as Record<string, unknown>,
|
|
307
|
+
openApiContractOptions(
|
|
308
|
+
serviceSlug,
|
|
309
|
+
header("host"),
|
|
310
|
+
header("x-forwarded-proto"),
|
|
311
|
+
),
|
|
312
|
+
);
|
|
313
|
+
return c.json(document);
|
|
314
|
+
});
|
|
315
|
+
|
|
280
316
|
const routeKeys = Object.keys(router as Record<string, unknown>).filter(
|
|
281
317
|
(k) => typeof (router as Record<string, unknown>)[k] !== "undefined",
|
|
282
318
|
);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
{
|
|
2
|
+
"suite": {
|
|
3
|
+
"target": "src/orpc-contract-rows.json"
|
|
4
|
+
},
|
|
5
|
+
"rows": [
|
|
6
|
+
{
|
|
7
|
+
"args": [
|
|
8
|
+
{
|
|
9
|
+
"key": "row-fixture-key",
|
|
10
|
+
"authorization": "Bearer row-fixture-key"
|
|
11
|
+
},
|
|
12
|
+
""
|
|
13
|
+
],
|
|
14
|
+
"note": "THE ONE CALLER THAT GETS THROUGH. The internal service-to-service fetch the MCP gateway makes at boot: the configured key presented as a Bearer token, on a request carrying no forwarding header. The second arg position is a placeholder the one-parameter subject ignores, because the registry writer collapses an argument array shorter than two elements (measured 2026-09-30 while filing the @teamscala/orpc rows) so a one-argument row cannot be spelled. The key value is a fixture, not a credential: no secret row holds it and the production key arrives from the SCALA_DEV_KEY environment.",
|
|
15
|
+
"expect": true,
|
|
16
|
+
"export": "openApiContractAuthorised",
|
|
17
|
+
"module": "src/orpc-openapi-gate.ts",
|
|
18
|
+
"package": "@teamscala/service-boot",
|
|
19
|
+
"suite_type": "function_call",
|
|
20
|
+
"slug": "boot:openapi-contract-authorises-the-internal-key-holder"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"args": [
|
|
24
|
+
"scala-quiz",
|
|
25
|
+
"",
|
|
26
|
+
""
|
|
27
|
+
],
|
|
28
|
+
"note": "THE FALLBACKS, PINNED SO A MISSING HEADER CANNOT PRODUCE A MALFORMED URL. A request that names no Host and carries no forwarding header still gets a well-formed server entry (localhost, http) rather than an undefined host interpolated into the document as the string null. The title comes from the slug regardless - the service always names itself. The empty-string arg positions stand for absent headers: a JSON argument cannot spell undefined, and the subject reads absence through the same falsy branch an empty string takes.",
|
|
29
|
+
"expect": {
|
|
30
|
+
"member_equals": {
|
|
31
|
+
"title": "scala-quiz oRPC procedures",
|
|
32
|
+
"servers.0.url": "http://localhost/api/rpc"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"export": "openApiContractOptions",
|
|
36
|
+
"module": "src/orpc-openapi-document.ts",
|
|
37
|
+
"package": "@teamscala/service-boot",
|
|
38
|
+
"suite_type": "function_call",
|
|
39
|
+
"slug": "boot:openapi-contract-falls-back-when-the-request-names-no-host"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"args": [
|
|
43
|
+
"scala-portal",
|
|
44
|
+
"scala-portal.internal:5200",
|
|
45
|
+
"http"
|
|
46
|
+
],
|
|
47
|
+
"note": "THE DOCUMENT NAMES ITS SERVICE AND THE PREFIX IT SERVES. The title is the slug the mount was handed and the server URL is the host this request arrived on, with the /api/rpc prefix appended because that is the prefix the POST handler serves under - a client that reads the document can therefore call what the document describes. Deriving both rather than configuring them is what makes ONE document correct for the internal address the MCP gateway fetches and for a public host at the same time; the values are asserted exactly, so a change in either is a visible diff rather than a silently wrong server URL.",
|
|
48
|
+
"expect": {
|
|
49
|
+
"member_equals": {
|
|
50
|
+
"title": "scala-portal oRPC procedures",
|
|
51
|
+
"servers.0.url": "http://scala-portal.internal:5200/api/rpc"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"export": "openApiContractOptions",
|
|
55
|
+
"module": "src/orpc-openapi-document.ts",
|
|
56
|
+
"package": "@teamscala/service-boot",
|
|
57
|
+
"suite_type": "function_call",
|
|
58
|
+
"slug": "boot:openapi-contract-names-the-service-and-the-prefix-it-serves"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"args": [
|
|
62
|
+
{
|
|
63
|
+
"key": "row-fixture-key",
|
|
64
|
+
"forwardedFor": "203.0.113.7",
|
|
65
|
+
"authorization": "Bearer row-fixture-key",
|
|
66
|
+
"forwardedProto": "https"
|
|
67
|
+
},
|
|
68
|
+
""
|
|
69
|
+
],
|
|
70
|
+
"note": "THE STATIC KEY IS NEVER A PUBLIC SOLE-GATE. Guard auth-helpers-fail-closed holds that a shared key is internal-only and names the remedy for a legitimate internal bearer check: also reject on proxy-header presence. This row is that remedy. The caller holds the right key and is still refused, because x-forwarded-proto and x-forwarded-for prove the request crossed a proxy, and on this platform only public ingress puts them there - an internal fetch, which is what the MCP gateway makes at boot, carries neither. Without this refusal the key alone would open the procedure surface to the public internet.",
|
|
71
|
+
"expect": false,
|
|
72
|
+
"export": "openApiContractAuthorised",
|
|
73
|
+
"module": "src/orpc-openapi-gate.ts",
|
|
74
|
+
"package": "@teamscala/service-boot",
|
|
75
|
+
"suite_type": "function_call",
|
|
76
|
+
"slug": "boot:openapi-contract-refuses-a-proxied-request"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"args": [
|
|
80
|
+
{
|
|
81
|
+
"key": "row-fixture-key"
|
|
82
|
+
},
|
|
83
|
+
""
|
|
84
|
+
],
|
|
85
|
+
"note": "NO CREDENTIAL, NO DOCUMENT. A caller presenting no Authorization header is refused even though the service HAS a key configured, so the gate is not merely comparing a header to a value. The trailing empty-string arg position is a placeholder the one-parameter subject ignores: the registry writer collapses an argument array shorter than two elements (measured 2026-09-30 while filing the @teamscala/orpc rows), so a one-argument row cannot be spelled.",
|
|
86
|
+
"expect": false,
|
|
87
|
+
"export": "openApiContractAuthorised",
|
|
88
|
+
"module": "src/orpc-openapi-gate.ts",
|
|
89
|
+
"package": "@teamscala/service-boot",
|
|
90
|
+
"suite_type": "function_call",
|
|
91
|
+
"slug": "boot:openapi-contract-refuses-a-request-with-no-authorization"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"args": [
|
|
95
|
+
{
|
|
96
|
+
"key": "row-fixture-key",
|
|
97
|
+
"authorization": "Bearer not-the-configured-key"
|
|
98
|
+
},
|
|
99
|
+
""
|
|
100
|
+
],
|
|
101
|
+
"note": "THE NEAR-MISS. A Bearer token of the right shape carrying the wrong value is refused, which is the case a prefix check alone would wave through. Trailing arg position is a placeholder (see the no-authorization row for why).",
|
|
102
|
+
"expect": false,
|
|
103
|
+
"export": "openApiContractAuthorised",
|
|
104
|
+
"module": "src/orpc-openapi-gate.ts",
|
|
105
|
+
"package": "@teamscala/service-boot",
|
|
106
|
+
"suite_type": "function_call",
|
|
107
|
+
"slug": "boot:openapi-contract-refuses-a-wrong-key"
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"args": [
|
|
111
|
+
{
|
|
112
|
+
"authorization": "Bearer anything-at-all"
|
|
113
|
+
},
|
|
114
|
+
""
|
|
115
|
+
],
|
|
116
|
+
"note": "FAIL CLOSED WHEN THE SERVICE CANNOT GATE. No SCALA_DEV_KEY configured means there is no key to match, and a missing signal must DENY rather than admit every caller: a service that cannot authenticate must not serve the internal procedure surface. The key member is absent from the argument, which is exactly how an unconfigured environment reaches this subject. Trailing arg position is a placeholder.",
|
|
117
|
+
"expect": false,
|
|
118
|
+
"export": "openApiContractAuthorised",
|
|
119
|
+
"module": "src/orpc-openapi-gate.ts",
|
|
120
|
+
"package": "@teamscala/service-boot",
|
|
121
|
+
"suite_type": "function_call",
|
|
122
|
+
"slug": "boot:openapi-contract-refuses-when-no-key-is-configured"
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten — none derivable: the title and the server URL are the MOUNT's facts (the slug it was handed, the host the request arrived on) and no row carries either — searched the portal's package-manifest rows (scala-portal, scala-portal-ts-dev, portal-ui, portal-shell) for a public base URL and the service_env rows for a configured origin; none carries one, so the reachable address can only come from the request
|
|
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.
|
|
16
|
+
*/
|
|
17
|
+
|
|
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
|
+
import type { OpenApiDocumentOptions } from "@teamscala/orpc/openapi";
|
|
25
|
+
|
|
26
|
+
const FALLBACK_HOST = "localhost";
|
|
27
|
+
const FALLBACK_PROTO = "http";
|
|
28
|
+
|
|
29
|
+
export function openApiContractOptions(
|
|
30
|
+
serviceSlug: string,
|
|
31
|
+
host: string | null,
|
|
32
|
+
forwardedProto: string | null,
|
|
33
|
+
): OpenApiDocumentOptions {
|
|
34
|
+
return {
|
|
35
|
+
title: `${serviceSlug} oRPC procedures`,
|
|
36
|
+
servers: [
|
|
37
|
+
{
|
|
38
|
+
url: `${forwardedProto || FALLBACK_PROTO}://${host || FALLBACK_HOST}/api/rpc`,
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @status generated — ts_function_contract_runner template (scala-test-suites
|
|
3
|
+
* templates/ts_function_contract_runner.pkl @1.3.1) over @teamscala/service-boot's
|
|
4
|
+
* test_vector rows: one `it` per function_call row (args -> value or
|
|
5
|
+
* expect_error) and one per fs_flow row (ordered steps in a temp
|
|
6
|
+
* workspace). This runner carries NO facts: every case is the registry
|
|
7
|
+
* row projected into ./orpc-contract-rows.json. Regenerate from
|
|
8
|
+
* inputs/<repo>/function_contract_runner.json; hand edits are overwritten.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
11
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import projected from "./orpc-contract-rows.json";
|
|
17
|
+
|
|
18
|
+
const ROWS = (projected.rows ?? projected) as Vector[];
|
|
19
|
+
|
|
20
|
+
// This file, and the one row a child process is selecting. Absent in the
|
|
21
|
+
// parent run: every row executes there, and a row with fixtures forks.
|
|
22
|
+
const SELF = import.meta.path;
|
|
23
|
+
const SELECTED = process.env.SCALA_CONTRACT_ROW;
|
|
24
|
+
|
|
25
|
+
if (!Array.isArray(ROWS)) {
|
|
26
|
+
throw new Error("./orpc-contract-rows.json must project an array of rows — the runner executes registry rows, never a hand-written case list");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PKG = "@teamscala/service-boot";
|
|
30
|
+
|
|
31
|
+
// import.meta.dir is this FILE's directory (bun's own value, extension-
|
|
32
|
+
// independent) — row modules are repo-relative, so the package root is the
|
|
33
|
+
// nearest enclosing directory carrying a package.json, walked up: suites
|
|
34
|
+
// render CO-LOCATED at any depth (1.3.0), and a fixed two-levels-up guess
|
|
35
|
+
// resolves src/tier-selector/TierSelector/ suites against src/.
|
|
36
|
+
const ROOT = (() => {
|
|
37
|
+
let dir = import.meta.dir;
|
|
38
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
39
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
40
|
+
dir = join(dir, "..");
|
|
41
|
+
}
|
|
42
|
+
throw new Error("no package.json above " + import.meta.dir + " — a contract suite renders inside a package");
|
|
43
|
+
})();
|
|
44
|
+
|
|
45
|
+
type Vector = Record<string, any> & { suite_type: string; slug: string };
|
|
46
|
+
|
|
47
|
+
// The ONE arg encoding: a declared error value is data, never a literal
|
|
48
|
+
// in the row's JSON — {"$error": {"class", "module"?, "args"}}.
|
|
49
|
+
// A JSON null at an ARG position is ABSENCE, never a value (row-writer
|
|
50
|
+
// convention 2026-09-28 19:50 #1): it decodes to `undefined`, which is what
|
|
51
|
+
// a Rust None and an omitted optional both mean. A null under `expect` is
|
|
52
|
+
// the opposite — a VALUE, asserted with toEqual(null).
|
|
53
|
+
async function decodeValue(value: unknown): Promise<unknown> {
|
|
54
|
+
if (value === null) return undefined;
|
|
55
|
+
if (value !== null && typeof value === "object" && "$error" in (value as object)) {
|
|
56
|
+
const spec = (value as { $error: { class: string; module?: string; args?: unknown[] } }).$error;
|
|
57
|
+
const ctor = spec.module
|
|
58
|
+
? ((await import(spec.module)) as Record<string, any>)[spec.class]
|
|
59
|
+
: (globalThis as Record<string, any>)[spec.class];
|
|
60
|
+
if (typeof ctor !== "function") throw new Error("unconstructible error class: " + spec.class);
|
|
61
|
+
return new ctor(...(spec.args ?? []));
|
|
62
|
+
}
|
|
63
|
+
// A FUNCTION-valued arg: a block export is built from thunks, and JSON
|
|
64
|
+
// cannot spell one. {"$fn": {"returns": <any>}} is the zero-arg thunk
|
|
65
|
+
// returning that value; {"$fn": {"throws": <any>}} is the one raising
|
|
66
|
+
// it. Decoded HERE, at build time, so the closure captures a finished
|
|
67
|
+
// value rather than an unresolved promise.
|
|
68
|
+
if (typeof value === "object" && "$fn" in (value as object)) {
|
|
69
|
+
const spec = (value as { $fn: { returns?: unknown; throws?: unknown } }).$fn;
|
|
70
|
+
if (spec.returns !== undefined) return () => spec.returns;
|
|
71
|
+
if (spec.throws !== undefined) {
|
|
72
|
+
const raised = await decodeValue(spec.throws);
|
|
73
|
+
return () => { throw raised; };
|
|
74
|
+
}
|
|
75
|
+
throw new Error("$fn declares neither returns nor throws — a function whose behaviour is undeclared is a case the runner cannot run");
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(value)) return Promise.all(value.map(decodeValue));
|
|
78
|
+
// Composite args are walked: a $error or $fn nested at a member position is
|
|
79
|
+
// the same encoding one level down, not a literal object that happens to
|
|
80
|
+
// contain one.
|
|
81
|
+
if (typeof value === "object") {
|
|
82
|
+
const walked: Record<string, unknown> = {};
|
|
83
|
+
for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
|
|
84
|
+
walked[key] = await decodeValue(inner);
|
|
85
|
+
}
|
|
86
|
+
return walked;
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Resolve the EXPORT, without calling it: the shape of a class, a frozen
|
|
92
|
+
// table or a namespace is a contract a call cannot state, so this is the
|
|
93
|
+
// half of the pair callExport below cannot do.
|
|
94
|
+
async function loadExport(modulePath: string, exportName: string): Promise<unknown> {
|
|
95
|
+
const mod = (await import(join(ROOT, modulePath))) as Record<string, any>;
|
|
96
|
+
if (!(exportName in mod)) throw new Error("no export " + exportName + " in " + modulePath);
|
|
97
|
+
return mod[exportName];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function callExport(modulePath: string, exportName: string, decodedArgs: unknown[]): Promise<unknown> {
|
|
101
|
+
const fn = await loadExport(modulePath, exportName);
|
|
102
|
+
if (typeof fn !== "function") throw new Error("export " + exportName + " in " + modulePath + " is not a function — a row that wants its shape declares expect.export_shape");
|
|
103
|
+
// Decoding happened at the CALLER, once. Decoding here too would walk an
|
|
104
|
+
// already-decoded $error instance a second time — an Error object has no
|
|
105
|
+
// enumerable own keys, so the walk would collapse it to {} and the row
|
|
106
|
+
// would assert against a value the subject never received.
|
|
107
|
+
return await fn(...decodedArgs);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function applyEnv(env?: Record<string, string>): () => void {
|
|
111
|
+
const before: Record<string, string | undefined> = {};
|
|
112
|
+
for (const [key, value] of Object.entries(env ?? {})) {
|
|
113
|
+
before[key] = process.env[key];
|
|
114
|
+
process.env[key] = value;
|
|
115
|
+
}
|
|
116
|
+
return () => {
|
|
117
|
+
for (const [key, value] of Object.entries(before)) {
|
|
118
|
+
if (value === undefined) delete process.env[key];
|
|
119
|
+
else process.env[key] = value;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function assertWellFormed(row: Vector): void {
|
|
125
|
+
const bad = (msg: string): never => {
|
|
126
|
+
throw new Error("row " + row.slug + " is malformed: " + msg);
|
|
127
|
+
};
|
|
128
|
+
if (PKG !== "*" && row.package !== PKG) bad("package " + JSON.stringify(row.package) + " — expected " + JSON.stringify(PKG));
|
|
129
|
+
if (row.suite_type === "function_call") {
|
|
130
|
+
if (typeof row.export !== "string" || row.export.length === 0) bad("export must be a non-empty string");
|
|
131
|
+
if (row.args === null || row.env === null) bad("args and env must be arrays/objects, never null — null and an absent key are different states");
|
|
132
|
+
if (row.args !== undefined && !Array.isArray(row.args)) bad("args must be a positional array (ruling 18:02 #1)");
|
|
133
|
+
if (row.expect !== undefined && row.expect_error !== undefined) bad("declares both expect and expect_error — the arms are XOR");
|
|
134
|
+
if (row.expect === undefined && row.expect_error === undefined) bad("declares neither expect nor expect_error — a row that asserts nothing asserts nothing");
|
|
135
|
+
// null and an absent key are DIFFERENT states everywhere in a row: expect
|
|
136
|
+
// null is a value (asserted with toEqual(null)), but a null args/env is a
|
|
137
|
+
// malformed row — it is refused, never silently substituted.
|
|
138
|
+
if (row.args === null || row.env === null) bad("args and env must be arrays/objects, never null");
|
|
139
|
+
assertOneAxis("row " + row.slug, row.expect, row.args as unknown[] | undefined, row.expect_error);
|
|
140
|
+
} else if (row.suite_type === "fs_flow") {
|
|
141
|
+
if (!Array.isArray(row.steps) || (row.steps as unknown[]).length === 0) bad("fs_flow needs non-empty ordered steps");
|
|
142
|
+
for (let i = 0; i < (row.steps as unknown[]).length; i++) {
|
|
143
|
+
const step = (row.steps as any[])[i];
|
|
144
|
+
if (typeof step.call !== "string" || step.call.length === 0) bad("steps[" + i + "].call must be a non-empty string");
|
|
145
|
+
if (step.args === null) bad("steps[" + i + "].args must be a positional array, never null — null and an absent key are different states");
|
|
146
|
+
if (step.args !== undefined && !Array.isArray(step.args)) bad("steps[" + i + "].args must be a positional array");
|
|
147
|
+
if (step.expect === undefined && step.expect_error === undefined) bad("steps[" + i + "] asserts nothing");
|
|
148
|
+
assertOneAxis("steps[" + i + "] of row " + row.slug, step.expect, step.args as unknown[] | undefined, step.expect_error);
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
bad("unknown suite_type " + JSON.stringify(row.suite_type));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function expectCall(row: Vector, exportName: string, args: unknown[]): Promise<void> {
|
|
156
|
+
const restore = applyEnv(row.env);
|
|
157
|
+
try {
|
|
158
|
+
await withStubs(row, async () => {
|
|
159
|
+
if (isExportShape(row.expect)) {
|
|
160
|
+
assertStructural(row.slug, await loadExport(row.module, exportName), (row.expect as Record<string, unknown>)[EXPORT_SHAPE]);
|
|
161
|
+
} else if (row.expect_error === undefined) {
|
|
162
|
+
const decoded = await Promise.all(args.map(decodeValue));
|
|
163
|
+
if (isStructural(row.expect) && (row.expect as Record<string, unknown>)[ARG_SHAPE] !== undefined) argTarget(row.slug, row.expect as Record<string, unknown>, decoded);
|
|
164
|
+
const value = await callExport(row.module, exportName, decoded);
|
|
165
|
+
if (isStructural(row.expect)) {
|
|
166
|
+
if ((row.expect as Record<string, unknown>)[ARG_SHAPE] !== undefined) assertArgShape(row.slug, row.expect as Record<string, unknown>, decoded);
|
|
167
|
+
else assertStructural(row.slug, value, row.expect);
|
|
168
|
+
}
|
|
169
|
+
else if (isTextSpec(row.expect)) assertText(row.slug, value, row.expect as Record<string, unknown>);
|
|
170
|
+
else expect(value).toEqual(row.expect);
|
|
171
|
+
} else {
|
|
172
|
+
const raised = callExport(row.module, exportName, await Promise.all(args.map(decodeValue)));
|
|
173
|
+
await expect(raised).rejects.toThrow(row.expect_error);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
} finally {
|
|
177
|
+
restore();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ------------------------------------------------------ structural expect
|
|
182
|
+
// A step names its own target: the live fs_flow rows spell call as
|
|
183
|
+
// "path/to/module.ts:exportName" and the arm admits no row module, so a
|
|
184
|
+
// step that declares no row module is split at its LAST colon. A call that
|
|
185
|
+
// can neither be split nor resolved is refused BY NAME.
|
|
186
|
+
function stepTarget(row: Vector, step: any): [string, string] {
|
|
187
|
+
if (row.module !== undefined && row.module !== null && row.module !== "") return [String(row.module), String(step.call)];
|
|
188
|
+
const spelled = String(step.call);
|
|
189
|
+
const colon = spelled.lastIndexOf(":");
|
|
190
|
+
if (colon <= 0 || colon === spelled.length - 1) {
|
|
191
|
+
throw new Error("row " + row.slug + " is malformed: a step with no row module spells call as <module>:<export>, got " + JSON.stringify(spelled));
|
|
192
|
+
}
|
|
193
|
+
return [spelled.slice(0, colon), spelled.slice(colon + 1)];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const STRUCTURAL_VERBS = ["has_members", "member_is", "member_equals", "reads_resolve"] as const;
|
|
197
|
+
const EXPORT_SHAPE = "export_shape";
|
|
198
|
+
const ARG_SHAPE = "arg_shape";
|
|
199
|
+
const TEXT_VERBS = ["contains_text", "absent", "in_order", "repeated"] as const;
|
|
200
|
+
|
|
201
|
+
function isTextSpec(spec: unknown): boolean {
|
|
202
|
+
return (
|
|
203
|
+
spec !== null &&
|
|
204
|
+
typeof spec === "object" &&
|
|
205
|
+
!Array.isArray(spec) &&
|
|
206
|
+
Object.keys(spec as object).some((key) => (TEXT_VERBS as readonly string[]).includes(key))
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function assertText(slug: string, value: unknown, spec: Record<string, unknown>): void {
|
|
211
|
+
const bad = (msg: string): never => {
|
|
212
|
+
throw new Error("row " + slug + " text expect did not hold: " + msg);
|
|
213
|
+
};
|
|
214
|
+
if (typeof value !== "string") {
|
|
215
|
+
throw new Error("row " + slug + " is malformed: a text expect asserts a returned string, got " + (value === null ? "null" : typeof value));
|
|
216
|
+
}
|
|
217
|
+
const text = value as string;
|
|
218
|
+
for (const verb of Object.keys(spec)) {
|
|
219
|
+
if (!(TEXT_VERBS as readonly string[]).includes(verb)) {
|
|
220
|
+
throw new Error("row " + slug + " is malformed: text expect " + verb + " is not a declared verb " + JSON.stringify(TEXT_VERBS) + " — a predicate this runner does not understand is a case it stopped asserting");
|
|
221
|
+
}
|
|
222
|
+
const needles = spec[verb];
|
|
223
|
+
if (verb === "contains_text") {
|
|
224
|
+
for (const needle of needles as string[]) if (!text.includes(needle)) bad("the text has no " + JSON.stringify(needle));
|
|
225
|
+
} else if (verb === "absent") {
|
|
226
|
+
for (const needle of needles as string[]) if (text.includes(needle)) bad("the text carries " + JSON.stringify(needle));
|
|
227
|
+
} else if (verb === "in_order") {
|
|
228
|
+
let after = -1;
|
|
229
|
+
let previous = "the start";
|
|
230
|
+
for (const marker of needles as string[]) {
|
|
231
|
+
const at = text.indexOf(marker);
|
|
232
|
+
if (at === -1) bad(JSON.stringify(marker) + " is not in the text at all");
|
|
233
|
+
if (at <= after) bad(JSON.stringify(marker) + " is not after " + previous);
|
|
234
|
+
after = at;
|
|
235
|
+
previous = JSON.stringify(marker);
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
const rep = needles as { pattern?: unknown; count?: unknown; group?: unknown };
|
|
239
|
+
if (typeof rep.pattern !== "string" || typeof rep.count !== "number") {
|
|
240
|
+
throw new Error("row " + slug + " is malformed: repeated needs {pattern, count} and an optional group — how many times the pattern matches, and which capture must not drift between the matches");
|
|
241
|
+
}
|
|
242
|
+
let matches: RegExpMatchArray[];
|
|
243
|
+
try {
|
|
244
|
+
matches = [...text.matchAll(new RegExp(rep.pattern, "g"))];
|
|
245
|
+
} catch (err) {
|
|
246
|
+
throw new Error("row " + slug + " is malformed: repeated.pattern is not a usable regex — " + String(err));
|
|
247
|
+
}
|
|
248
|
+
if (matches.length !== rep.count) bad("the pattern matched " + matches.length + " times, not the declared " + rep.count);
|
|
249
|
+
if (rep.group !== undefined) {
|
|
250
|
+
const group = rep.group as number;
|
|
251
|
+
const first = matches[0]?.[group];
|
|
252
|
+
if (first === undefined) bad("capture group " + group + " took no part in the first match");
|
|
253
|
+
for (const match of matches) {
|
|
254
|
+
if (match[group] !== first) bad("capture group " + group + " differs between matches: " + JSON.stringify(first) + " vs " + JSON.stringify(match[group]) + " — the two copies of this text must be the same set");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
function isExportShape(spec: unknown): boolean {
|
|
263
|
+
return (
|
|
264
|
+
spec !== null &&
|
|
265
|
+
typeof spec === "object" &&
|
|
266
|
+
!Array.isArray(spec) &&
|
|
267
|
+
(spec as Record<string, unknown>)[EXPORT_SHAPE] !== undefined
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function assertOneAxis(where: string, spec: unknown, args: unknown[] | undefined, expectError: unknown): void {
|
|
272
|
+
const bad = (msg: string): never => {
|
|
273
|
+
throw new Error(where + " is malformed: " + msg);
|
|
274
|
+
};
|
|
275
|
+
if (isExportShape(spec)) {
|
|
276
|
+
const declared = (spec as Record<string, unknown>)[EXPORT_SHAPE];
|
|
277
|
+
if (declared === null || typeof declared !== "object" || Array.isArray(declared)) bad(EXPORT_SHAPE + " must be a structural spec object");
|
|
278
|
+
if (expectError !== undefined) bad(EXPORT_SHAPE + " with expect_error — no call is made, so nothing is raised to match");
|
|
279
|
+
if (args !== undefined && args.length > 0) bad(EXPORT_SHAPE + " declares args — the export is not called, so nothing receives them");
|
|
280
|
+
for (const verb of Object.keys(spec as object)) {
|
|
281
|
+
if (verb !== EXPORT_SHAPE) bad("expect declares " + verb + " alongside " + EXPORT_SHAPE + " — one row states one axis; a row that wants both is two rows");
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (isRenderRow(spec, args)) {
|
|
286
|
+
if (args !== undefined && args.length > 1) bad("a render row takes at most one arg — the props object, not a positional list");
|
|
287
|
+
if (spec === null || spec === undefined || typeof spec !== "object" || Array.isArray(spec)) return;
|
|
288
|
+
for (const verb of Object.keys(spec as object)) {
|
|
289
|
+
if (verb !== "render") bad("expect declares " + verb + " alongside render — one row states one axis; a row that wants both is two rows");
|
|
290
|
+
}
|
|
291
|
+
const renderSpec = (spec as Record<string, unknown>).render;
|
|
292
|
+
if (renderSpec === null || typeof renderSpec !== "object" || Array.isArray(renderSpec)) bad("render must be a spec object");
|
|
293
|
+
for (const verb of Object.keys(renderSpec as object)) {
|
|
294
|
+
if (!(RENDER_VERBS as readonly string[]).includes(verb)) bad("render " + verb + " is not a declared verb " + JSON.stringify(RENDER_VERBS) + " — a predicate this runner does not understand is a case it stopped asserting");
|
|
295
|
+
}
|
|
296
|
+
const clickSelectors = (renderSpec as Record<string, unknown>).click;
|
|
297
|
+
if (clickSelectors !== undefined) {
|
|
298
|
+
if ((spec as Record<string, unknown>).expect_error !== undefined) bad("click beside expect_error — a row that expects the render to throw has nothing to click");
|
|
299
|
+
const selectors = (Array.isArray(clickSelectors) ? clickSelectors : [clickSelectors]) as unknown[];
|
|
300
|
+
if (selectors.length === 0) bad("click needs at least one selector");
|
|
301
|
+
for (const selector of selectors) {
|
|
302
|
+
if (typeof selector !== "string" || selector.length === 0) bad("click selectors must be non-empty strings, got " + JSON.stringify(selector));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (spec === null || spec === undefined || typeof spec !== "object" || Array.isArray(spec)) return;
|
|
308
|
+
if ((spec as Record<string, unknown>)[ARG_SHAPE] === undefined) return;
|
|
309
|
+
const argSpec = (spec as Record<string, unknown>)[ARG_SHAPE];
|
|
310
|
+
if (argSpec === null || typeof argSpec !== "object" || Array.isArray(argSpec)) bad(ARG_SHAPE + " must be a spec object");
|
|
311
|
+
if (typeof (argSpec as { arg?: unknown }).arg !== "number") bad(ARG_SHAPE + ".arg must be declared — the positional index of the argument the subject mutates");
|
|
312
|
+
for (const verb of Object.keys(spec as object)) {
|
|
313
|
+
if (verb !== ARG_SHAPE) bad("expect declares " + verb + " alongside " + ARG_SHAPE + " — one row states one axis; a row that wants both is two rows");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function isStructural(spec: unknown): boolean {
|
|
318
|
+
return (
|
|
319
|
+
isExportShape(spec) ||
|
|
320
|
+
(spec !== null &&
|
|
321
|
+
typeof spec === "object" &&
|
|
322
|
+
!Array.isArray(spec) &&
|
|
323
|
+
Object.keys(spec as object).some((key) =>
|
|
324
|
+
(STRUCTURAL_VERBS as readonly string[]).includes(key) || key === ARG_SHAPE
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function argTarget(slug: string, spec: Record<string, unknown>, decoded: unknown[]): number {
|
|
331
|
+
const target = (spec[ARG_SHAPE] as { arg?: unknown }).arg;
|
|
332
|
+
if (typeof target !== "number" || !Number.isInteger(target) || target < 0 || target >= decoded.length) {
|
|
333
|
+
throw new Error("row " + slug + " is malformed: " + ARG_SHAPE + ".arg must be an argument position this row declares (0.." + (decoded.length - 1) + "), got " + JSON.stringify(target));
|
|
334
|
+
}
|
|
335
|
+
return target;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function assertArgShape(slug: string, spec: Record<string, unknown>, decoded: unknown[]): void {
|
|
339
|
+
assertStructural(slug, decoded[argTarget(slug, spec, decoded)], spec[ARG_SHAPE], "arg");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function memberAt(value: unknown, path: string): unknown {
|
|
343
|
+
let at: any = value;
|
|
344
|
+
for (const key of path.split(".")) {
|
|
345
|
+
if (at === null || at === undefined) return undefined;
|
|
346
|
+
at = at[key];
|
|
347
|
+
}
|
|
348
|
+
return at;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function assertStructural(slug: string, value: unknown, spec: any, declaration?: string): void {
|
|
352
|
+
for (const verb of Object.keys(spec)) {
|
|
353
|
+
if (verb === declaration) continue;
|
|
354
|
+
if (!(STRUCTURAL_VERBS as readonly string[]).includes(verb)) {
|
|
355
|
+
throw new Error(
|
|
356
|
+
"row " + slug + " is malformed: structural expect " + verb +
|
|
357
|
+
" is not a declared verb " + JSON.stringify(STRUCTURAL_VERBS) +
|
|
358
|
+
" — a predicate this runner does not understand is a case it stopped asserting");
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const bad = (verb: string, detail: string): never => {
|
|
362
|
+
throw new Error("row " + slug + " outcome " + verb + " did not hold: " + detail);
|
|
363
|
+
};
|
|
364
|
+
if (spec.has_members !== undefined) {
|
|
365
|
+
for (const path of spec.has_members as string[]) {
|
|
366
|
+
if (memberAt(value, path) === undefined) bad("has_members", path + " is not declared");
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (spec.member_is !== undefined) {
|
|
370
|
+
for (const [path, kind] of Object.entries(spec.member_is as Record<string, string>)) {
|
|
371
|
+
const got = memberAt(value, path);
|
|
372
|
+
if (got === undefined) bad("member_is", path + " is not declared");
|
|
373
|
+
if (typeof got !== kind) bad("member_is", path + " is " + typeof got + ", the row declares " + kind);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (spec.member_equals !== undefined) {
|
|
377
|
+
for (const [path, want] of Object.entries(spec.member_equals as Record<string, unknown>)) {
|
|
378
|
+
expect(memberAt(value, path)).toEqual(want);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (spec.reads_resolve !== undefined) {
|
|
382
|
+
for (const path of spec.reads_resolve as string[]) {
|
|
383
|
+
try {
|
|
384
|
+
memberAt(value, path);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
bad("reads_resolve", "reading " + path + " threw " + String(err));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const RENDER_VERBS = ["contains", "selector_present", "selector_absent", "click"] as const;
|
|
393
|
+
const ELEMENT_MARKER = "$throw_on_render";
|
|
394
|
+
|
|
395
|
+
function isRender(spec: unknown): boolean {
|
|
396
|
+
return (
|
|
397
|
+
spec !== null &&
|
|
398
|
+
typeof spec === "object" &&
|
|
399
|
+
!Array.isArray(spec) &&
|
|
400
|
+
(spec as Record<string, unknown>).render !== undefined
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function hasMarker(value: unknown): boolean {
|
|
405
|
+
if (Array.isArray(value)) return value.some(hasMarker);
|
|
406
|
+
if (value !== null && typeof value === "object") {
|
|
407
|
+
if (Object.keys(value as object).includes(ELEMENT_MARKER)) return true;
|
|
408
|
+
for (const key of Object.keys(value as object)) {
|
|
409
|
+
if (hasMarker((value as Record<string, unknown>)[key])) return true;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function isRenderRow(spec: unknown, args: unknown[] | undefined): boolean {
|
|
416
|
+
return isRender(spec) || (args !== undefined && args.some(hasMarker));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// The ONE element encoding: {"$throw_on_render": "<message>"} anywhere in
|
|
420
|
+
// the props tree becomes a component whose render raises that message — the
|
|
421
|
+
// throwing child a recovery contract is ABOUT. Anything else starting with $
|
|
422
|
+
// inside a render tree is refused: a marker this runner does not know would
|
|
423
|
+
// otherwise render as inert data and a case would silently stop asserting.
|
|
424
|
+
function buildElements(React: any, value: unknown, slug: string): unknown {
|
|
425
|
+
if (Array.isArray(value)) return value.map((item, index) => {
|
|
426
|
+
const builtItem = buildElements(React, item, slug);
|
|
427
|
+
if (builtItem !== null && typeof builtItem === "object" && (builtItem as Record<string, unknown>).$$typeof !== undefined) return React.cloneElement(builtItem, { key: String(index) });
|
|
428
|
+
return builtItem;
|
|
429
|
+
});
|
|
430
|
+
if (value !== null && typeof value === "object") {
|
|
431
|
+
const record = value as Record<string, unknown>;
|
|
432
|
+
if (Object.keys(record).includes(ELEMENT_MARKER)) {
|
|
433
|
+
if (typeof record[ELEMENT_MARKER] !== "string") throw new Error("row " + slug + " is malformed: " + ELEMENT_MARKER + " takes the error message string, got " + JSON.stringify(record[ELEMENT_MARKER]));
|
|
434
|
+
const message = record[ELEMENT_MARKER] as string;
|
|
435
|
+
return React.createElement(function Thrower(): never {
|
|
436
|
+
throw new Error(message);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
const built: Record<string, unknown> = {};
|
|
440
|
+
for (const key of Object.keys(record)) {
|
|
441
|
+
if (key.startsWith("$")) throw new Error("row " + slug + " is malformed: element marker " + key + " is not declared — a marker this runner does not know is a child that would render as inert data");
|
|
442
|
+
built[key] = buildElements(React, record[key], slug);
|
|
443
|
+
}
|
|
444
|
+
return built;
|
|
445
|
+
}
|
|
446
|
+
return value;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function assertRenderSpec(slug: string, container: HTMLElement, spec: any): void {
|
|
450
|
+
const text = container.textContent ?? "";
|
|
451
|
+
const bad = (verb: string, detail: string): never => {
|
|
452
|
+
throw new Error("row " + slug + " outcome " + verb + " did not hold: " + detail);
|
|
453
|
+
};
|
|
454
|
+
for (const needle of (spec.contains ?? []) as string[]) {
|
|
455
|
+
if (!text.includes(needle)) bad("contains", "the rendered text has no " + JSON.stringify(needle));
|
|
456
|
+
}
|
|
457
|
+
for (const selector of (spec.selector_present ?? []) as string[]) {
|
|
458
|
+
if (container.querySelector(selector) === null) bad("selector_present", "no element matches " + selector);
|
|
459
|
+
}
|
|
460
|
+
for (const selector of (spec.selector_absent ?? []) as string[]) {
|
|
461
|
+
if (container.querySelector(selector) !== null) bad("selector_absent", selector + " is present");
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function renderRow(row: Vector): Promise<void> {
|
|
466
|
+
const restore = applyEnv(row.env);
|
|
467
|
+
try {
|
|
468
|
+
await withStubs(row, async () => {
|
|
469
|
+
if (!globalThis.document) {
|
|
470
|
+
const { GlobalRegistrator } = await import("@happy-dom/global-registrator");
|
|
471
|
+
await GlobalRegistrator.register({ url: "http://localhost" });
|
|
472
|
+
}
|
|
473
|
+
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
474
|
+
const React = await import("react");
|
|
475
|
+
const { createRoot } = await import("react-dom/client");
|
|
476
|
+
const act = (React as unknown as { act: (run: () => Promise<void>) => Promise<void> }).act;
|
|
477
|
+
const Component = await loadExport(row.module, row.export);
|
|
478
|
+
if (typeof Component !== "function") throw new Error("export " + row.export + " in " + row.module + " is not a component — a render row needs a callable component export");
|
|
479
|
+
const decoded = await Promise.all(((row.args ?? []) as unknown[]).map(decodeValue));
|
|
480
|
+
const props = decoded.length === 0 ? {} : buildElements(React, decoded[0], row.slug);
|
|
481
|
+
const host = document.createElement("div");
|
|
482
|
+
document.body.appendChild(host);
|
|
483
|
+
const root = createRoot(host);
|
|
484
|
+
let caught: unknown;
|
|
485
|
+
try {
|
|
486
|
+
await act(async () => {
|
|
487
|
+
root.render(React.createElement(Component, props as Record<string, unknown>));
|
|
488
|
+
});
|
|
489
|
+
} catch (err) {
|
|
490
|
+
caught = err;
|
|
491
|
+
}
|
|
492
|
+
if (row.expect_error !== undefined) {
|
|
493
|
+
if (caught === undefined) throw new Error("row " + row.slug + " expected the render to throw " + JSON.stringify(row.expect_error) + ", and it returned a tree");
|
|
494
|
+
const message = caught instanceof Error ? caught.message : String(caught);
|
|
495
|
+
if (!message.includes(String(row.expect_error))) throw new Error("row " + row.slug + " outcome expect_error did not hold: the render threw " + JSON.stringify(message));
|
|
496
|
+
await act(async () => { root.unmount(); });
|
|
497
|
+
host.remove();
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (caught !== undefined) throw caught instanceof Error ? caught : new Error(String(caught));
|
|
501
|
+
const clickSelectors = ((row.expect as Record<string, unknown>).render as Record<string, unknown>).click;
|
|
502
|
+
if (clickSelectors !== undefined) {
|
|
503
|
+
for (const selector of (Array.isArray(clickSelectors) ? clickSelectors : [clickSelectors]) as string[]) {
|
|
504
|
+
const target = host.querySelector(selector);
|
|
505
|
+
if (target === null) throw new Error("row " + row.slug + " outcome click did not hold: no element matches " + selector);
|
|
506
|
+
await act(async () => {
|
|
507
|
+
target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
assertRenderSpec(row.slug, host, (row.expect as Record<string, unknown>).render);
|
|
512
|
+
await act(async () => { root.unmount(); });
|
|
513
|
+
host.remove();
|
|
514
|
+
});
|
|
515
|
+
} finally {
|
|
516
|
+
restore();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
function runFsFlow(row: Vector): Promise<void> {
|
|
520
|
+
return (async () => {
|
|
521
|
+
const workspace = await mkdtemp(join(tmpdir(), "contract-"));
|
|
522
|
+
const before = process.env.SCALA_WORKSPACE_ROOT;
|
|
523
|
+
process.env.SCALA_WORKSPACE_ROOT = workspace;
|
|
524
|
+
try {
|
|
525
|
+
await withStubs(row, async () => {
|
|
526
|
+
for (const step of row.steps as Vector[]) {
|
|
527
|
+
const [modPath, stepExport] = stepTarget(row, step);
|
|
528
|
+
const args = await Promise.all((step.args ?? []).map(decodeValue));
|
|
529
|
+
if (isExportShape(step.expect)) {
|
|
530
|
+
assertStructural(row.slug, await loadExport(modPath, stepExport), (step.expect as Record<string, unknown>)[EXPORT_SHAPE]);
|
|
531
|
+
} else if (step.expect_error !== undefined) {
|
|
532
|
+
await expect(callExport(modPath, stepExport, args)).rejects.toThrow(step.expect_error);
|
|
533
|
+
} else {
|
|
534
|
+
if (isStructural(step.expect) && (step.expect as Record<string, unknown>)[ARG_SHAPE] !== undefined) argTarget(row.slug, step.expect as Record<string, unknown>, args);
|
|
535
|
+
const value = await callExport(modPath, stepExport, args);
|
|
536
|
+
if (isStructural(step.expect)) {
|
|
537
|
+
if ((step.expect as Record<string, unknown>)[ARG_SHAPE] !== undefined) assertArgShape(row.slug, step.expect as Record<string, unknown>, args);
|
|
538
|
+
else assertStructural(row.slug, value, step.expect);
|
|
539
|
+
}
|
|
540
|
+
else if (isTextSpec(step.expect)) assertText(row.slug, value, step.expect as Record<string, unknown>);
|
|
541
|
+
else expect(value).toEqual(step.expect);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
} finally {
|
|
546
|
+
if (before === undefined) delete process.env.SCALA_WORKSPACE_ROOT;
|
|
547
|
+
else process.env.SCALA_WORKSPACE_ROOT = before;
|
|
548
|
+
await rm(workspace, { recursive: true, force: true });
|
|
549
|
+
}
|
|
550
|
+
})();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
// ------------------------------------------------------------- fixtures
|
|
555
|
+
// Stubs, recorded outcomes and seeded state are three different facts. This
|
|
556
|
+
// runner carries the FIRST two; seeded state is the db_flow arm's business.
|
|
557
|
+
//
|
|
558
|
+
// The CLOSED outcome vocabulary. A key outside it is refused by name: a
|
|
559
|
+
// predicate the runner does not understand is a row whose case silently
|
|
560
|
+
// stopped being asserted, which is the one failure this wave cannot have.
|
|
561
|
+
const OUTCOME_VERBS = ["count", "equals", "contains", "absent", "sql_contains", "sql_absent", "binds"] as const;
|
|
562
|
+
|
|
563
|
+
type Stub = Vector & { module: string; export: string; record?: string; returns?: unknown; error?: unknown; when_sql_contains?: string };
|
|
564
|
+
type Call = { args: unknown[] };
|
|
565
|
+
|
|
566
|
+
// A stub's `module` is the SUBJECT'S OWN IMPORT SPECIFIER — what the
|
|
567
|
+
// subject wrote in its import line — so it resolves against the subject
|
|
568
|
+
// file's directory, not the package root.
|
|
569
|
+
function stubPath(row: Vector, stub: Stub): string {
|
|
570
|
+
return resolve(dirname(resolve(ROOT, String(row.module))), String(stub.module));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// A stub answers with the DATA the row declared, decoded SYNCHRONOUSLY: a
|
|
574
|
+
// subject may call its dependency without awaiting it, and an async stub
|
|
575
|
+
// hands that subject a Promise whose members are undefined — the row then
|
|
576
|
+
// asserts against a value no row declared. Awaiting a plain value is the
|
|
577
|
+
// value, so an async subject is unaffected. A stub return is a VALUE
|
|
578
|
+
// position: null is null here, the opposite of an ARG position.
|
|
579
|
+
function decodeStubReturn(value: unknown): unknown {
|
|
580
|
+
if (Array.isArray(value)) return value.map(decodeStubReturn);
|
|
581
|
+
if (typeof value === "object" && value !== null) {
|
|
582
|
+
if ("$fn" in value) {
|
|
583
|
+
// A stubbed export may RETURN a contract whose members are FUNCTIONS —
|
|
584
|
+
// an injected page client (getBrandColors: () => Record) is the measured
|
|
585
|
+
// shape. {"$fn": {"returns": X}} decodes to the same SYNC zero-arg thunk
|
|
586
|
+
// the arg side builds; a function member declared as plain data would be
|
|
587
|
+
// called by the subject and fail as "not a function" against a stub that
|
|
588
|
+
// answered with an inert object.
|
|
589
|
+
const spec = (value as { $fn: { returns?: unknown; throws?: unknown } }).$fn;
|
|
590
|
+
if (spec.returns !== undefined) return () => decodeStubReturn(spec.returns);
|
|
591
|
+
if (spec.throws !== undefined) {
|
|
592
|
+
const raised = decodeStubReturn(spec.throws);
|
|
593
|
+
return () => {
|
|
594
|
+
throw raised;
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
throw new Error("a fixture stub return declares $fn with neither returns nor throws — a function whose behaviour is undeclared is a case the runner cannot run");
|
|
598
|
+
}
|
|
599
|
+
if ("$error" in value) {
|
|
600
|
+
throw new Error("a fixture stub return declares $error — a stub's failure is the stub-level error key; a return is what the call answers with");
|
|
601
|
+
}
|
|
602
|
+
const walked: Record<string, unknown> = {};
|
|
603
|
+
for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
|
|
604
|
+
walked[key] = decodeStubReturn(inner);
|
|
605
|
+
}
|
|
606
|
+
return walked;
|
|
607
|
+
}
|
|
608
|
+
return value;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Every stub sharing an export forms ONE group: a Db stub answers per SQL
|
|
612
|
+
// shape, and the first shape whose needle the call's SQL satisfies wins. A
|
|
613
|
+
// call matching no declared shape REFUSES — guessing an answer no row
|
|
614
|
+
// declared is how a test asserts something that was never true.
|
|
615
|
+
function stubReturn(slug: string, group: Stub[], call: Call): unknown {
|
|
616
|
+
const sql = typeof call.args[0] === "string" ? (call.args[0] as string) : "";
|
|
617
|
+
for (const stub of group) {
|
|
618
|
+
if (stub.when_sql_contains !== undefined && !sql.includes(String(stub.when_sql_contains))) continue;
|
|
619
|
+
if (stub.error !== undefined) throw stub.error;
|
|
620
|
+
return stub.returns;
|
|
621
|
+
}
|
|
622
|
+
if (group.every((stub) => stub.when_sql_contains !== undefined)) {
|
|
623
|
+
throw new Error(
|
|
624
|
+
`row ${slug} is malformed: no fixture stub matches the SQL shape ${JSON.stringify(sql.slice(0, 120))} — ` +
|
|
625
|
+
"declared shapes: " + group.map((stub) => JSON.stringify(stub.when_sql_contains)).join(", "));
|
|
626
|
+
}
|
|
627
|
+
return group[0].returns;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Applies the row's stubs, runs the body, ASSERTS the recording on the
|
|
631
|
+
// success path, then RESTORES every mocked module with the exports it had —
|
|
632
|
+
// a stub that outlived its row would answer the rows after it with a value
|
|
633
|
+
// they never declared. Outcomes are NOT asserted when the body itself
|
|
634
|
+
// threw: the row already failed for a more specific reason than any
|
|
635
|
+
// predicate over a call that may never have completed.
|
|
636
|
+
async function withStubs<T>(row: Vector, fn: () => Promise<T>): Promise<T> {
|
|
637
|
+
const stubs = (row.fixtures?.stubs ?? []) as Stub[];
|
|
638
|
+
if (stubs.length === 0) return fn();
|
|
639
|
+
const slug = row.slug;
|
|
640
|
+
const recorders: Record<string, Call[]> = {};
|
|
641
|
+
for (const stub of stubs) {
|
|
642
|
+
if (stub.record !== undefined) (recorders[stub.record] ??= []);
|
|
643
|
+
}
|
|
644
|
+
const paths = [...new Set(stubs.map((stub) => stubPath(row, stub)))];
|
|
645
|
+
for (const path of paths) {
|
|
646
|
+
const group = stubs.filter((stub) => stubPath(row, stub) === path);
|
|
647
|
+
const exported: Record<string, unknown> = {};
|
|
648
|
+
for (const name of new Set(group.map((stub) => String(stub.export)))) {
|
|
649
|
+
const arms = group.filter((stub) => String(stub.export) === name);
|
|
650
|
+
exported[name] = (...args: unknown[]) => {
|
|
651
|
+
const call: Call = { args };
|
|
652
|
+
for (const arm of arms) {
|
|
653
|
+
if (arm.record !== undefined) (recorders[arm.record] ??= []).push(call);
|
|
654
|
+
}
|
|
655
|
+
return decodeStubReturn(stubReturn(slug, arms, call));
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
mock.module(path, () => exported);
|
|
659
|
+
}
|
|
660
|
+
const result = await fn();
|
|
661
|
+
returnOutcome(slug, recorders, row.outcomes as Record<string, Vector[]> | undefined);
|
|
662
|
+
return result;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// A STUB LIVES FOR THE WHOLE PROCESS — measured on bun 1.4.2, not assumed:
|
|
666
|
+
// mock.module(path, () => original) leaves the mock installed,
|
|
667
|
+
// mock.restore() does not restore module mocks, and the live namespace is
|
|
668
|
+
// readonly, so there is no in-process teardown that undoes a module mock.
|
|
669
|
+
// The honest consequence: a row with fixtures runs in its OWN PROCESS (
|
|
670
|
+
// runIsolated below), so no stub can answer a row that never declared it.
|
|
671
|
+
function hasFixtures(row: Vector): boolean {
|
|
672
|
+
return Array.isArray(row.fixtures?.stubs) && (row.fixtures!.stubs as Stub[]).length > 0;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Re-runs THIS FILE under `bun test` selecting only this row, in a fresh
|
|
676
|
+
// process. The child's exit code is the verdict and its output is the
|
|
677
|
+
// evidence, so a refusal inside the child (an undeclared SQL shape, an
|
|
678
|
+
// outcome verb the runner does not know) surfaces verbatim in the parent.
|
|
679
|
+
function runIsolated(row: Vector): void {
|
|
680
|
+
const child = spawnSync("bun", ["test", SELF, "--test-name-pattern", row.slug], {
|
|
681
|
+
cwd: ROOT,
|
|
682
|
+
env: { ...process.env, SCALA_CONTRACT_ROW: row.slug },
|
|
683
|
+
encoding: "utf8",
|
|
684
|
+
});
|
|
685
|
+
if (child.status !== 0) {
|
|
686
|
+
const evidence = (child.stderr || child.stdout || "").trimEnd().split("\n").slice(-14).join("\n");
|
|
687
|
+
throw new Error(`row ${row.slug} failed in its isolated run:\n${evidence}`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ONE predicate vocabulary over what the stubs recorded: count is the number
|
|
692
|
+
// of calls; equals is the whole recording deep-equal to the declared value;
|
|
693
|
+
// contains/absent are substrings of it; sql_contains/sql_absent are over the
|
|
694
|
+
// recorded SQL; binds is the binds argument of the selected call (the one
|
|
695
|
+
// sql_contains names, or the first call when no SQL predicate is declared),
|
|
696
|
+
// deep-equal to the declared array. Every declared verb is checked — one the
|
|
697
|
+
// runner does not know is a refusal, never a skip.
|
|
698
|
+
function returnOutcome(slug: string, recorders: Record<string, Call[]>, outcomes?: Record<string, Vector[]>): void {
|
|
699
|
+
if (outcomes === undefined) return;
|
|
700
|
+
for (const [recorder, predicates] of Object.entries(outcomes)) {
|
|
701
|
+
const calls = recorders[recorder] ?? [];
|
|
702
|
+
for (const predicate of predicates) {
|
|
703
|
+
for (const verb of Object.keys(predicate)) {
|
|
704
|
+
if (!(OUTCOME_VERBS as readonly string[]).includes(verb)) {
|
|
705
|
+
throw new Error(
|
|
706
|
+
`row ${slug} is malformed: outcome ${recorder}.${verb} is not a declared verb ` +
|
|
707
|
+
JSON.stringify(OUTCOME_VERBS) + " — a predicate this runner does not understand is a case it stopped asserting");
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
const say = (verb: string): never => {
|
|
711
|
+
throw new Error(`row ${slug} outcome ${recorder}.${verb} did not hold`);
|
|
712
|
+
};
|
|
713
|
+
if (predicate.count !== undefined && calls.length !== Number(predicate.count)) say("count");
|
|
714
|
+
if (predicate.sql_absent !== undefined) {
|
|
715
|
+
for (const call of calls) {
|
|
716
|
+
if (typeof call.args[0] === "string" && (call.args[0] as string).includes(String(predicate.sql_absent))) say("sql_absent");
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
const text = JSON.stringify(calls);
|
|
720
|
+
if (predicate.contains !== undefined && !text.includes(String(predicate.contains))) say("contains");
|
|
721
|
+
if (predicate.absent !== undefined && text.includes(String(predicate.absent))) say("absent");
|
|
722
|
+
if (predicate.equals !== undefined && text !== JSON.stringify(predicate.equals)) say("equals");
|
|
723
|
+
if (predicate.sql_contains !== undefined && !calls.some((call) => typeof call.args[0] === "string" && (call.args[0] as string).includes(String(predicate.sql_contains)))) say("sql_contains");
|
|
724
|
+
if (predicate.binds !== undefined) {
|
|
725
|
+
const selected = predicate.sql_contains === undefined
|
|
726
|
+
? calls[0]
|
|
727
|
+
: calls.find((call) => typeof call.args[0] === "string" && (call.args[0] as string).includes(String(predicate.sql_contains)));
|
|
728
|
+
if (selected === undefined) say("binds");
|
|
729
|
+
const binds = selected.args.find((arg) => Array.isArray(arg));
|
|
730
|
+
if (JSON.stringify(binds) !== JSON.stringify(predicate.binds)) say("binds");
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
function runRow(row: Vector): Promise<void> | void {
|
|
736
|
+
// Refused BY NAME before any call: the failing `it` is the row's slug.
|
|
737
|
+
assertWellFormed(row);
|
|
738
|
+
if (SELECTED === undefined && hasFixtures(row)) return runIsolated(row);
|
|
739
|
+
if (isRenderRow(row.expect, row.args as unknown[] | undefined)) return renderRow(row);
|
|
740
|
+
if (row.suite_type === "fs_flow") return runFsFlow(row);
|
|
741
|
+
return expectCall(row, row.export, row.args ?? []);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
describe("@teamscala/service-boot function_call contracts", () => {
|
|
745
|
+
for (const row of ROWS) {
|
|
746
|
+
if (SELECTED !== undefined && row.slug !== SELECTED) continue;
|
|
747
|
+
it(row.slug, async () => {
|
|
748
|
+
await runRow(row);
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten — none derivable: the gate is a REJECTION RULE over four request facts, and no row states a rule — searched the service-boot auth surfaces (auth-gate.ts, auth/, content-gate/, gpt-gate.ts, bind-page-handler.ts) for an existing static-key check to call, and public-paths.ts names exempted PATHS, never a verifier
|
|
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.
|
|
16
|
+
*/
|
|
17
|
+
|
|
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
|
+
export interface OpenApiContractRequest {
|
|
24
|
+
/** The raw `authorization` header, absent when the caller sent none. */
|
|
25
|
+
authorization?: string | null;
|
|
26
|
+
/** The configured `SCALA_DEV_KEY`, absent when the service has none. */
|
|
27
|
+
key?: string | null;
|
|
28
|
+
/** `x-forwarded-proto`. Its presence means the request was proxied. */
|
|
29
|
+
forwardedProto?: string | null;
|
|
30
|
+
/** `x-forwarded-for`. Same meaning, checked so neither alone is load-bearing. */
|
|
31
|
+
forwardedFor?: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True only for an unproxied request presenting exactly the configured key. */
|
|
35
|
+
export function openApiContractAuthorised(request: OpenApiContractRequest): boolean {
|
|
36
|
+
if (!request.key) return false;
|
|
37
|
+
if (request.forwardedProto || request.forwardedFor) return false;
|
|
38
|
+
const header = request.authorization;
|
|
39
|
+
if (!header?.startsWith("Bearer ")) return false;
|
|
40
|
+
return header.slice("Bearer ".length) === request.key;
|
|
41
|
+
}
|