@lovrozagar/oat 0.1.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 (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +401 -0
  3. package/bin/oat.js +2 -0
  4. package/dist/cli.js +386 -0
  5. package/dist/config/define-config.d.ts +165 -0
  6. package/dist/config/define-config.d.ts.map +1 -0
  7. package/dist/config/define-config.js +24 -0
  8. package/dist/config/define-config.js.map +1 -0
  9. package/dist/config/load.d.ts +10 -0
  10. package/dist/config/load.d.ts.map +1 -0
  11. package/dist/config/load.js +61 -0
  12. package/dist/config/load.js.map +1 -0
  13. package/dist/index.d.ts +17 -0
  14. package/dist/index.js +9 -0
  15. package/dist/reference/defects.d.ts +254 -0
  16. package/dist/reference/defects.d.ts.map +1 -0
  17. package/dist/reference/defects.js +265 -0
  18. package/dist/reference/defects.js.map +1 -0
  19. package/dist/reference/dialect.d.ts +104 -0
  20. package/dist/reference/dialect.d.ts.map +1 -0
  21. package/dist/reference/dialect.js +174 -0
  22. package/dist/reference/dialect.js.map +1 -0
  23. package/dist/reference/http.d.ts +79 -0
  24. package/dist/reference/http.d.ts.map +1 -0
  25. package/dist/reference/http.js +889 -0
  26. package/dist/reference/http.js.map +1 -0
  27. package/dist/reference/model.d.ts +56 -0
  28. package/dist/reference/model.d.ts.map +1 -0
  29. package/dist/reference/model.js +156 -0
  30. package/dist/reference/model.js.map +1 -0
  31. package/dist/reference/query.d.ts +45 -0
  32. package/dist/reference/query.d.ts.map +1 -0
  33. package/dist/reference/query.js +353 -0
  34. package/dist/reference/query.js.map +1 -0
  35. package/dist/reference/spec.d.ts +13 -0
  36. package/dist/reference/spec.d.ts.map +1 -0
  37. package/dist/reference/spec.js +547 -0
  38. package/dist/reference/spec.js.map +1 -0
  39. package/dist/reference/store-api.d.ts +59 -0
  40. package/dist/reference/store-api.d.ts.map +1 -0
  41. package/dist/reference/store-api.js +76 -0
  42. package/dist/reference/store-api.js.map +1 -0
  43. package/dist/reference/stores/memory.d.ts +27 -0
  44. package/dist/reference/stores/memory.d.ts.map +1 -0
  45. package/dist/reference/stores/memory.js +97 -0
  46. package/dist/reference/stores/memory.js.map +1 -0
  47. package/dist/reference/stores/postgres.d.ts +61 -0
  48. package/dist/reference/stores/postgres.d.ts.map +1 -0
  49. package/dist/reference/stores/postgres.js +406 -0
  50. package/dist/reference/stores/postgres.js.map +1 -0
  51. package/dist/reference/stores/sqlite-driver.d.ts +54 -0
  52. package/dist/reference/stores/sqlite-driver.d.ts.map +1 -0
  53. package/dist/reference/stores/sqlite-driver.js +99 -0
  54. package/dist/reference/stores/sqlite-driver.js.map +1 -0
  55. package/dist/reference/stores/sqlite.d.ts +68 -0
  56. package/dist/reference/stores/sqlite.d.ts.map +1 -0
  57. package/dist/reference/stores/sqlite.js +464 -0
  58. package/dist/reference/stores/sqlite.js.map +1 -0
  59. package/dist/report/console.d.ts +33 -0
  60. package/dist/report/console.d.ts.map +1 -0
  61. package/dist/report/console.js +237 -0
  62. package/dist/report/console.js.map +1 -0
  63. package/dist/report/matrix.d.ts +131 -0
  64. package/dist/report/matrix.d.ts.map +1 -0
  65. package/dist/report/matrix.js +865 -0
  66. package/dist/report/matrix.js.map +1 -0
  67. package/dist/report/render.d.ts +66 -0
  68. package/dist/report/render.d.ts.map +1 -0
  69. package/dist/report/render.js +423 -0
  70. package/dist/report/render.js.map +1 -0
  71. package/dist/runtime/async.d.ts +34 -0
  72. package/dist/runtime/async.d.ts.map +1 -0
  73. package/dist/runtime/async.js +130 -0
  74. package/dist/runtime/async.js.map +1 -0
  75. package/dist/runtime/auth.d.ts +46 -0
  76. package/dist/runtime/auth.d.ts.map +1 -0
  77. package/dist/runtime/auth.js +236 -0
  78. package/dist/runtime/auth.js.map +1 -0
  79. package/dist/runtime/checks.d.ts +89 -0
  80. package/dist/runtime/checks.d.ts.map +1 -0
  81. package/dist/runtime/checks.js +3400 -0
  82. package/dist/runtime/checks.js.map +1 -0
  83. package/dist/runtime/client.d.ts +65 -0
  84. package/dist/runtime/client.d.ts.map +1 -0
  85. package/dist/runtime/client.js +122 -0
  86. package/dist/runtime/client.js.map +1 -0
  87. package/dist/runtime/finding.d.ts +50 -0
  88. package/dist/runtime/finding.d.ts.map +1 -0
  89. package/dist/runtime/finding.js +50 -0
  90. package/dist/runtime/finding.js.map +1 -0
  91. package/dist/runtime/fixture.d.ts +20 -0
  92. package/dist/runtime/fixture.d.ts.map +1 -0
  93. package/dist/runtime/fixture.js +175 -0
  94. package/dist/runtime/fixture.js.map +1 -0
  95. package/dist/runtime/progress.d.ts +41 -0
  96. package/dist/runtime/progress.d.ts.map +1 -0
  97. package/dist/runtime/progress.js +186 -0
  98. package/dist/runtime/progress.js.map +1 -0
  99. package/dist/runtime/run.d.ts +69 -0
  100. package/dist/runtime/run.d.ts.map +1 -0
  101. package/dist/runtime/run.js +575 -0
  102. package/dist/runtime/run.js.map +1 -0
  103. package/dist/runtime/teardown.d.ts +38 -0
  104. package/dist/runtime/teardown.d.ts.map +1 -0
  105. package/dist/runtime/teardown.js +129 -0
  106. package/dist/runtime/teardown.js.map +1 -0
  107. package/dist/runtime/validate.d.ts +31 -0
  108. package/dist/runtime/validate.d.ts.map +1 -0
  109. package/dist/runtime/validate.js +111 -0
  110. package/dist/runtime/validate.js.map +1 -0
  111. package/dist/runtime/world.d.ts +49 -0
  112. package/dist/runtime/world.d.ts.map +1 -0
  113. package/dist/runtime/world.js +119 -0
  114. package/dist/runtime/world.js.map +1 -0
  115. package/dist/spec/collection.d.ts +31 -0
  116. package/dist/spec/collection.d.ts.map +1 -0
  117. package/dist/spec/collection.js +121 -0
  118. package/dist/spec/collection.js.map +1 -0
  119. package/dist/spec/conventions.d.ts +103 -0
  120. package/dist/spec/conventions.d.ts.map +1 -0
  121. package/dist/spec/conventions.js +275 -0
  122. package/dist/spec/conventions.js.map +1 -0
  123. package/dist/spec/extensions.d.ts +74 -0
  124. package/dist/spec/extensions.d.ts.map +1 -0
  125. package/dist/spec/extensions.js +335 -0
  126. package/dist/spec/extensions.js.map +1 -0
  127. package/dist/spec/graph.d.ts +89 -0
  128. package/dist/spec/graph.d.ts.map +1 -0
  129. package/dist/spec/graph.js +318 -0
  130. package/dist/spec/graph.js.map +1 -0
  131. package/dist/spec/load.d.ts +36 -0
  132. package/dist/spec/load.d.ts.map +1 -0
  133. package/dist/spec/load.js +166 -0
  134. package/dist/spec/load.js.map +1 -0
  135. package/dist/spec/types.d.ts +57 -0
  136. package/dist/spec/types.d.ts.map +1 -0
  137. package/dist/spec/types.js +24 -0
  138. package/dist/spec/types.js.map +1 -0
  139. package/labs/README.md +130 -0
  140. package/labs/annotated-openapi.yaml +311 -0
  141. package/labs/anyrow.config.ts +107 -0
  142. package/labs/demo.mjs +124 -0
  143. package/labs/local.config.ts +41 -0
  144. package/labs/minimal.config.ts +32 -0
  145. package/package.json +80 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lovrozagar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,401 @@
1
+ # oat
2
+
3
+ OpenAPI Tester — behavioural testing of a live backend against its OpenAPI specification.
4
+
5
+ Schema validators answer *did this response match its declared shape?* Most real API bugs do not violate a schema:
6
+
7
+ - a resource exists on `GET /tables/{id}` but not on `GET /tables`
8
+ - `?filter=status.eq.nope` returns every row, because the backend dropped the param
9
+ - paging at `limit=2` yields 9 rows; `limit=100` yields 10 — the sort has no total order
10
+ - `PATCH { name }` also cleared `instruction`
11
+ - `?filter=id.eq.<another tenant's id>` returns the row
12
+
13
+ oat asserts the same fact through every projection the API offers, then compares those projections against each other.
14
+
15
+ ## Table of contents
16
+
17
+ - [Install](#install)
18
+ - [Quick start](#quick-start)
19
+ - [Commands](#commands)
20
+ - [Configuration](#configuration)
21
+ - [OpenAPI meta tags](#openapi-meta-tags)
22
+ - [What gets tested](#what-gets-tested)
23
+ - [Reports](#reports)
24
+ - [Conformance](#conformance)
25
+ - [What this is not](#what-this-is-not)
26
+ - [Labs](#labs)
27
+ - [License](#license)
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ npm i -D @lovrozagar/oat
33
+ ```
34
+
35
+ Requires Node.js 20 or later.
36
+
37
+ ## Quick start
38
+
39
+ oat ships a demo API — the same reference backend its self-test uses — so you can try it before pointing it at anything real:
40
+
41
+ ```bash
42
+ # terminal 1
43
+ oat serve --defects STALE_LIST,PATCH_REPLACES
44
+
45
+ # terminal 2
46
+ oat run --config labs/local.config.ts --base-url <url printed above>
47
+ ```
48
+
49
+ ```
50
+ 25 checks · 3 entities · 164 requests · 0.2s
51
+
52
+ BACKEND DEFECTS (5)
53
+ table list projection does not reflect a completed write
54
+ table PATCH changed fields the request did not mention
55
+
56
+ cleaned up 22/22 created record(s)
57
+ report: oat-out/oat-report.md
58
+ ```
59
+
60
+ `oat-out/` holds a markdown report, a JSON copy for CI, and one runnable `curl` script per finding (`issue-repro/`). Drop `--defects` and a correct backend reports nothing across 55 checks.
61
+
62
+ Against your own API:
63
+
64
+ ```bash
65
+ oat doctor --spec https://api.example.com/openapi.json
66
+ oat plan --spec https://api.example.com/openapi.json
67
+ oat run --config oat.config.ts
68
+ ```
69
+
70
+ `doctor` is the adoption command. It runs offline against the spec alone and reports every coverage gap, naming the meta tag that would close it.
71
+
72
+ ## Commands
73
+
74
+ ```
75
+ oat run --config <file> test a live backend and write a report
76
+ oat plan --spec <url|file> derive and print the test model (offline)
77
+ oat doctor --spec <url|file> report what oat can and cannot test, and why
78
+ oat serve [--defects A,B] run the demo API
79
+ oat conformance self-test: injected defects vs detection
80
+ ```
81
+
82
+ Useful flags:
83
+
84
+ | flag | |
85
+ | --- | --- |
86
+ | `--config` | config module (`.ts` / `.js` / `.mjs` / `.json`) with a default export |
87
+ | `--spec` | OpenAPI document, URL or path |
88
+ | `--base-url` | backend under test, overrides the config |
89
+ | `--only` | comma-separated entity names |
90
+ | `--out` | report directory (default `./oat-out`) |
91
+ | `--concurrency` | entities tested in parallel (default 1) |
92
+ | `--quiet` | no live progress on stderr (`progress.log` is still written) |
93
+ | `--keep-fixtures` | leave created records in place |
94
+ | `--backend` | conformance storage: `memory` \| `sqlite` \| `postgres` \| `d1` |
95
+ | `--dialect` | API shape: `postgrest` \| `classic` \| `linked` \| `jsonapi` \| `plain` |
96
+ | `--fuzz` | inject random defect combinations |
97
+ | `--precision` | vary cohort data against a correct backend |
98
+
99
+ ## Configuration
100
+
101
+ Two inputs, always: the spec, and a config file. oat never reads your source, assumes your framework, or hardcodes a route. Backend-specific knowledge lives in one of two places:
102
+
103
+ - **`x-*` tags in the spec** — optional; each degrades to a heuristic and reports the degradation rather than guessing silently. A complete worked document is [`labs/annotated-openapi.yaml`](./labs/annotated-openapi.yaml).
104
+ - **`oat.config.ts`** — auth, roots, and the one hook oat cannot infer: values delivered outside HTTP (verification tokens, OTPs).
105
+
106
+ A backend adopts oat by adding tags, not by adapting to oat.
107
+
108
+ Smallest useful config — a long-lived API key and two tenants, so isolation checks can run:
109
+
110
+ ```ts
111
+ import { defineConfig } from "@lovrozagar/oat"
112
+
113
+ export default defineConfig({
114
+ baseUrl: "https://api.example.com",
115
+ spec: "https://api.example.com/openapi.json",
116
+ principals: [
117
+ {
118
+ id: "alpha",
119
+ headers: { authorization: `Bearer ${process.env.API_TOKEN}` },
120
+ roots: { project_id: process.env.PROJECT_A },
121
+ },
122
+ {
123
+ id: "beta",
124
+ headers: { authorization: `Bearer ${process.env.API_TOKEN_B}` },
125
+ roots: { project_id: process.env.PROJECT_B },
126
+ },
127
+ ],
128
+ })
129
+ ```
130
+
131
+ Multi-step login is a chain of operations. Values flow forward through `saveAs`; the credential is taken from the last response:
132
+
133
+ ```ts
134
+ {
135
+ id: "alpha",
136
+ auth: {
137
+ credentialFrom: "$.access_token",
138
+ steps: [{ operationId: "auth.token", body: { key: process.env.API_KEY } }],
139
+ },
140
+ roots: { org_id: "org_alpha" },
141
+ }
142
+ ```
143
+
144
+ `resolveOutOfBand` on the config is the hook for emailed tokens and OTPs. Credentials refresh from JWT `exp` before they expire. See [`labs/minimal.config.ts`](./labs/minimal.config.ts) and [`labs/anyrow.config.ts`](./labs/anyrow.config.ts).
145
+
146
+ ## OpenAPI meta tags
147
+
148
+ Vendor-neutral `x-*` extensions oat reads from your spec. Every one is **optional**. Precedence is always: **explicit tag → heuristic → skip with a coverage gap**.
149
+
150
+ `oat conformance` loads [`labs/annotated-openapi.yaml`](./labs/annotated-openapi.yaml) and asserts the derived model matches what its comments claim, so the examples cannot drift from the implementation.
151
+
152
+ ```bash
153
+ oat plan --spec openapi.yaml
154
+ oat doctor --spec openapi.yaml
155
+ ```
156
+
157
+ ### `x-invalidate`
158
+
159
+ ```yaml
160
+ x-invalidate:
161
+ - GET /v1/projects/{project_id}/tables
162
+ - GET /v1/projects/{project_id}/tables/{table_id}
163
+ ```
164
+
165
+ `string[]` of `"METHOD /path"`. Names the read routes this mutation must change. Colon (`/tables/:id`) and brace (`/tables/{id}`) syntax both work; oat normalises to brace.
166
+
167
+ This is the entity graph. Inverted, it gives every entity its *read surface* — the projections through which it is observable. Highest-value tag, and the one many specs already emit.
168
+
169
+ **Fallback:** pair a mutator with the sibling collection and item routes on the same path prefix. Catches the obvious cases, misses cross-entity effects.
170
+
171
+ ### `x-entity`
172
+
173
+ ```yaml
174
+ x-entity:
175
+ name: table
176
+ action: create # create | list | read | update | delete | action
177
+ identity: id # optional; property that identifies an instance
178
+ ```
179
+
180
+ Overrides path-segment entity inference. Needed when the path does not follow `/<plural>/{id}`.
181
+
182
+ `identity` matters when the item schema does not declare an `id` — for example free-form row objects.
183
+
184
+ **Fallback:** deepest plural segment + HTTP verb. Irregular paths (`/rows/aggregate`) yield nothing and lose lifecycle tracking.
185
+
186
+ ### `x-invite`
187
+
188
+ ```yaml
189
+ x-invite:
190
+ invite: table.invite # operationId that creates the invite
191
+ accept: invite.accept # operationId the invitee calls
192
+ revoke: table.revoke # operationId that removes the grant
193
+ granteeField: key # invite body field naming the invitee
194
+ tokenPointer: $.token # where the accept token is
195
+ grantPointer: $.grant_id # where the revoke handle is
196
+ ```
197
+
198
+ Declares the delegated-access flow. oat asserts the timeline: the invitee cannot read before accept, can after, and cannot after revoke. Put the tag on the invite operation.
199
+
200
+ The invitee's config must set `inviteAs` to the value the invite body expects.
201
+
202
+ **Fallback:** the check does not run. There is no heuristic for a multi-step flow.
203
+
204
+ ### `x-query`
205
+
206
+ ```yaml
207
+ x-query:
208
+ filterable: [id, name, slug, status, created_at]
209
+ sortable: [name, created_at, updated_at]
210
+ searchable: [name, slug]
211
+ selectable: [id, name, slug, created_at]
212
+ maxLimit: 100
213
+ defaultOrder: created_at.desc
214
+ stableTiebreak: id
215
+ ```
216
+
217
+ Declares what the list endpoint's `filter` / `order` / `q` / `select` params actually support.
218
+
219
+ Without it, oat has to guess which fields are filterable from the item schema and will report false failures on fields the backend does not index.
220
+
221
+ `stableTiebreak` is load-bearing: if a sort has no total order, keyset pagination is unsound and page walks silently drop or duplicate rows. Declaring it lets oat assert it; omitting it makes oat test for the instability instead.
222
+
223
+ **Fallback:** if a filter / order / select / search *role* resolves (`sort`, `fields`, `q`, `where`, …), treat every scalar property as filterable/sortable and warn. Pagination-only lists stay uncovered.
224
+
225
+ ### `x-async`
226
+
227
+ ```yaml
228
+ x-async:
229
+ poll: "GET /v1/projects/{project_id}/batches/{batch_id}"
230
+ idFrom: batch_id
231
+ until: "status.in.complete,partial,failed"
232
+ successWhen: "status.eq.complete"
233
+ timeoutMs: 120000
234
+ pollIntervalMs: 2000
235
+ ```
236
+
237
+ Marks an operation whose HTTP response is a receipt, not an outcome. oat drives the poll to a terminal state, then treats that payload as the real result.
238
+
239
+ Without this, every extract / export / batch endpoint is untestable beyond "did it return 200".
240
+
241
+ **Fallback:** treated as synchronous; downstream assertions are marked `COVERAGE_GAP`.
242
+
243
+ ### `x-effects`
244
+
245
+ ```yaml
246
+ x-effects:
247
+ - { entity: table, op: create, count: 1 }
248
+ - { entity: delivery, op: append, count: 1 }
249
+ ```
250
+
251
+ `x-invalidate` says *this GET changes*; `x-effects` says *how*. That is what lets oat assert an exact delta (`count 4 → 5`, new id present) instead of a weak body inequality.
252
+
253
+ Also tells oat to track and clean up resources created as side effects.
254
+
255
+ `op`: `create` | `append` | `update` | `delete` | `replace`.
256
+
257
+ **Fallback:** derived from `x-entity.action` for the operation's own entity only.
258
+
259
+ ### `x-soft-delete`
260
+
261
+ ```yaml
262
+ x-soft-delete: deleted_at
263
+ ```
264
+
265
+ On the entity's delete (or any operation on the entity). Tells oat that DELETE tombstones rather than removes.
266
+
267
+ Changes the post-delete assertion from "absent everywhere" to "absent from the default list". Without this tag, correct soft-delete behaviour is reported as a bug.
268
+
269
+ ### `x-immutable` / `x-generated`
270
+
271
+ ```yaml
272
+ x-immutable: [id, project_id, created_at]
273
+ x-generated: [id, created_at, updated_at]
274
+ ```
275
+
276
+ `x-generated` lets oat build valid create bodies without guessing, and assert the fields appear. `x-immutable` powers the update-safety case: PATCH each immutable field and require the stored value to be unchanged.
277
+
278
+ **Fallback:** `readOnly: true` is honoured as generated; no immutability testing without the tag.
279
+
280
+ ### `x-tenant`
281
+
282
+ ```yaml
283
+ x-tenant: project_id
284
+ ```
285
+
286
+ Names the path parameter that scopes the operation to a tenant.
287
+
288
+ Drives the isolation matrix: a second principal in another tenant must not see, read, or mutate the record — and `filter=id.eq.<other tenant's id>` must return empty rather than becoming an authz bypass.
289
+
290
+ **Fallback:** regex over `{organization_id}`, `{project_id}`, `{tenant_id}`, `{workspace_id}`, `{app_slug}`. Without the tag, a cross-tenant read is an ambiguity, not a security finding.
291
+
292
+ ### `x-root`
293
+
294
+ ```yaml
295
+ x-root: true
296
+ ```
297
+
298
+ On a **path parameter**, not an operation. Declares that this resource has no create endpoint and must be supplied by config.
299
+
300
+ **Fallback:** inferred when a path param has no discoverable create op; everything beneath it is reported `UNSEEDABLE`.
301
+
302
+ ### `x-cost` / `x-destructive` / `x-idempotent`
303
+
304
+ ```yaml
305
+ x-cost: high # low | medium | high
306
+ x-destructive: true # excluded outside --profile paranoid
307
+ x-idempotent: true # oat asserts repeat-equivalence
308
+ ```
309
+
310
+ `x-cost: high` is why an extract runs once instead of inside a permutation loop.
311
+
312
+ ### `x-cleanup`
313
+
314
+ ```yaml
315
+ x-cleanup: "DELETE /v1/projects/{project_id}/tables/{table_id}"
316
+ ```
317
+
318
+ Explicit teardown when the delete op is not discoverable from the entity graph.
319
+
320
+ **Fallback:** the entity's `delete` op; oat reports leaked resources at end of run if there is none.
321
+
322
+ ### `x-fresh-principal`
323
+
324
+ ```yaml
325
+ x-fresh-principal: true
326
+ ```
327
+
328
+ Operation mutates session or principal state and must run against a freshly provisioned principal (login, logout, token refresh, key rotation).
329
+
330
+ ## What gets tested
331
+
332
+ 55 property checks. None of them needs ground truth about what your data "should" be. A filter and its negation must partition the set; a page walk must cover the collection; a record read four ways must read the same.
333
+
334
+ | group | examples |
335
+ | --- | --- |
336
+ | Foundations | create lands, schema matches, page size is honoured |
337
+ | Query, single axis | filter, sort, search, select, cursor vs page, count |
338
+ | Query, composition | filter+sort, filter+select, search+filter, and the triples |
339
+ | Spec as adversary | declared filterable / sortable / selectable fields actually work |
340
+ | Isolation | cross-tenant item, filter, and existence disclosure |
341
+ | Authorization | rank is monotonic; invite grants then revokes |
342
+ | Tagged behaviour | immutable, soft-delete, invalidate, effects, async |
343
+ | Validation | enum, maxLength, required, content-type |
344
+ | Writes | PATCH minimality, idempotency, delete 404, lost update |
345
+
346
+ Every check reaches a stated outcome: a finding, *did not apply* (with what it needed), *blocked by an earlier failure* (with the cause), or *could not conclude* (with why). None of the last three is a pass.
347
+
348
+ Findings are graded by how much the document states. A cross-tenant read is a security finding when `x-tenant` declares the boundary, and an ambiguity when oat only inferred it.
349
+
350
+ ## Reports
351
+
352
+ A run writes:
353
+
354
+ | file | |
355
+ | --- | --- |
356
+ | `oat-out/oat-report.md` | human report |
357
+ | `oat-out/oat-report.json` | CI / machine copy |
358
+ | `oat-out/issue-repro/*.sh` | one `curl` script per finding — omitted when the run is clean |
359
+ | `oat-out/progress.log` | live logfmt progress (also `.jsonl` and `.tsv`) |
360
+
361
+ Cascade suppression is transitive: one root cause produces one finding, not a page of consequences.
362
+
363
+ ## Conformance
364
+
365
+ oat ships four reference backends — memory, SQLite, Postgres, and Cloudflare D1 — behind the same HTTP surface and the same 56 named defects. `oat conformance` injects each defect and asserts the matching diagnosis, then runs a correct backend and asserts nothing is reported.
366
+
367
+ They are served in five dialects (`postgrest`, `classic`, `linked`, `jsonapi`, `plain`) so a check that only works on one spelling is visible.
368
+
369
+ ```bash
370
+ npm test # memory + sqlite, every dialect
371
+ oat conformance --fuzz 300 # random defect combinations
372
+ oat conformance --precision 60 # varied data, correct backend — any finding is a false positive
373
+ ```
374
+
375
+ The testing path never touches a database. Runtime dependencies are `ajv`, `ajv-formats`, and `yaml`. SQL drivers are optional and loaded on demand.
376
+
377
+ ## What this is not
378
+
379
+ oat is not a fuzzer. Tools like [Schemathesis](https://schemathesis.readthedocs.io/) generate inputs from your schema and check that responses validate — that finds crashes, 500s, and schema drift. Run both.
380
+
381
+ The difference is *statefulness*. A fuzzer sends independent requests and has no model of what the API should now contain, so it cannot ask whether the record it just created appears in the listing, whether a filter and its negation partition the set, or whether another tenant can read a record they should not see.
382
+
383
+ | | schema fuzzers | oat |
384
+ | --- | --- | --- |
385
+ | multi-step auth | header injection | declarative chain, JSON-path binding |
386
+ | out-of-band token | — | `resolveOutOfBand` hook |
387
+ | credential refresh | — | JWT `exp`, refreshes before expiry |
388
+ | expected-state oracle | none | shadow model of everything it created |
389
+ | N live principals | — | peer tenants + optional rank lattice |
390
+ | cross-tenant read / filter / disclosure | — | 3 checks |
391
+ | stateful invariants | random walk | 55 property checks |
392
+ | N-role permission matrix | — | monotonicity over `rank` |
393
+ | invite / delegated access | — | `x-invite` timeline |
394
+
395
+ ## Labs
396
+
397
+ [`labs/`](./labs) is a family of real Hono backends on Cloudflare D1, used to iterate oat against correct APIs and planted bugs. Schema is generated from [`labs/worlds/catalog.ts`](./labs/worlds/catalog.ts). See [`labs/README.md`](./labs/README.md).
398
+
399
+ ## License
400
+
401
+ MIT
package/bin/oat.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../dist/cli.js"