@lovrozagar/oat 0.10.0 → 0.12.0
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/README.md +25 -2006
- package/dist/cli.js +283 -59
- package/dist/config/define-config.d.ts +8 -0
- package/dist/config/define-config.d.ts.map +1 -1
- package/dist/config/define-config.js.map +1 -1
- package/dist/config/validate.d.ts +12 -0
- package/dist/config/validate.d.ts.map +1 -0
- package/dist/config/validate.js +86 -0
- package/dist/config/validate.js.map +1 -0
- package/dist/conformance/effects.d.ts +9 -0
- package/dist/conformance/effects.d.ts.map +1 -0
- package/dist/conformance/effects.js +276 -0
- package/dist/conformance/effects.js.map +1 -0
- package/dist/conformance/expected-failures.d.ts +18 -0
- package/dist/conformance/expected-failures.d.ts.map +1 -0
- package/dist/conformance/expected-failures.js +48 -0
- package/dist/conformance/expected-failures.js.map +1 -0
- package/dist/conformance/feature-gate.d.ts +7 -0
- package/dist/conformance/feature-gate.d.ts.map +1 -0
- package/dist/conformance/feature-gate.js +428 -0
- package/dist/conformance/feature-gate.js.map +1 -0
- package/dist/conformance/fuzz.d.ts +108 -0
- package/dist/conformance/fuzz.d.ts.map +1 -0
- package/dist/conformance/fuzz.js +246 -0
- package/dist/conformance/fuzz.js.map +1 -0
- package/dist/conformance/leaks.d.ts +10 -0
- package/dist/conformance/leaks.d.ts.map +1 -0
- package/dist/conformance/leaks.js +23 -0
- package/dist/conformance/leaks.js.map +1 -0
- package/dist/conformance/pool.d.ts +49 -0
- package/dist/conformance/pool.d.ts.map +1 -0
- package/dist/conformance/pool.js +75 -0
- package/dist/conformance/pool.js.map +1 -0
- package/dist/conformance/rate-limit.d.ts +7 -0
- package/dist/conformance/rate-limit.d.ts.map +1 -0
- package/dist/conformance/rate-limit.js +372 -0
- package/dist/conformance/rate-limit.js.map +1 -0
- package/dist/conformance/scope.d.ts +15 -0
- package/dist/conformance/scope.d.ts.map +1 -0
- package/dist/conformance/scope.js +78 -0
- package/dist/conformance/scope.js.map +1 -0
- package/dist/conformance/seed-contract.d.ts +11 -0
- package/dist/conformance/seed-contract.d.ts.map +1 -0
- package/dist/conformance/seed-contract.js +680 -0
- package/dist/conformance/seed-contract.js.map +1 -0
- package/dist/conformance/shapes.d.ts +35 -0
- package/dist/conformance/shapes.d.ts.map +1 -0
- package/dist/conformance/shapes.js +162 -0
- package/dist/conformance/shapes.js.map +1 -0
- package/dist/conformance/specs.d.ts +21 -0
- package/dist/conformance/specs.d.ts.map +1 -0
- package/dist/conformance/specs.js +306 -0
- package/dist/conformance/specs.js.map +1 -0
- package/dist/conformance/suite.d.ts +209 -0
- package/dist/conformance/suite.d.ts.map +1 -0
- package/dist/conformance/suite.js +1538 -0
- package/dist/conformance/suite.js.map +1 -0
- package/dist/conformance/unique.d.ts +7 -0
- package/dist/conformance/unique.d.ts.map +1 -0
- package/dist/conformance/unique.js +586 -0
- package/dist/conformance/unique.js.map +1 -0
- package/dist/conformance/worker.d.ts +3 -0
- package/dist/conformance/worker.d.ts.map +1 -0
- package/dist/conformance/worker.js +27 -0
- package/dist/conformance/worker.js.map +1 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.js +5 -2
- package/dist/reference/defects.d.ts +71 -1
- package/dist/reference/defects.d.ts.map +1 -1
- package/dist/reference/defects.js +74 -8
- package/dist/reference/defects.js.map +1 -1
- package/dist/reference/dialect.d.ts +6 -4
- package/dist/reference/dialect.d.ts.map +1 -1
- package/dist/reference/dialect.js +1 -0
- package/dist/reference/dialect.js.map +1 -1
- package/dist/reference/http.d.ts +17 -25
- package/dist/reference/http.d.ts.map +1 -1
- package/dist/reference/http.js +574 -224
- package/dist/reference/http.js.map +1 -1
- package/dist/reference/listing.d.ts +51 -0
- package/dist/reference/listing.d.ts.map +1 -0
- package/dist/reference/listing.js +256 -0
- package/dist/reference/listing.js.map +1 -0
- package/dist/reference/model.d.ts +35 -1
- package/dist/reference/model.d.ts.map +1 -1
- package/dist/reference/model.js +42 -0
- package/dist/reference/model.js.map +1 -1
- package/dist/reference/query.d.ts +132 -43
- package/dist/reference/query.d.ts.map +1 -1
- package/dist/reference/query.js +397 -315
- package/dist/reference/query.js.map +1 -1
- package/dist/reference/shapes.d.ts +60 -0
- package/dist/reference/shapes.d.ts.map +1 -0
- package/dist/reference/shapes.js +142 -0
- package/dist/reference/shapes.js.map +1 -0
- package/dist/reference/spec.d.ts +9 -2
- package/dist/reference/spec.d.ts.map +1 -1
- package/dist/reference/spec.js +291 -71
- package/dist/reference/spec.js.map +1 -1
- package/dist/reference/store-api.d.ts +14 -43
- package/dist/reference/store-api.d.ts.map +1 -1
- package/dist/reference/store-api.js +8 -58
- package/dist/reference/store-api.js.map +1 -1
- package/dist/reference/stores/memory.d.ts +12 -15
- package/dist/reference/stores/memory.d.ts.map +1 -1
- package/dist/reference/stores/memory.js +22 -70
- package/dist/reference/stores/memory.js.map +1 -1
- package/dist/reference/stores/postgres.d.ts +11 -23
- package/dist/reference/stores/postgres.d.ts.map +1 -1
- package/dist/reference/stores/postgres.js +53 -334
- package/dist/reference/stores/postgres.js.map +1 -1
- package/dist/reference/stores/sql-compile.d.ts +36 -0
- package/dist/reference/stores/sql-compile.d.ts.map +1 -0
- package/dist/reference/stores/sql-compile.js +95 -0
- package/dist/reference/stores/sql-compile.js.map +1 -0
- package/dist/reference/stores/sqlite.d.ts +12 -27
- package/dist/reference/stores/sqlite.d.ts.map +1 -1
- package/dist/reference/stores/sqlite.js +54 -355
- package/dist/reference/stores/sqlite.js.map +1 -1
- package/dist/report/console.d.ts.map +1 -1
- package/dist/report/console.js +23 -9
- package/dist/report/console.js.map +1 -1
- package/dist/report/matrix.d.ts +2 -0
- package/dist/report/matrix.d.ts.map +1 -1
- package/dist/report/matrix.js +63 -10
- package/dist/report/matrix.js.map +1 -1
- package/dist/report/render.d.ts +10 -1
- package/dist/report/render.d.ts.map +1 -1
- package/dist/report/render.js +75 -13
- package/dist/report/render.js.map +1 -1
- package/dist/runtime/async.d.ts +16 -3
- package/dist/runtime/async.d.ts.map +1 -1
- package/dist/runtime/async.js +24 -19
- package/dist/runtime/async.js.map +1 -1
- package/dist/runtime/auth.d.ts +8 -9
- package/dist/runtime/auth.d.ts.map +1 -1
- package/dist/runtime/auth.js +60 -62
- package/dist/runtime/auth.js.map +1 -1
- package/dist/runtime/body.d.ts +15 -3
- package/dist/runtime/body.d.ts.map +1 -1
- package/dist/runtime/body.js +61 -12
- package/dist/runtime/body.js.map +1 -1
- package/dist/runtime/check-costs.d.ts +15 -0
- package/dist/runtime/check-costs.d.ts.map +1 -0
- package/dist/runtime/check-costs.js +114 -0
- package/dist/runtime/check-costs.js.map +1 -0
- package/dist/runtime/check-ids.d.ts +15 -0
- package/dist/runtime/check-ids.d.ts.map +1 -0
- package/dist/runtime/check-ids.js +140 -0
- package/dist/runtime/check-ids.js.map +1 -0
- package/dist/runtime/checks.d.ts +88 -20
- package/dist/runtime/checks.d.ts.map +1 -1
- package/dist/runtime/checks.js +3288 -1947
- package/dist/runtime/checks.js.map +1 -1
- package/dist/runtime/client.d.ts +112 -0
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js +271 -39
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/cookies.d.ts +16 -2
- package/dist/runtime/cookies.d.ts.map +1 -1
- package/dist/runtime/cookies.js +83 -22
- package/dist/runtime/cookies.js.map +1 -1
- package/dist/runtime/exchanges.d.ts +4 -5
- package/dist/runtime/exchanges.d.ts.map +1 -1
- package/dist/runtime/exchanges.js +33 -55
- package/dist/runtime/exchanges.js.map +1 -1
- package/dist/runtime/exit.d.ts +40 -0
- package/dist/runtime/exit.d.ts.map +1 -0
- package/dist/runtime/exit.js +48 -0
- package/dist/runtime/exit.js.map +1 -0
- package/dist/runtime/feature-gate.d.ts +0 -9
- package/dist/runtime/feature-gate.d.ts.map +1 -1
- package/dist/runtime/feature-gate.js +0 -21
- package/dist/runtime/feature-gate.js.map +1 -1
- package/dist/runtime/finding.d.ts +49 -8
- package/dist/runtime/finding.d.ts.map +1 -1
- package/dist/runtime/finding.js +51 -8
- package/dist/runtime/finding.js.map +1 -1
- package/dist/runtime/fixture.d.ts +35 -28
- package/dist/runtime/fixture.d.ts.map +1 -1
- package/dist/runtime/fixture.js +92 -731
- package/dist/runtime/fixture.js.map +1 -1
- package/dist/runtime/generate.d.ts +63 -0
- package/dist/runtime/generate.d.ts.map +1 -0
- package/dist/runtime/generate.js +635 -0
- package/dist/runtime/generate.js.map +1 -0
- package/dist/runtime/headers.d.ts +6 -0
- package/dist/runtime/headers.d.ts.map +1 -0
- package/dist/runtime/headers.js +16 -0
- package/dist/runtime/headers.js.map +1 -0
- package/dist/runtime/path.d.ts +7 -0
- package/dist/runtime/path.d.ts.map +1 -0
- package/dist/runtime/path.js +44 -0
- package/dist/runtime/path.js.map +1 -0
- package/dist/runtime/payloads.d.ts +2 -0
- package/dist/runtime/payloads.d.ts.map +1 -1
- package/dist/runtime/payloads.js +2 -1
- package/dist/runtime/payloads.js.map +1 -1
- package/dist/runtime/rate-limit.d.ts +6 -4
- package/dist/runtime/rate-limit.d.ts.map +1 -1
- package/dist/runtime/rate-limit.js +22 -33
- package/dist/runtime/rate-limit.js.map +1 -1
- package/dist/runtime/redact.d.ts +24 -0
- package/dist/runtime/redact.d.ts.map +1 -0
- package/dist/runtime/redact.js +98 -0
- package/dist/runtime/redact.js.map +1 -0
- package/dist/runtime/run.d.ts +34 -2
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js +374 -138
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/runs.d.ts +5 -0
- package/dist/runtime/runs.d.ts.map +1 -1
- package/dist/runtime/runs.js +7 -1
- package/dist/runtime/runs.js.map +1 -1
- package/dist/runtime/teardown.d.ts +17 -4
- package/dist/runtime/teardown.d.ts.map +1 -1
- package/dist/runtime/teardown.js +49 -16
- package/dist/runtime/teardown.js.map +1 -1
- package/dist/runtime/transcript.d.ts +34 -2
- package/dist/runtime/transcript.d.ts.map +1 -1
- package/dist/runtime/transcript.js +119 -24
- package/dist/runtime/transcript.js.map +1 -1
- package/dist/runtime/unique.d.ts +2 -11
- package/dist/runtime/unique.d.ts.map +1 -1
- package/dist/runtime/unique.js +27 -53
- package/dist/runtime/unique.js.map +1 -1
- package/dist/runtime/validate.d.ts +13 -2
- package/dist/runtime/validate.d.ts.map +1 -1
- package/dist/runtime/validate.js +51 -31
- package/dist/runtime/validate.js.map +1 -1
- package/dist/runtime/wait.d.ts +2 -1
- package/dist/runtime/wait.d.ts.map +1 -1
- package/dist/runtime/wait.js +2 -36
- package/dist/runtime/wait.js.map +1 -1
- package/dist/runtime/world.d.ts +24 -1
- package/dist/runtime/world.d.ts.map +1 -1
- package/dist/runtime/world.js +101 -26
- package/dist/runtime/world.js.map +1 -1
- package/dist/spec/collection.d.ts +8 -2
- package/dist/spec/collection.d.ts.map +1 -1
- package/dist/spec/collection.js +47 -7
- package/dist/spec/collection.js.map +1 -1
- package/dist/spec/extensions.d.ts +11 -1
- package/dist/spec/extensions.d.ts.map +1 -1
- package/dist/spec/extensions.js +10 -2
- package/dist/spec/extensions.js.map +1 -1
- package/dist/spec/graph.d.ts +46 -0
- package/dist/spec/graph.d.ts.map +1 -1
- package/dist/spec/graph.js +175 -11
- package/dist/spec/graph.js.map +1 -1
- package/dist/spec/load.d.ts +25 -5
- package/dist/spec/load.d.ts.map +1 -1
- package/dist/spec/load.js +160 -59
- package/dist/spec/load.js.map +1 -1
- package/dist/spec/query-capabilities.d.ts +4 -0
- package/dist/spec/query-capabilities.d.ts.map +1 -1
- package/dist/spec/query-capabilities.js +6 -0
- package/dist/spec/query-capabilities.js.map +1 -1
- package/dist/spec/schema.d.ts +20 -0
- package/dist/spec/schema.d.ts.map +1 -0
- package/dist/spec/schema.js +118 -0
- package/dist/spec/schema.js.map +1 -0
- package/dist/spec/tag-schema.d.ts +12 -0
- package/dist/spec/tag-schema.d.ts.map +1 -0
- package/dist/spec/tag-schema.js +144 -0
- package/dist/spec/tag-schema.js.map +1 -0
- package/dist/spec/types.js +17 -1
- package/dist/spec/types.js.map +1 -1
- package/docs/api.md +59 -0
- package/docs/assumptions.md +99 -0
- package/docs/checks.md +183 -0
- package/docs/commands.md +214 -0
- package/docs/configuration.md +650 -0
- package/docs/model.md +184 -0
- package/docs/reference-defects.md +94 -0
- package/docs/reports.md +211 -0
- package/docs/tags.md +403 -0
- package/package.json +3 -1
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
# oat — Configuration
|
|
2
|
+
|
|
3
|
+
[← back to the overview](../README.md)
|
|
4
|
+
|
|
5
|
+
## Complete configs
|
|
6
|
+
|
|
7
|
+
These are copy-paste starting points. Real configs import `defineConfig` from `@lovrozagar/oat`. Files inside this repository import from `../dist/index.js` because they live in the source tree.
|
|
8
|
+
|
|
9
|
+
### Static API keys, two tenants (smallest useful)
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// oat.config.ts
|
|
13
|
+
import { defineConfig } from "@lovrozagar/oat"
|
|
14
|
+
|
|
15
|
+
export default defineConfig({
|
|
16
|
+
spec: "https://api.example.com/openapi.json",
|
|
17
|
+
baseUrl: "https://api.example.com",
|
|
18
|
+
principals: [
|
|
19
|
+
{
|
|
20
|
+
id: "alpha",
|
|
21
|
+
headers: { authorization: "Bearer ${API_TOKEN}" },
|
|
22
|
+
roots: { project_id: "${PROJECT_A}" },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "beta",
|
|
26
|
+
headers: { authorization: "Bearer ${API_TOKEN_B}" },
|
|
27
|
+
roots: { project_id: "${PROJECT_B}" },
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
export API_TOKEN=… API_TOKEN_B=… PROJECT_A=proj_a PROJECT_B=proj_b
|
|
35
|
+
oat run --config oat.config.ts
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
One principal is enough to seed and run CRUD / query / schema checks. The second principal, with different `roots`, is what makes `tenant.*` run. Without it those checks are **did not apply**, not a pass.
|
|
39
|
+
|
|
40
|
+
Shipped as `labs/minimal.config.ts` (and in the npm package).
|
|
41
|
+
|
|
42
|
+
### JSON config
|
|
43
|
+
|
|
44
|
+
Same object. `${NAME}` is interpolated after load. There is no `defineConfig` wrapper.
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"spec": "https://api.example.com/openapi.json",
|
|
49
|
+
"baseUrl": "https://api.example.com",
|
|
50
|
+
"principals": [
|
|
51
|
+
{
|
|
52
|
+
"id": "alpha",
|
|
53
|
+
"headers": { "authorization": "Bearer ${API_TOKEN}" },
|
|
54
|
+
"roots": { "project_id": "${PROJECT_A}" }
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"seed": 42,
|
|
58
|
+
"cohortSize": 7,
|
|
59
|
+
"outDir": "./.oat/runs"
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
oat run --config oat.config.json
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Demo server (operation-id login)
|
|
68
|
+
|
|
69
|
+
Shipped as `labs/local.config.ts`. Points at `oat serve`.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { defineConfig } from "@lovrozagar/oat"
|
|
73
|
+
|
|
74
|
+
export default defineConfig({
|
|
75
|
+
spec: "/v1/openapi/spec",
|
|
76
|
+
baseUrl: "http://127.0.0.1:8787",
|
|
77
|
+
seed: 42,
|
|
78
|
+
principals: [
|
|
79
|
+
{
|
|
80
|
+
id: "alpha",
|
|
81
|
+
roots: { project_id: "proj_alpha" },
|
|
82
|
+
auth: {
|
|
83
|
+
credentialFrom: "$.access_token",
|
|
84
|
+
steps: [{ operationId: "auth.token", body: { key: "key_alpha" } }],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "beta",
|
|
89
|
+
roots: { project_id: "proj_beta" },
|
|
90
|
+
auth: {
|
|
91
|
+
credentialFrom: "$.access_token",
|
|
92
|
+
steps: [{ operationId: "auth.token", body: { key: "key_beta" } }],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`spec: "/v1/openapi/spec"` is resolved against `baseUrl`. `--base-url` on the CLI overrides the origin without editing the file.
|
|
100
|
+
|
|
101
|
+
### Two tenants plus a same-tenant rank lattice
|
|
102
|
+
|
|
103
|
+
See [Principals](#principals). Isolation keys off `roots`. Rank keys off `rank` with shared `roots`.
|
|
104
|
+
|
|
105
|
+
## Configuration
|
|
106
|
+
|
|
107
|
+
Two inputs, always: **the spec** and **a config file**. Backend-specific knowledge lives in `x-*` tags and this file. A backend adopts oat by adding tags, not by adapting to oat.
|
|
108
|
+
|
|
109
|
+
A config is:
|
|
110
|
+
|
|
111
|
+
- `.ts` / `.js` / `.mjs` with `export default defineConfig({ ... })`, or
|
|
112
|
+
- `.json` with the same object.
|
|
113
|
+
|
|
114
|
+
Named export without `default` is also accepted (`module.default ?? module`).
|
|
115
|
+
|
|
116
|
+
### Top-level fields
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { defineConfig } from "@lovrozagar/oat"
|
|
120
|
+
|
|
121
|
+
export default defineConfig({
|
|
122
|
+
spec: "https://api.example.com/openapi.json", // URL or filesystem path; JSON or YAML
|
|
123
|
+
baseUrl: "https://api.example.com",
|
|
124
|
+
principals: [/* at least one; see below */],
|
|
125
|
+
hooks: {/* optional */},
|
|
126
|
+
uploads: { pool: ["./fixtures/**/*"], eachMax: 24 },
|
|
127
|
+
globalHeaders: { "x-request-id": "oat" }, // sent on every request; oat does not inspect them
|
|
128
|
+
roots: { org_id: "org_shared" }, // path params oat cannot create; also declarable via x-root
|
|
129
|
+
seed: 42, // fixture generation; a failing run with the same seed is identical
|
|
130
|
+
cohortSize: 12, // records created per entity (default 7)
|
|
131
|
+
maxInFlight: 4, // HTTP in flight
|
|
132
|
+
ops: ["store.create", "product.*"], // grade only these operationIds; the rest is support
|
|
133
|
+
only: ["store", "product"], // grade every operation these entities own (union with ops)
|
|
134
|
+
keepFixtures: false,
|
|
135
|
+
outDir: "./.oat/runs",
|
|
136
|
+
saveExchanges: true, // default on unless --profile cheap; --quiet does not turn this off
|
|
137
|
+
network: { retries: 4, waitMs: 60_000 }, // fetch-threw: retry, then wait for the link
|
|
138
|
+
outOfBand: { attempts: 20, initialMs: 1000, maxMs: 8000 },
|
|
139
|
+
origins: [{ id: "cdn", baseUrl: "https://cdn.example.com", spec: "https://cdn.example.com/openapi.json" }],
|
|
140
|
+
query: {
|
|
141
|
+
operators: ["eq", "neq", "gt", "gte", "lt", "lte", "in", "nin", "like", "ilike", "is"],
|
|
142
|
+
emptyIn: "match-none",
|
|
143
|
+
maxInValues: 100,
|
|
144
|
+
searchEmpty: "match-all",
|
|
145
|
+
sort: { nulls: ["first", "last"], maxKeys: 3 },
|
|
146
|
+
select: { unknown: "reject" },
|
|
147
|
+
},
|
|
148
|
+
entities: {
|
|
149
|
+
row: {
|
|
150
|
+
query: {
|
|
151
|
+
identityFilter: "_id",
|
|
152
|
+
filterable: [{ field: "_id", type: "string", ops: ["eq", "neq", "in"] }],
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
| field | required | default | notes |
|
|
160
|
+
| --------------- | -------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
161
|
+
| `spec` | yes | | See [Spec loading](#spec-loading) |
|
|
162
|
+
| `baseUrl` | yes | | Primary origin. OpenAPI `servers[]` is ignored |
|
|
163
|
+
| `principals` | yes | | Non-empty. First is the writer |
|
|
164
|
+
| `hooks` | no | | See [Hooks](#hooks) |
|
|
165
|
+
| `uploads` | no | | `pool` globs; optional `each` (operationId → globs) and `eachMax`. JSON configs may set all three |
|
|
166
|
+
| `globalHeaders` | no | `{}` | Merged first. `resolveHeaders` then caller headers then auth |
|
|
167
|
+
| `origins` | no | `[]` | Extra `{ id, baseUrl, spec }` hosts. Auth JWT is reused. Do not merge those routes into `spec` |
|
|
168
|
+
| `outOfBand` | no | `{ attempts: 6, initialMs: 200, maxMs: 3000 }` | Backoff for `resolveOutOfBand` and `resolvePrincipalAuth`. See [Hooks](#hooks) |
|
|
169
|
+
| `roots` | no | `{}` | Shared path params (merged with each principal's `roots`) |
|
|
170
|
+
| `seed` | no | `1` | Integer. Same seed → same fixture bodies |
|
|
171
|
+
| `cohortSize` | no | `7` | Sliced from the 7 built-in variants. Larger repeats the pattern |
|
|
172
|
+
| `maxInFlight` | no | `4` | Across the whole run |
|
|
173
|
+
| `payloads` | no | `"per-write-path"` | String payload catalog: all 159 cases once per write path (media type + field type), one per family elsewhere; `"full"` sends every case to every field |
|
|
174
|
+
| `only` | no | all | Entity names from `oat plan` |
|
|
175
|
+
| `keepFixtures` | no | `false` | Skip DELETE at the end |
|
|
176
|
+
| `outDir` | no | `./.oat/runs` | History root. Each run writes `<outDir>/<datetime>/` and updates `latest`. Also writes `principals.json` after acquire |
|
|
177
|
+
| `saveExchanges` | no | on unless `profile` is `cheap` | Persist every HTTP exchange under the run dir. `--save-exchanges` / `--no-save-exchanges` override. `--quiet` does not |
|
|
178
|
+
| `network` | no | `{ retries: 4, waitMs: 60000, requestTimeoutMs: 180000 }` | When `fetch` throws (offline / DNS / reset / timeout): retry, then wait once for the link. Not a 5xx policy. `requestTimeoutMs: 0` disables the per-attempt cap |
|
|
179
|
+
| `query` | no | | Global query-catalog defaults. Overlay after `x-query`. Does not invent operators |
|
|
180
|
+
| `entities` | no | | Per-entity overlays. This release only reads `query`. Unknown names are ignored; `doctor` warns |
|
|
181
|
+
|
|
182
|
+
`spec` may be a path relative to `baseUrl` (`/v1/openapi/spec`) or an absolute URL or a file.
|
|
183
|
+
|
|
184
|
+
CLI `--base-url`, `--ops`, `--only`, `--seed`, `--out`, `--max-in-flight`, `--keep-fixtures`, `--save-exchanges` / `--no-save-exchanges` override these when passed.
|
|
185
|
+
|
|
186
|
+
### Spec loading
|
|
187
|
+
|
|
188
|
+
Resolved in this order, never by guessing the string's "look":
|
|
189
|
+
|
|
190
|
+
1. Absolute `http(s)://` or `file://` — used as given.
|
|
191
|
+
2. A path that exists on disk, relative to the working directory.
|
|
192
|
+
3. Anything else, when `baseUrl` is known — resolved against it (`/v1/openapi/spec`, `openapi.json`).
|
|
193
|
+
|
|
194
|
+
JSON if the first non-space character is `{` or `[`, otherwise YAML. Empty files error. A JSON document with more opening than closing brackets is diagnosed as truncated (proxy / download limit), not as a syntax error.
|
|
195
|
+
|
|
196
|
+
OpenAPI 3.0 and 3.1 both work. oat reads `paths`, operations, parameters, request/response JSON schemas, and `x-*` extensions. It does not require a particular `openapi:` version string.
|
|
197
|
+
|
|
198
|
+
Internal `$ref`s are dereferenced. External `$ref`s stay unresolved and show up in `oat doctor` / the JSON `externalRefs` list.
|
|
199
|
+
|
|
200
|
+
### Principals
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
{
|
|
204
|
+
id: "alpha", // required, stable name in reports
|
|
205
|
+
headers: { authorization: "Bearer …" }, // static; enough for a long-lived key
|
|
206
|
+
auth: { /* AuthFlow — see below */ },
|
|
207
|
+
roots: { org_id: "org_alpha" }, // this principal's tenant / path params
|
|
208
|
+
rootsFromFlow: { org_id: "orgId" }, // take path params from values the auth flow bound
|
|
209
|
+
role: "owner", // free-form label in reports
|
|
210
|
+
rank: 2, // higher can do everything a lower rank can; default 0
|
|
211
|
+
inviteAs: "key_beta", // how an owner names this principal in an invite body
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Rules that matter:
|
|
216
|
+
|
|
217
|
+
- **Isolation** (`tenant.*`) needs two principals whose `roots` differ.
|
|
218
|
+
- **Rank** (`auth.rank-is-monotonic`) needs two principals with the _same_ `roots` and different `rank`.
|
|
219
|
+
- **Invite** (`auth.invite-grants-then-revokes`) needs `x-invite` on the spec and a _different-tenant_ principal with `inviteAs` set.
|
|
220
|
+
- Extra principals are not ignored. Isolation picks the first different-`roots` peer. Rank uses the same-tenant pair.
|
|
221
|
+
- `headers` and `auth` compose: static headers are sent, then the flow's credential header is merged on top.
|
|
222
|
+
- A principal with only `headers` (no `auth`) never hits a login route.
|
|
223
|
+
- `auth` may be a harvested credential instead of a step chain: `{ fromHook: "oauth-google" }`. See [Harvested principal](#harvested-principal).
|
|
224
|
+
|
|
225
|
+
Example — two tenants plus a same-tenant lattice:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
principals: [
|
|
229
|
+
{
|
|
230
|
+
id: "alpha",
|
|
231
|
+
role: "owner",
|
|
232
|
+
rank: 2,
|
|
233
|
+
auth: {
|
|
234
|
+
credentialFrom: "$.access_token",
|
|
235
|
+
steps: [{ operationId: "auth.token", body: { key: "key_alpha" } }],
|
|
236
|
+
},
|
|
237
|
+
roots: { org_id: "org_alpha" },
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
id: "alpha_member",
|
|
241
|
+
role: "member",
|
|
242
|
+
rank: 1,
|
|
243
|
+
auth: {
|
|
244
|
+
credentialFrom: "$.access_token",
|
|
245
|
+
steps: [{ operationId: "auth.token", body: { key: "key_alpha_member" } }],
|
|
246
|
+
},
|
|
247
|
+
roots: { org_id: "org_alpha" },
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
id: "beta",
|
|
251
|
+
role: "owner",
|
|
252
|
+
rank: 2,
|
|
253
|
+
inviteAs: "key_beta",
|
|
254
|
+
auth: {
|
|
255
|
+
credentialFrom: "$.access_token",
|
|
256
|
+
steps: [{ operationId: "auth.token", body: { key: "key_beta" } }],
|
|
257
|
+
},
|
|
258
|
+
roots: { org_id: "org_beta" },
|
|
259
|
+
},
|
|
260
|
+
]
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Auth flows
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
auth: {
|
|
267
|
+
steps: [ /* register / verify — first acquire only */ ],
|
|
268
|
+
credentialFrom: "$.access_token", // JSON path in the last (or saved) response
|
|
269
|
+
expiresInFrom: "$.expires_in", // lifetime in seconds
|
|
270
|
+
header: "authorization", // default
|
|
271
|
+
template: "Bearer {credential}", // default
|
|
272
|
+
assumeTtlMs: 3600000, // used only if neither expiresInFrom nor JWT exp is present
|
|
273
|
+
refreshBufferMs: 30_000, // optional; default 30s. Proactive when expiresAt - now <= this
|
|
274
|
+
refresh: { // signup flows must set this — re-running steps is not a refresh
|
|
275
|
+
steps: [
|
|
276
|
+
{
|
|
277
|
+
operationId: "auth.refreshToken",
|
|
278
|
+
body: { refresh_token: "{refreshToken}" },
|
|
279
|
+
saveAs: {
|
|
280
|
+
credential: "$.access_token",
|
|
281
|
+
refreshToken: "$.refresh_token",
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Expiry is `expiresInFrom` (seconds) → JWT `exp` claim → `assumeTtlMs`. `assumeTtlMs` is only the fallback lifetime when nothing else revealed expiry — it is not used for the refresh threshold when `expiresAt` is known.
|
|
290
|
+
|
|
291
|
+
Refresh is countdown-based (`expiresAt - refreshBufferMs`, default 30s) before every dispatch and before each async poll. Signup flows declare `auth.refresh` (refresh-token operation). Re-running `steps` is only the fallback when `refresh` is omitted (API-key / token-exchange principals). A register-like first hop without `refresh` fails closed (`AUTH_REFRESH_REQUIRED`) rather than signing up again.
|
|
292
|
+
|
|
293
|
+
One 401 → force refresh + single retry with live headers. A second 401 is evidence. 5xx / 429 are never a refresh trigger. `expiresAt === null` (static-header principal) never proactive-refreshes.
|
|
294
|
+
|
|
295
|
+
Each step is one of:
|
|
296
|
+
|
|
297
|
+
**Operation step** (prefer this — survives the path moving):
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
{
|
|
301
|
+
operationId: "auth.token",
|
|
302
|
+
body: { key: "${API_KEY}" },
|
|
303
|
+
headers: { "x-extra": "1" },
|
|
304
|
+
query: { realm: "test" },
|
|
305
|
+
saveAs: { credential: "$.access_token", refreshToken: "$.refresh_token" },
|
|
306
|
+
saveClaimsFrom: { token: "$.access_token", bind: { orgId: "orgs.0.oid" } },
|
|
307
|
+
bind: { address: "user@example.test" }, // literals, with {name} interpolation
|
|
308
|
+
expect: [200], // default: any 2xx
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
**Request step** (when the document has no auth operations, or the hop is not in the spec):
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
{
|
|
316
|
+
method: "POST",
|
|
317
|
+
path: "/v1/auth/register/email",
|
|
318
|
+
body: { email: "{address}", password: "…" },
|
|
319
|
+
bind: { address: "oat-alpha@example.test" },
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
`path` is joined to this origin's `baseUrl`, unless it is an absolute `http://` or `https://` URL — then oat dispatches to that URL as a recorded exchange (method, URL, status, redirects, response headers). That is how a consume page on the app origin is an auth step when `baseUrl` is the API. Unknown host is allowed. `redirect` defaults to `"follow"` with a per-request cookie jar, so a 303 that sets `Set-Cookie` still leaves that cookie visible to `saveAs`. `redirect: "manual"` stops at the first 3xx (`expect: [303]` then bind from that response).
|
|
324
|
+
|
|
325
|
+
**Out-of-band step** (email link, OTP — oat cannot collect this itself):
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
{ outOfBand: { address: "{address}", kind: "email-verify", as: "verifyLink" } }
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
The hook returns a string. If that string is a URL, a later RequestStep must GET it — a hook-side `fetch` is not a recorded exchange.
|
|
332
|
+
|
|
333
|
+
Later steps interpolate `{name}` from the flow scope, `saveAs` addresses included (`cookie:rt.{userId}` reads a per-user cookie). `saveAs` addresses:
|
|
334
|
+
|
|
335
|
+
- `$.foo.bar` / `$.orgs.0.id` — JSON body (dot + numeric index only; no JSON Pointer, no filters)
|
|
336
|
+
- `cookie:<name>` — that cookie on `Set-Cookie` for this hop, including followed hops
|
|
337
|
+
- `header:<name>` — a non-set-cookie response header, case-insensitive
|
|
338
|
+
|
|
339
|
+
Missing cookie / header / JSON path fails the auth step closed. Bind `saveAs.refreshToken` from `$.refresh_token` or `cookie:refresh` so `{refreshToken}` interpolates in `auth.refresh`. `saveClaimsFrom.token` is a JSON path into the response body (`$.access_token`) or a scope key already bound by `saveAs` (`credential`). Signature is not verified — oat is reading its own credential. `rootsFromFlow` maps path parameter names to those bound keys.
|
|
340
|
+
|
|
341
|
+
`bind` on a step runs **before** the request. `saveAs` then `saveClaimsFrom` run **after**. `credentialFrom` is read from the last HTTP response unless a step already saved `credential`. `outOfBand.as = "credential"` still wins over `credentialFrom`.
|
|
342
|
+
|
|
343
|
+
If a step's status is not acceptable, auth fails the run (not a finding): `oat: principal "alpha" failed at auth step 2 (POST /v1/…)`.
|
|
344
|
+
|
|
345
|
+
A complete register → mailed GET → cookie session example:
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
function signUp(email: string): AuthFlow {
|
|
349
|
+
return {
|
|
350
|
+
credentialFrom: "$.access_token",
|
|
351
|
+
expiresInFrom: "$.access_token_expires_in",
|
|
352
|
+
header: "cookie",
|
|
353
|
+
template: "session={credential}",
|
|
354
|
+
refresh: {
|
|
355
|
+
steps: [
|
|
356
|
+
{
|
|
357
|
+
body: { refresh_token: "{refreshToken}" },
|
|
358
|
+
method: "POST",
|
|
359
|
+
path: "/v1/auth/refresh",
|
|
360
|
+
saveAs: { credential: "$.access_token", refreshToken: "$.refresh_token" },
|
|
361
|
+
},
|
|
362
|
+
],
|
|
363
|
+
},
|
|
364
|
+
steps: [
|
|
365
|
+
{
|
|
366
|
+
bind: { address: email },
|
|
367
|
+
body: { email, password: "…" },
|
|
368
|
+
method: "POST",
|
|
369
|
+
path: "/v1/auth/register/email",
|
|
370
|
+
saveAs: { credential: "$.access_token", refreshToken: "$.refresh_token" },
|
|
371
|
+
},
|
|
372
|
+
{ outOfBand: { address: email, as: "verifyLink", kind: "email-verify" } },
|
|
373
|
+
{
|
|
374
|
+
method: "GET",
|
|
375
|
+
path: "{verifyLink}", // absolute URL; not joined to baseUrl
|
|
376
|
+
expect: [200, 303],
|
|
377
|
+
saveAs: {
|
|
378
|
+
credential: "cookie:session",
|
|
379
|
+
refreshToken: "cookie:refresh",
|
|
380
|
+
},
|
|
381
|
+
saveClaimsFrom: {
|
|
382
|
+
token: "credential",
|
|
383
|
+
bind: { orgId: "orgs.0.oid", projectId: "orgs.0.pids.0" },
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
],
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
When the user path **is** POST-token, keep that chain — it is still valid:
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
{ outOfBand: { address: email, as: "verifyToken", kind: "email-verify" } },
|
|
395
|
+
{
|
|
396
|
+
body: { token: "{verifyToken}" },
|
|
397
|
+
method: "POST",
|
|
398
|
+
path: "/v1/auth/email/verify",
|
|
399
|
+
saveAs: { credential: "$.access_token", refreshToken: "$.refresh_token" },
|
|
400
|
+
saveClaimsFrom: { token: "$.access_token", bind: { orgId: "orgs.0.oid" } },
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
principals: [
|
|
406
|
+
{
|
|
407
|
+
id: "alpha",
|
|
408
|
+
auth: signUp("oat-alpha@example.test"),
|
|
409
|
+
rootsFromFlow: { organization_id: "orgId", project_id: "projectId" },
|
|
410
|
+
},
|
|
411
|
+
]
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
The address used for `teardownPrincipal` is `scope.address` or `scope.email` (set via `bind: { address }` or `bind: { email }`).
|
|
415
|
+
|
|
416
|
+
### Harvested principal
|
|
417
|
+
|
|
418
|
+
When the credential is produced outside oat (a human finishes Google OAuth on a harvest page, a pair lands in KV), do not make oat drive `authorize` / `callback`:
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
{
|
|
422
|
+
id: "google-user",
|
|
423
|
+
auth: { fromHook: "oauth-google" },
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
oat polls `hooks.resolvePrincipalAuth("oauth-google")` with the same `outOfBand` backoff as mail. Return `{ credential, refreshToken?, expiresIn? }` or `null` to retry. Refresh re-calls the hook (401 and countdown). oat does not speak OAuth.
|
|
428
|
+
|
|
429
|
+
### Secondary origins
|
|
430
|
+
|
|
431
|
+
One run, one primary `baseUrl`. A CDN (or any second host) keeps its own OpenAPI.
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
export default defineConfig({
|
|
435
|
+
spec: "https://api.example.com/openapi.json",
|
|
436
|
+
baseUrl: "https://api.example.com",
|
|
437
|
+
principals: [/* acquire JWT on the API */],
|
|
438
|
+
origins: [{ id: "cdn", baseUrl: "https://cdn.example.com", spec: "https://cdn.example.com/openapi.json" }],
|
|
439
|
+
})
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
After primary auth, oat snapshots the principals, binds those credentials to the other host, and runs the matrix against that document. Auth steps may set `origin: "cdn"` to send one hop to a named origin during acquire.
|
|
443
|
+
|
|
444
|
+
Do not merge CDN routes into the API gateway document.
|
|
445
|
+
|
|
446
|
+
A second `defineConfig` can reuse the first run's snapshot instead:
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
import { defineConfig, loadPersistedPrincipals } from "@lovrozagar/oat"
|
|
450
|
+
|
|
451
|
+
export default defineConfig({
|
|
452
|
+
spec: "https://cdn.example.com/openapi.json",
|
|
453
|
+
baseUrl: "https://cdn.example.com",
|
|
454
|
+
principals: loadPersistedPrincipals("./.oat/runs/latest/principals.json"),
|
|
455
|
+
})
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
The CLI writes `principals.json` into each run folder (and `.oat/runs/latest/principals.json` via the `latest` symlink).
|
|
459
|
+
|
|
460
|
+
### Hooks
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
hooks: {
|
|
464
|
+
// Return null to retry (attempt is 1-based). oat backs off until a value arrives.
|
|
465
|
+
// `scope` is the flow at this step; `headers` are the live principal credential headers.
|
|
466
|
+
resolveOutOfBand: async ({ address, kind, attempt, scope, headers }) => {
|
|
467
|
+
const link = await readMailCatcher(address, kind)
|
|
468
|
+
return link // URL or token string, or null
|
|
469
|
+
},
|
|
470
|
+
// Remove a principal this run provisioned. `ctx` is the last live credential.
|
|
471
|
+
teardownPrincipal: async (address, { credential, headers }) => {
|
|
472
|
+
await fetch(`https://api.example.com/v1/auth/account`, { method: "DELETE", headers })
|
|
473
|
+
},
|
|
474
|
+
// Return a file to send, `{ fields }` to replace the whole request, or null to fall through.
|
|
475
|
+
resolveUpload: async ({ operationId, field, contentMediaType }) => {
|
|
476
|
+
if (operationId === "extract.once" && field === "file") {
|
|
477
|
+
const bytes = await Deno.readFile("./invoices/known.pdf")
|
|
478
|
+
return { bytes, filename: "known.pdf", mediaType: "application/pdf" }
|
|
479
|
+
}
|
|
480
|
+
return null
|
|
481
|
+
},
|
|
482
|
+
// After globalHeaders, before auth. Return null to add nothing.
|
|
483
|
+
resolveHeaders: async ({ operationId, method, url }) => {
|
|
484
|
+
if (operationId === "auth.register" || operationId === "auth.login") {
|
|
485
|
+
return { "cf-turnstile-response": await harvestTurnstile() }
|
|
486
|
+
}
|
|
487
|
+
return null
|
|
488
|
+
},
|
|
489
|
+
// Replace a generated JSON field. Null keeps the generator.
|
|
490
|
+
resolveInput: async ({ operationId, field }) => {
|
|
491
|
+
if (operationId === "billing.subscribe" && field === "payment_method_id") {
|
|
492
|
+
return process.env.STRIPE_TEST_PM
|
|
493
|
+
}
|
|
494
|
+
return null
|
|
495
|
+
},
|
|
496
|
+
// Harvested OAuth pair. Null retries with the outOfBand backoff.
|
|
497
|
+
resolvePrincipalAuth: async (fromHook) => {
|
|
498
|
+
if (fromHook !== "oauth-google") return null
|
|
499
|
+
const pair = await readHarvestedGoogle()
|
|
500
|
+
return pair === null ? null : { credential: pair.access_token, refreshToken: pair.refresh_token, expiresIn: pair.expires_in }
|
|
501
|
+
},
|
|
502
|
+
// After seed: add or replace any axis of the query catalog. Null keeps the merge.
|
|
503
|
+
resolveQueryCapabilities: async ({ entity, get }) => {
|
|
504
|
+
if (entity !== "row") return null
|
|
505
|
+
const body = await get("table.get")
|
|
506
|
+
return { filterable: /* harvest from body */ [] }
|
|
507
|
+
},
|
|
508
|
+
// Optional extra stop condition while x-wait polls.
|
|
509
|
+
awaitSideEffect: async ({ operationId, record }) => {
|
|
510
|
+
if (operationId !== "webhook.deliver") return null
|
|
511
|
+
return Array.isArray((record as { items?: unknown }).items) && (record as { items: unknown[] }).items.length > 0
|
|
512
|
+
? true
|
|
513
|
+
: null
|
|
514
|
+
},
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Without `resolveOutOfBand`, an `outOfBand` step cannot complete. oat polls the hook; the hook must not sleep. Returning `""` is treated like `null`. The hook is not a recorded hop: if the human GETs a mailed URL, a later RequestStep must GET that URL so it appears in `exchanges.jsonl` and can `expect` / `saveAs`.
|
|
519
|
+
|
|
520
|
+
Default schedule (0.6.2, unchanged unless `outOfBand` is set): **6** attempts, first sleep **200** ms, doubling, cap **3000** ms. oat sleeps after every miss, including the last, so the worst-case wait is
|
|
521
|
+
|
|
522
|
+
`200 + 400 + 800 + 1600 + 3000 + 3000 = 9000` ms.
|
|
523
|
+
|
|
524
|
+
That is too short for real mail (often 10–60 s) and for a human finishing Google OAuth or a Turnstile harvest. Configure it:
|
|
525
|
+
|
|
526
|
+
```ts
|
|
527
|
+
outOfBand: { attempts: 20, initialMs: 1000, maxMs: 8000 }
|
|
528
|
+
// worst case: 1000 + 2000 + 4000 + 8000×17 = 143000 ms
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
Worst-case wait is `sum_{i=0}^{attempts-1} min(initialMs × 2^i, maxMs)`. `worstCaseWaitMs()` from the package computes it. Existing configs that omit `outOfBand` do not slow down.
|
|
532
|
+
|
|
533
|
+
Without `teardownPrincipal`, provisioned accounts are reported as leftover rather than cascade-deleted. Per-record DELETE still runs for seeded rows when a delete (or `x-cleanup`) exists. The hook is called with the last live credential (`ctx.credential` / `ctx.headers`), not an already-cleared one, so authenticated delete-account is expressible without a tester-key god route. One-argument JavaScript callbacks still run.
|
|
534
|
+
|
|
535
|
+
`resolveHeaders` is called on every dispatch (including the 401 retry). Merge order: `globalHeaders` → hook → per-request headers → principal credential. Use `ctx.operationId` / `ctx.method` / `ctx.url` to attach a one-shot captcha only on captcha ops. oat does not speak Turnstile.
|
|
536
|
+
|
|
537
|
+
`resolveInput` is the JSON twin of `resolveUpload`. Return a value to replace that field (`payment_method_id` on `billing.subscribe`); `null` keeps the generator.
|
|
538
|
+
|
|
539
|
+
`resolveQueryCapabilities` runs once per entity after seed. `get(operationId)` (or `"GET /path"`) uses the seeded parent scope so a follow-up read can harvest dynamic columns. A provided `filterable` / `sortable` / `searchable` / `selectable` list **replaces** that axis; omitted axes stay. JSON configs have no hook.
|
|
540
|
+
|
|
541
|
+
`resolvePrincipalAuth` and `awaitSideEffect` are documented below.
|
|
542
|
+
|
|
543
|
+
Worked `query` overlay for a generic PostgREST-shaped API (not a named product):
|
|
544
|
+
|
|
545
|
+
```ts
|
|
546
|
+
export default defineConfig({
|
|
547
|
+
spec: "https://api.example.com/openapi.json",
|
|
548
|
+
baseUrl: "https://api.example.com",
|
|
549
|
+
principals: [{ id: "alpha", headers: { authorization: `Bearer ${process.env.TOKEN}` } }],
|
|
550
|
+
query: {
|
|
551
|
+
operators: ["eq", "neq", "gt", "gte", "lt", "lte", "in", "nin", "like", "ilike", "is"],
|
|
552
|
+
operatorsByType: {
|
|
553
|
+
string: ["eq", "neq", "like", "ilike", "in", "nin", "is"],
|
|
554
|
+
number: ["eq", "neq", "gt", "gte", "lt", "lte", "in", "nin", "is"],
|
|
555
|
+
date: ["eq", "neq", "gt", "gte", "lt", "lte", "is"],
|
|
556
|
+
boolean: ["eq", "neq", "is"],
|
|
557
|
+
},
|
|
558
|
+
aliases: { ne: "neq" },
|
|
559
|
+
emptyIn: "match-none",
|
|
560
|
+
maxInValues: 100,
|
|
561
|
+
maxFilterConditions: 20,
|
|
562
|
+
searchEmpty: "match-all",
|
|
563
|
+
sort: { nulls: ["first", "last"], maxKeys: 3 },
|
|
564
|
+
select: { nested: false, unknown: "reject" },
|
|
565
|
+
},
|
|
566
|
+
})
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
New surface still has to be listed. oat will not silently enable `in` / `ilike` / `is` / `contains` / search modes / `nullsfirst` on every PostgREST-shaped document.
|
|
570
|
+
|
|
571
|
+
### Uploads
|
|
572
|
+
|
|
573
|
+
Multipart and binary parts are filled in this order:
|
|
574
|
+
|
|
575
|
+
1. `hooks.resolveUpload` — a non-null `UploadFile` wins. `{ fields }` that includes a file part replaces the whole request. `{ fields }` that omits the file part overlays scalars and keeps the each / pool / dummy bytes.
|
|
576
|
+
2. `uploads.each` fixture for this invocation, when that operation is listed.
|
|
577
|
+
3. `uploads.pool` — first file whose extension / sniffed type matches the part's `contentMediaType`. Same `seed` + field + index → same pick. Ops not in `each` stay pick-one.
|
|
578
|
+
4. A tiny dummy with sniffable magic (`%PDF-1.1`, 1×1 PNG, empty zip, …). Unknown types become 16 octet-stream bytes, not a skip.
|
|
579
|
+
|
|
580
|
+
`uploads.each` is a matrix, not a source. `operationId → globs` means that operation is invoked once per matched file (after `eachMax`). Same seed does **not** collapse `each`. A hook that ignores `request.fixture` and always returns the same file will send that file N times.
|
|
581
|
+
|
|
582
|
+
Each invocation is still a citeable hop (method, URL, status, fixture name, content-addressed body). Live `FormData` / `Blob` payloads are **not** kept on `Client.transcript` for the rest of the run — they are replaced with `{ sha256, bytes, mediaType }` (and multipart `{ parts }`) after the hop is journaled, or immediately when the journal is off. Fan-out therefore multiplies requests, not retained fixture bytes.
|
|
583
|
+
|
|
584
|
+
```ts
|
|
585
|
+
export default defineConfig({
|
|
586
|
+
spec: "openapi.json",
|
|
587
|
+
baseUrl: "https://api.example.com",
|
|
588
|
+
principals: [/* … */],
|
|
589
|
+
uploads: {
|
|
590
|
+
pool: ["./fixtures/**/*"],
|
|
591
|
+
each: {
|
|
592
|
+
"extract.once": ["./fixtures/**/*"],
|
|
593
|
+
"extract.stream": ["./fixtures/**/*"],
|
|
594
|
+
},
|
|
595
|
+
eachMax: 24,
|
|
596
|
+
},
|
|
597
|
+
hooks: {
|
|
598
|
+
resolveUpload: async ({ operationId, field, fixture }) => {
|
|
599
|
+
if (operationId === "extract.once" && field === "file") {
|
|
600
|
+
return { fields: { columns: "vendor,date,amount" } }
|
|
601
|
+
}
|
|
602
|
+
return null
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
})
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
| case | outcome |
|
|
609
|
+
| ------------------------------- | ----------------------------------------------------------- |
|
|
610
|
+
| `each` omitted | pick-one (today) |
|
|
611
|
+
| glob matches 0 files | warn once, no extra invocations, fall through to pool/dummy |
|
|
612
|
+
| one path in the list is missing | drop that slot, warn once, never `BACKEND_BUG` |
|
|
613
|
+
| `eachMax` < match count | first `eachMax` after sort, warn that it capped |
|
|
614
|
+
| fixture unreadable | drop that slot, warn once, not `BACKEND_BUG` |
|
|
615
|
+
|
|
616
|
+
A missing pool path warns once and falls through. An empty pool match uses a dummy. The run does not fail. JSON configs may set `pool`, `each`, and `eachMax`. `resolveUpload` is TypeScript.
|
|
617
|
+
|
|
618
|
+
`--profile cheap` (or any profile that excludes the op) still drops the whole family. `each` does not punch through a profile.
|
|
619
|
+
|
|
620
|
+
Findings from an `each` invocation carry `fixture: "invoice.pdf"` and render as `extract.once · invoice.pdf`. One 5xx is one finding on that file.
|
|
621
|
+
|
|
622
|
+
oat does not OCR. It sends bytes and checks HTTP / JSON. A 200 with empty extract rows is not automatically a backend defect. A 4xx because the dummy is “not a real invoice” is not automatically a backend defect if the dummy matched the declared `contentMediaType`.
|
|
623
|
+
|
|
624
|
+
Prefer `multipart/form-data` when the operation documents it, even if JSON is also listed. Text form fields still use the string generator (`format` / `pattern` / `maxLength`). A part that is either text or file is sent as a file when the schema is binary.
|
|
625
|
+
|
|
626
|
+
### Environment interpolation
|
|
627
|
+
|
|
628
|
+
After the module loads, every string in the config is scanned for `${NAME}`:
|
|
629
|
+
|
|
630
|
+
```ts
|
|
631
|
+
headers: {
|
|
632
|
+
authorization: "Bearer ${API_TOKEN}"
|
|
633
|
+
}
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
If `API_TOKEN` is unset, oat exits with an error. Do not commit secrets; put them in the environment.
|
|
637
|
+
|
|
638
|
+
Template literals in a `.ts` config (`Bearer ${process.env.API_TOKEN}`) are evaluated by Node _before_ oat sees the object. Either style works; `${NAME}` is what a `.json` config can use.
|
|
639
|
+
|
|
640
|
+
Names match `[A-Z0-9_]+` case-insensitively.
|
|
641
|
+
|
|
642
|
+
### Loading TypeScript configs
|
|
643
|
+
|
|
644
|
+
`.js` / `.mjs` / `.json` load everywhere.
|
|
645
|
+
|
|
646
|
+
`.ts` configs require a runtime that can import TypeScript: Node 22.6+ with `--experimental-strip-types`, or Node 23+. The published `oat` binary is itself JS; it still has to `import()` your config. If that fails, the error says so. Workaround: compile the config, or write `.mjs`.
|
|
647
|
+
|
|
648
|
+
```bash
|
|
649
|
+
node --experimental-strip-types ./node_modules/@lovrozagar/oat/dist/cli.js run --config oat.config.ts
|
|
650
|
+
```
|