@lovrozagar/oat 0.9.1 → 0.11.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.
Files changed (283) hide show
  1. package/README.md +26 -1936
  2. package/dist/cli.js +326 -60
  3. package/dist/config/define-config.d.ts +15 -1
  4. package/dist/config/define-config.d.ts.map +1 -1
  5. package/dist/config/define-config.js.map +1 -1
  6. package/dist/config/validate.d.ts +12 -0
  7. package/dist/config/validate.d.ts.map +1 -0
  8. package/dist/config/validate.js +86 -0
  9. package/dist/config/validate.js.map +1 -0
  10. package/dist/conformance/effects.d.ts +9 -0
  11. package/dist/conformance/effects.d.ts.map +1 -0
  12. package/dist/conformance/effects.js +276 -0
  13. package/dist/conformance/effects.js.map +1 -0
  14. package/dist/conformance/expected-failures.d.ts +18 -0
  15. package/dist/conformance/expected-failures.d.ts.map +1 -0
  16. package/dist/conformance/expected-failures.js +48 -0
  17. package/dist/conformance/expected-failures.js.map +1 -0
  18. package/dist/conformance/feature-gate.d.ts +7 -0
  19. package/dist/conformance/feature-gate.d.ts.map +1 -0
  20. package/dist/conformance/feature-gate.js +428 -0
  21. package/dist/conformance/feature-gate.js.map +1 -0
  22. package/dist/conformance/fuzz.d.ts +108 -0
  23. package/dist/conformance/fuzz.d.ts.map +1 -0
  24. package/dist/conformance/fuzz.js +246 -0
  25. package/dist/conformance/fuzz.js.map +1 -0
  26. package/dist/conformance/leaks.d.ts +10 -0
  27. package/dist/conformance/leaks.d.ts.map +1 -0
  28. package/dist/conformance/leaks.js +23 -0
  29. package/dist/conformance/leaks.js.map +1 -0
  30. package/dist/conformance/pool.d.ts +49 -0
  31. package/dist/conformance/pool.d.ts.map +1 -0
  32. package/dist/conformance/pool.js +75 -0
  33. package/dist/conformance/pool.js.map +1 -0
  34. package/dist/conformance/rate-limit.d.ts +7 -0
  35. package/dist/conformance/rate-limit.d.ts.map +1 -0
  36. package/dist/conformance/rate-limit.js +372 -0
  37. package/dist/conformance/rate-limit.js.map +1 -0
  38. package/dist/conformance/scope.d.ts +15 -0
  39. package/dist/conformance/scope.d.ts.map +1 -0
  40. package/dist/conformance/scope.js +78 -0
  41. package/dist/conformance/scope.js.map +1 -0
  42. package/dist/conformance/seed-contract.d.ts +11 -0
  43. package/dist/conformance/seed-contract.d.ts.map +1 -0
  44. package/dist/conformance/seed-contract.js +680 -0
  45. package/dist/conformance/seed-contract.js.map +1 -0
  46. package/dist/conformance/shapes.d.ts +35 -0
  47. package/dist/conformance/shapes.d.ts.map +1 -0
  48. package/dist/conformance/shapes.js +162 -0
  49. package/dist/conformance/shapes.js.map +1 -0
  50. package/dist/conformance/specs.d.ts +21 -0
  51. package/dist/conformance/specs.d.ts.map +1 -0
  52. package/dist/conformance/specs.js +306 -0
  53. package/dist/conformance/specs.js.map +1 -0
  54. package/dist/conformance/suite.d.ts +209 -0
  55. package/dist/conformance/suite.d.ts.map +1 -0
  56. package/dist/conformance/suite.js +1538 -0
  57. package/dist/conformance/suite.js.map +1 -0
  58. package/dist/conformance/unique.d.ts +7 -0
  59. package/dist/conformance/unique.d.ts.map +1 -0
  60. package/dist/conformance/unique.js +586 -0
  61. package/dist/conformance/unique.js.map +1 -0
  62. package/dist/conformance/worker.d.ts +3 -0
  63. package/dist/conformance/worker.d.ts.map +1 -0
  64. package/dist/conformance/worker.js +27 -0
  65. package/dist/conformance/worker.js.map +1 -0
  66. package/dist/index.d.ts +10 -3
  67. package/dist/index.js +6 -2
  68. package/dist/reference/defects.d.ts +71 -1
  69. package/dist/reference/defects.d.ts.map +1 -1
  70. package/dist/reference/defects.js +74 -8
  71. package/dist/reference/defects.js.map +1 -1
  72. package/dist/reference/dialect.d.ts +6 -4
  73. package/dist/reference/dialect.d.ts.map +1 -1
  74. package/dist/reference/dialect.js +1 -0
  75. package/dist/reference/dialect.js.map +1 -1
  76. package/dist/reference/http.d.ts +17 -25
  77. package/dist/reference/http.d.ts.map +1 -1
  78. package/dist/reference/http.js +574 -224
  79. package/dist/reference/http.js.map +1 -1
  80. package/dist/reference/listing.d.ts +51 -0
  81. package/dist/reference/listing.d.ts.map +1 -0
  82. package/dist/reference/listing.js +256 -0
  83. package/dist/reference/listing.js.map +1 -0
  84. package/dist/reference/model.d.ts +35 -1
  85. package/dist/reference/model.d.ts.map +1 -1
  86. package/dist/reference/model.js +42 -0
  87. package/dist/reference/model.js.map +1 -1
  88. package/dist/reference/query.d.ts +132 -43
  89. package/dist/reference/query.d.ts.map +1 -1
  90. package/dist/reference/query.js +397 -315
  91. package/dist/reference/query.js.map +1 -1
  92. package/dist/reference/shapes.d.ts +60 -0
  93. package/dist/reference/shapes.d.ts.map +1 -0
  94. package/dist/reference/shapes.js +142 -0
  95. package/dist/reference/shapes.js.map +1 -0
  96. package/dist/reference/spec.d.ts +9 -2
  97. package/dist/reference/spec.d.ts.map +1 -1
  98. package/dist/reference/spec.js +291 -71
  99. package/dist/reference/spec.js.map +1 -1
  100. package/dist/reference/store-api.d.ts +14 -43
  101. package/dist/reference/store-api.d.ts.map +1 -1
  102. package/dist/reference/store-api.js +8 -58
  103. package/dist/reference/store-api.js.map +1 -1
  104. package/dist/reference/stores/memory.d.ts +12 -15
  105. package/dist/reference/stores/memory.d.ts.map +1 -1
  106. package/dist/reference/stores/memory.js +22 -70
  107. package/dist/reference/stores/memory.js.map +1 -1
  108. package/dist/reference/stores/postgres.d.ts +11 -23
  109. package/dist/reference/stores/postgres.d.ts.map +1 -1
  110. package/dist/reference/stores/postgres.js +53 -334
  111. package/dist/reference/stores/postgres.js.map +1 -1
  112. package/dist/reference/stores/sql-compile.d.ts +36 -0
  113. package/dist/reference/stores/sql-compile.d.ts.map +1 -0
  114. package/dist/reference/stores/sql-compile.js +95 -0
  115. package/dist/reference/stores/sql-compile.js.map +1 -0
  116. package/dist/reference/stores/sqlite.d.ts +12 -27
  117. package/dist/reference/stores/sqlite.d.ts.map +1 -1
  118. package/dist/reference/stores/sqlite.js +54 -355
  119. package/dist/reference/stores/sqlite.js.map +1 -1
  120. package/dist/report/console.d.ts +2 -1
  121. package/dist/report/console.d.ts.map +1 -1
  122. package/dist/report/console.js +56 -10
  123. package/dist/report/console.js.map +1 -1
  124. package/dist/report/matrix.d.ts +10 -13
  125. package/dist/report/matrix.d.ts.map +1 -1
  126. package/dist/report/matrix.js +82 -18
  127. package/dist/report/matrix.js.map +1 -1
  128. package/dist/report/render.d.ts +18 -1
  129. package/dist/report/render.d.ts.map +1 -1
  130. package/dist/report/render.js +169 -14
  131. package/dist/report/render.js.map +1 -1
  132. package/dist/runtime/async.d.ts +16 -3
  133. package/dist/runtime/async.d.ts.map +1 -1
  134. package/dist/runtime/async.js +24 -19
  135. package/dist/runtime/async.js.map +1 -1
  136. package/dist/runtime/auth.d.ts +2 -9
  137. package/dist/runtime/auth.d.ts.map +1 -1
  138. package/dist/runtime/auth.js +38 -62
  139. package/dist/runtime/auth.js.map +1 -1
  140. package/dist/runtime/body.d.ts +15 -3
  141. package/dist/runtime/body.d.ts.map +1 -1
  142. package/dist/runtime/body.js +61 -12
  143. package/dist/runtime/body.js.map +1 -1
  144. package/dist/runtime/check-costs.d.ts +15 -0
  145. package/dist/runtime/check-costs.d.ts.map +1 -0
  146. package/dist/runtime/check-costs.js +114 -0
  147. package/dist/runtime/check-costs.js.map +1 -0
  148. package/dist/runtime/check-ids.d.ts +15 -0
  149. package/dist/runtime/check-ids.d.ts.map +1 -0
  150. package/dist/runtime/check-ids.js +140 -0
  151. package/dist/runtime/check-ids.js.map +1 -0
  152. package/dist/runtime/checks.d.ts +122 -18
  153. package/dist/runtime/checks.d.ts.map +1 -1
  154. package/dist/runtime/checks.js +3437 -1933
  155. package/dist/runtime/checks.js.map +1 -1
  156. package/dist/runtime/client.d.ts +112 -0
  157. package/dist/runtime/client.d.ts.map +1 -1
  158. package/dist/runtime/client.js +271 -39
  159. package/dist/runtime/client.js.map +1 -1
  160. package/dist/runtime/cookies.d.ts +16 -2
  161. package/dist/runtime/cookies.d.ts.map +1 -1
  162. package/dist/runtime/cookies.js +83 -22
  163. package/dist/runtime/cookies.js.map +1 -1
  164. package/dist/runtime/exchanges.d.ts +4 -5
  165. package/dist/runtime/exchanges.d.ts.map +1 -1
  166. package/dist/runtime/exchanges.js +33 -55
  167. package/dist/runtime/exchanges.js.map +1 -1
  168. package/dist/runtime/exit.d.ts +40 -0
  169. package/dist/runtime/exit.d.ts.map +1 -0
  170. package/dist/runtime/exit.js +48 -0
  171. package/dist/runtime/exit.js.map +1 -0
  172. package/dist/runtime/feature-gate.d.ts +0 -9
  173. package/dist/runtime/feature-gate.d.ts.map +1 -1
  174. package/dist/runtime/feature-gate.js +0 -21
  175. package/dist/runtime/feature-gate.js.map +1 -1
  176. package/dist/runtime/finding.d.ts +61 -7
  177. package/dist/runtime/finding.d.ts.map +1 -1
  178. package/dist/runtime/finding.js +74 -12
  179. package/dist/runtime/finding.js.map +1 -1
  180. package/dist/runtime/fixture.d.ts +35 -28
  181. package/dist/runtime/fixture.d.ts.map +1 -1
  182. package/dist/runtime/fixture.js +92 -731
  183. package/dist/runtime/fixture.js.map +1 -1
  184. package/dist/runtime/generate.d.ts +63 -0
  185. package/dist/runtime/generate.d.ts.map +1 -0
  186. package/dist/runtime/generate.js +635 -0
  187. package/dist/runtime/generate.js.map +1 -0
  188. package/dist/runtime/headers.d.ts +6 -0
  189. package/dist/runtime/headers.d.ts.map +1 -0
  190. package/dist/runtime/headers.js +16 -0
  191. package/dist/runtime/headers.js.map +1 -0
  192. package/dist/runtime/path.d.ts +7 -0
  193. package/dist/runtime/path.d.ts.map +1 -0
  194. package/dist/runtime/path.js +44 -0
  195. package/dist/runtime/path.js.map +1 -0
  196. package/dist/runtime/payloads.d.ts +2 -0
  197. package/dist/runtime/payloads.d.ts.map +1 -1
  198. package/dist/runtime/payloads.js +2 -1
  199. package/dist/runtime/payloads.js.map +1 -1
  200. package/dist/runtime/rate-limit.d.ts +6 -4
  201. package/dist/runtime/rate-limit.d.ts.map +1 -1
  202. package/dist/runtime/rate-limit.js +22 -33
  203. package/dist/runtime/rate-limit.js.map +1 -1
  204. package/dist/runtime/redact.d.ts +24 -0
  205. package/dist/runtime/redact.d.ts.map +1 -0
  206. package/dist/runtime/redact.js +98 -0
  207. package/dist/runtime/redact.js.map +1 -0
  208. package/dist/runtime/run.d.ts +51 -2
  209. package/dist/runtime/run.d.ts.map +1 -1
  210. package/dist/runtime/run.js +463 -195
  211. package/dist/runtime/run.js.map +1 -1
  212. package/dist/runtime/runs.d.ts +5 -0
  213. package/dist/runtime/runs.d.ts.map +1 -1
  214. package/dist/runtime/runs.js +7 -1
  215. package/dist/runtime/runs.js.map +1 -1
  216. package/dist/runtime/scope.d.ts +163 -0
  217. package/dist/runtime/scope.d.ts.map +1 -0
  218. package/dist/runtime/scope.js +423 -0
  219. package/dist/runtime/scope.js.map +1 -0
  220. package/dist/runtime/teardown.d.ts +17 -4
  221. package/dist/runtime/teardown.d.ts.map +1 -1
  222. package/dist/runtime/teardown.js +49 -16
  223. package/dist/runtime/teardown.js.map +1 -1
  224. package/dist/runtime/transcript.d.ts +34 -2
  225. package/dist/runtime/transcript.d.ts.map +1 -1
  226. package/dist/runtime/transcript.js +119 -24
  227. package/dist/runtime/transcript.js.map +1 -1
  228. package/dist/runtime/unique.d.ts +2 -11
  229. package/dist/runtime/unique.d.ts.map +1 -1
  230. package/dist/runtime/unique.js +27 -53
  231. package/dist/runtime/unique.js.map +1 -1
  232. package/dist/runtime/validate.d.ts +13 -2
  233. package/dist/runtime/validate.d.ts.map +1 -1
  234. package/dist/runtime/validate.js +51 -31
  235. package/dist/runtime/validate.js.map +1 -1
  236. package/dist/runtime/wait.d.ts +2 -1
  237. package/dist/runtime/wait.d.ts.map +1 -1
  238. package/dist/runtime/wait.js +2 -36
  239. package/dist/runtime/wait.js.map +1 -1
  240. package/dist/runtime/world.d.ts +31 -3
  241. package/dist/runtime/world.d.ts.map +1 -1
  242. package/dist/runtime/world.js +113 -32
  243. package/dist/runtime/world.js.map +1 -1
  244. package/dist/spec/collection.d.ts +8 -2
  245. package/dist/spec/collection.d.ts.map +1 -1
  246. package/dist/spec/collection.js +47 -7
  247. package/dist/spec/collection.js.map +1 -1
  248. package/dist/spec/extensions.d.ts +5 -1
  249. package/dist/spec/extensions.d.ts.map +1 -1
  250. package/dist/spec/extensions.js +9 -2
  251. package/dist/spec/extensions.js.map +1 -1
  252. package/dist/spec/graph.d.ts +46 -0
  253. package/dist/spec/graph.d.ts.map +1 -1
  254. package/dist/spec/graph.js +175 -11
  255. package/dist/spec/graph.js.map +1 -1
  256. package/dist/spec/load.d.ts +25 -5
  257. package/dist/spec/load.d.ts.map +1 -1
  258. package/dist/spec/load.js +160 -59
  259. package/dist/spec/load.js.map +1 -1
  260. package/dist/spec/query-capabilities.d.ts +4 -0
  261. package/dist/spec/query-capabilities.d.ts.map +1 -1
  262. package/dist/spec/query-capabilities.js +6 -0
  263. package/dist/spec/query-capabilities.js.map +1 -1
  264. package/dist/spec/schema.d.ts +20 -0
  265. package/dist/spec/schema.d.ts.map +1 -0
  266. package/dist/spec/schema.js +118 -0
  267. package/dist/spec/schema.js.map +1 -0
  268. package/dist/spec/tag-schema.d.ts +12 -0
  269. package/dist/spec/tag-schema.d.ts.map +1 -0
  270. package/dist/spec/tag-schema.js +143 -0
  271. package/dist/spec/tag-schema.js.map +1 -0
  272. package/dist/spec/types.js +17 -1
  273. package/dist/spec/types.js.map +1 -1
  274. package/docs/api.md +59 -0
  275. package/docs/assumptions.md +99 -0
  276. package/docs/checks.md +183 -0
  277. package/docs/commands.md +214 -0
  278. package/docs/configuration.md +650 -0
  279. package/docs/model.md +184 -0
  280. package/docs/reference-defects.md +94 -0
  281. package/docs/reports.md +211 -0
  282. package/docs/tags.md +400 -0
  283. 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
+ ```