@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
package/docs/tags.md ADDED
@@ -0,0 +1,400 @@
1
+ # oat — OpenAPI meta tags
2
+
3
+ [← back to the overview](../README.md)
4
+
5
+ ## OpenAPI meta tags
6
+
7
+ Vendor-neutral `x-*` extensions. Every one is optional. Precedence: **explicit tag → heuristic → skip with a coverage gap**.
8
+
9
+ A complete document with every tag in place is shipped as `labs/annotated-openapi.yaml` (also in the npm package). `oat conformance` asserts the derived model matches that file.
10
+
11
+ ```bash
12
+ oat plan --spec node_modules/@lovrozagar/oat/labs/annotated-openapi.yaml
13
+ oat doctor --spec node_modules/@lovrozagar/oat/labs/annotated-openapi.yaml
14
+ ```
15
+
16
+ What each tag **unlocks** (otherwise the check cannot run):
17
+
18
+ | tag | checks unlocked |
19
+ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
20
+ | `x-async` | `async.reaches-terminal-state`, `async.receipt-identifies-the-job` |
21
+ | `x-effects` | `effects.declared-effect-occurs` |
22
+ | `x-immutable` | `patch.immutable-field-rejected` |
23
+ | `x-invalidate` | `invalidation.declared-route-changes` (when the list names another entity) |
24
+ | `x-query` | `spec.declared-filterable-is-filterable`, `spec.declared-sortable-is-sortable`, `spec.declared-selectable-is-selectable`, plus the declared-or-skip catalog checks (`in` / `ilike` / `is` / nulls / caps / …) |
25
+ | `x-soft-delete` | `softdelete.absent-from-default-list` |
26
+ | `x-invite` | `auth.invite-grants-then-revokes` |
27
+ | `x-wait` | `effects.side-effect-arrives` |
28
+ | `x-unique` | `create.unique-conflict-rejected`, `update.unique-conflict-rejected` |
29
+
30
+ What each tag **sharpens** (the check already runs, but the verdict changes):
31
+
32
+ | tag | without it |
33
+ | ---------- | --------------------------------------------------------------------------------------------------------------------------- |
34
+ | `x-query` | every scalar is probed, including columns you never indexed — expect findings you will dismiss |
35
+ | `x-tenant` | inferred tenant: a cross-tenant read is `AMBIGUITY`, not `SECURITY`. No tenant tagged or inferred: the check does not apply |
36
+
37
+ ### `x-invalidate`
38
+
39
+ ```yaml
40
+ x-invalidate:
41
+ - GET /v1/projects/{project_id}/tables
42
+ - GET /v1/projects/{project_id}/tables/{table_id}
43
+ ```
44
+
45
+ `string[]` of `"METHOD /path"`. Colon or brace path params; oat normalises to brace. Method is uppercased.
46
+
47
+ This is the entity graph. Inverted, it is each entity's read surface. Highest-value tag.
48
+
49
+ **Fallback:** pair a mutator with sibling collection/item routes on the same path prefix. Misses cross-entity effects.
50
+
51
+ **Unlocks:** `invalidation.declared-route-changes` (when the list names _another_ entity's route).
52
+
53
+ ### `x-entity`
54
+
55
+ ```yaml
56
+ x-entity:
57
+ name: table
58
+ action: create # create | list | read | update | delete | action
59
+ identity: id
60
+ ```
61
+
62
+ Overrides path-segment inference. `identity` is required when the item schema has no `id` (or `uuid` / `slug` / `key` / `name`).
63
+
64
+ **Fallback:** deepest plural segment + HTTP verb. See [How the model is derived](model.md#how-the-model-is-derived).
65
+
66
+ ### `x-invite`
67
+
68
+ ```yaml
69
+ x-invite:
70
+ invite: table.invite
71
+ accept: invite.accept
72
+ revoke: table.revoke
73
+ granteeField: key
74
+ tokenPointer: $.token
75
+ grantPointer: $.grant_id
76
+ tokenFrom: response # or outOfBand
77
+ tokenKind: org-invite # only when tokenFrom is outOfBand; default `${entity}-invite`
78
+ acceptFrom: token # default. `link` = GET the OOB URL instead of POSTing accept JSON
79
+ ```
80
+
81
+ Put this on the invite operation. Config must give the invitee `inviteAs`. Defaults if omitted: `granteeField: key`, `tokenPointer: $.token`, `grantPointer: $.grant_id`, `tokenFrom: response`, `acceptFrom: token`. All three of `invite` / `accept` / `revoke` (operationIds) are required or the tag is ignored. `oat doctor` / `oat plan` print the accept mode.
82
+
83
+ `tokenFrom: response` (default) reads the accept token from the invite HTTP body at `tokenPointer`. Keep this for backends that still put the token in JSON.
84
+
85
+ `tokenFrom: outOfBand` ignores the response token and calls `resolveOutOfBand({ address: inviteAs, kind, scope, headers })` after the invite POST. `kind` is `tokenKind` or `${entity}-invite` (`org` → `org-invite`, `project` → `project-invite`). Use this when the live profile must accept only the mailed token.
86
+
87
+ `acceptFrom: token` (default) stuffs that string into the documented accept JSON / path as today. `acceptFrom: link` requires the string to be an absolute `http(s)` URL: oat GETs it (recorded, cookie-jar follow). The `accept` operationId is unused for that hop; `revoke` still uses the documented revoke operation. 2xx/3xx that leaves the grant readable continues the timeline; 4xx, 404, or an unreadable grant is a finding. A config that still POSTs `{ token }` with `tokenFrom: outOfBand` and no `acceptFrom` does not change meaning.
88
+
89
+ An invite operation is **not** the entity's fixture create, even when it is `POST` on the collection. oat will not seed it with a generated email. The invite check (and only that check) creates the grant, using `inviteAs` as `granteeField`. The check still runs when there is no non-invite create, as long as an item or list route exists.
90
+
91
+ Timeline asserted: cannot read → invite → still cannot → accept → can → revoke → cannot.
92
+
93
+ Accept and revoke send the documented JSON request body when the operation declares one, filled from the invite token / grant id (and other known scope values). A path-only accept (`POST /invites/{token}` with no body) stays path-only — oat does not invent a body.
94
+
95
+ **Fallback:** the check does not run.
96
+
97
+ ### `x-query`
98
+
99
+ String arrays still work. Structured rows are optional. Either, not both required.
100
+
101
+ ```yaml
102
+ x-query:
103
+ grammar: postgrest # postgrest | colon | equality
104
+ filterable: [id, name, created_at]
105
+ sortable: [name, created_at]
106
+ searchable: [name, slug]
107
+ selectable: [id, name, created_at]
108
+ maxLimit: 100
109
+ defaultOrder: created_at.desc
110
+ stableTiebreak: id
111
+
112
+ # structured (optional) — unlocks per-field ops / types / nulls
113
+ filterable:
114
+ - { field: id, type: string, ops: [eq, neq, in, nin] }
115
+ - { field: name, type: string, ops: [eq, neq, like, ilike, in] }
116
+ - { field: created_at, type: date, ops: [eq, gt, gte, lt, lte, is] }
117
+ - { field: tags, type: array, ops: [contains, eq, is] }
118
+ sortable:
119
+ - { field: name, type: string }
120
+ - { field: created_at, type: date, nulls: [first, last] }
121
+
122
+ operators: [eq, ne, neq, gt, gte, lt, lte, in, nin, like, ilike, is, contains]
123
+ operatorsByType:
124
+ string: [eq, ne, neq, like, ilike, in, nin, is]
125
+ number: [eq, ne, neq, gt, gte, lt, lte, in, nin, is]
126
+ date: [eq, ne, neq, gt, gte, lt, lte, is]
127
+ enum: [eq, ne, neq, in, nin, is]
128
+ boolean: [eq, ne, neq, is]
129
+ array: [contains, eq, is]
130
+ aliases: { ne: neq }
131
+ identityFilter: _id # filter field when it differs from the JSON identity
132
+ emptyIn: match-none # reject | match-none; undeclared → empty-in check skips
133
+ maxInValues: 100
134
+ maxFilterConditions: 20
135
+ searchModes: [keyword] # free-form; undeclared → mode checks skip
136
+ searchEmpty: match-all # ignore | match-all | reject
137
+ sortNulls: [first, last]
138
+ maxSortKeys: 3
139
+ sortCollation: case-insensitive # binary | case-insensitive | locale; undeclared → any, consistently
140
+ sortDefaultNulls: last # first | last, with no modifier; undeclared → either end, consistently
141
+ selectNested: true # `rel(col)` grammar; undeclared → nested check skips
142
+ selectUnknown: reject # reject | ignore; undeclared → unknown-select check skips
143
+
144
+ # harvest any axis from another GET — no baked path
145
+ filterableFrom: { operationId: table.get, path: $.columns[*].name, typePath: $.columns[*].type, typeMap: { text: string, int: number } }
146
+ sortableFrom: { operationId: table.get, path: $.columns[*].name }
147
+ searchableFrom: { operationId: table.get, path: $.columns[?(@.searchable==true)].name }
148
+ selectableFrom: { operationId: table.get, path: $.columns[*].name }
149
+ ```
150
+
151
+ `defaultOrder` / `stableTiebreak` / `maxLimit` stay. Harvest keys are generic JSON paths (`$`, dots, `[n]`, `[*]`, `[?(@.key==value)]`). Absent → no harvest.
152
+
153
+ String arrays + `operatorsByType` is enough for most APIs. Per-field `ops` / `nulls` close the list that field actually accepts.
154
+
155
+ **Fallback:** if those roles resolve and the axis was not tagged and not set in config, treat every scalar as capable and warn. Empty tagged `filterable: []` / `searchable: null` / `selectable: []` is an explicit claim of none.
156
+
157
+ **Unlocks:** `spec.declared-filterable-is-filterable`, `spec.declared-sortable-is-sortable`, `spec.declared-selectable-is-selectable`. Structured extras unlock the matching catalog checks (`in` = union of `eq`, `ilike` vs `like`, `is.null`, illegal op, empty `in`, caps, nulls, search empty/modes, unknown select, nested `rel(col)`). Sharpens every other query check (without the tag they probe columns you may not have indexed).
158
+
159
+ `oat doctor` prints, per entity, the effective map (all four axes, ops, modes, caps, harvest/hook) and which new checks will apply. Unknown `config.entities` names are ignored and warned.
160
+
161
+ ### `x-async`
162
+
163
+ ```yaml
164
+ x-async:
165
+ poll: "GET /v1/projects/{project_id}/batches/{batch_id}"
166
+ idFrom: batch_id # or $.id
167
+ until: "status.in.complete,partial,failed"
168
+ successWhen: "status.eq.complete"
169
+ timeoutMs: 120000
170
+ pollIntervalMs: 2000
171
+ ```
172
+
173
+ `x-async` means **POST is a receipt, then poll**. It is not how you mark a stream. A stream is a success response that lists `text/event-stream`.
174
+
175
+ When the start response is JSON, oat polls `poll` until `until` matches, then treats that payload as the result. `poll` may be an operationId or `"GET /path/{id}"`. Defaults: `timeoutMs: 120000`, `pollIntervalMs: 2000`.
176
+
177
+ When the start response is `text/event-stream`, oat parses `event:` / `data:` frames. `data` is JSON when it starts with `{` / `[`. `idFrom` is resolved against **each event's JSON `data`**, first hit wins — `$.batch_id` on `event: batch` data `{ "batch_id": "…" }` works. A frame named `complete` / `error`, or one whose `data` matches `until`, is the terminal record; `successWhen` is applied to it and oat does **not** GET the poll route. Poll only if the stream ended without a terminal frame **and** `idFrom` resolved.
178
+
179
+ A spec may leave both `x-async` and `text/event-stream` on the same operation. The stream is still the result; `x-async` only supplies `idFrom` / `until` / the poll fallback.
180
+
181
+ `until` / `successWhen` use the same `field.op.value` predicates as filters (`eq`, `in`, …).
182
+
183
+ **Fallback:** treated as synchronous; async checks are `COVERAGE_GAP`. A 2xx stream without `x-async` is consumed and recorded; async checks do not run.
184
+
185
+ ### `x-effects`
186
+
187
+ ```yaml
188
+ x-effects:
189
+ - { entity: table, op: create }
190
+ - { entity: row, op: append, min: 1 }
191
+ ```
192
+
193
+ `op`: `create` | `append` | `update` | `delete` | `replace`. Each item is `{ entity, op, count?, min? }`.
194
+
195
+ - `count` is an **exact** cardinality delta on that entity's list.
196
+ - `min` is **at-least** (`delta >= min` and `added.length >= min`). Use this when the child count is data-dependent (an extract that appends 1 / 2 / 5 rows).
197
+ - Omit both → `count: 1` (invite / create).
198
+ - Set both on one item → rejected at load / `oat doctor` (`x-effects` gap). The item is not checked.
199
+
200
+ After a write that declares `create` on A, oat binds the new A id from the write response (`table_id`, or the entity identity) or from A's list delta — the same adopt idea as a 402 plan-limit reuse. Later items in **that same** `x-effects` array whose child list is under A (`GET .../tables/{table_id}/rows`) fill the path with that id. `x-wait` after the same write uses the same bound id. No tester hook.
201
+
202
+ An extract-shaped write (create table + append ≥1 row) fails when the new table's row list is empty, and passes when it has 5.
203
+
204
+ **Fallback:** derived from `x-entity.action` for this entity only.
205
+
206
+ ### `x-wait`
207
+
208
+ ```yaml
209
+ x-wait:
210
+ operationId: inbox.list
211
+ until: $.items.0
212
+ timeoutMs: 30000
213
+ pollIntervalMs: 1000
214
+ ```
215
+
216
+ Put this on the **write**. After that write succeeds, oat polls `operationId` until `until` (JSON path `$.items.0` or JSON pointer `/items/0`) is non-empty, or `hooks.awaitSideEffect` returns `true`. Default `timeoutMs` is **30s**. Timeout is a **finding** (`effects.side-effect-arrives`), not a coverage gap.
217
+
218
+ Use this for queue consumers and webhook inboxes (1–30 s), not for the same request. `x-effects` still asserts cardinality; `x-wait` asserts “this other GET eventually has a body”. If the poll path needs a parent id the write created, oat binds it the same way `x-effects` does.
219
+
220
+ **Fallback:** the check does not run.
221
+
222
+ ### `x-soft-delete`
223
+
224
+ ```yaml
225
+ x-soft-delete: deleted_at
226
+ ```
227
+
228
+ On any operation of the entity (commonly DELETE). Tombstone, not remove. Without it, a correct soft-delete looks like a bug (the row is still GET-able).
229
+
230
+ ### `x-immutable` / `x-generated`
231
+
232
+ ```yaml
233
+ x-immutable: [id, project_id, created_at]
234
+ x-generated: [id, created_at, updated_at]
235
+ ```
236
+
237
+ Generated fields are omitted from create bodies and expected in responses. Immutable fields must reject or ignore PATCH.
238
+
239
+ **Fallback:** `readOnly: true` counts as generated. No immutability testing without the tag.
240
+
241
+ ### `x-tenant`
242
+
243
+ ```yaml
244
+ x-tenant: project_id
245
+ ```
246
+
247
+ Path parameter that scopes the operation.
248
+
249
+ **Fallback:** regex over `{organization_id}`, `{project_id}`, `{tenant_id}`, `{workspace_id}`, `{app_slug}`, `{org_id}`, `{account_id}`, … (`org|organization|tenant|workspace|account|project|app` + optional `_id`/`_slug`). Without the tag, a matching path parameter still infers a tenant and a cross-tenant read is `AMBIGUITY`, not `SECURITY`. Omitting `x-tenant` and not naming a tenant path parameter means the check does not apply.
250
+
251
+ ### `x-root`
252
+
253
+ ```yaml
254
+ # on a path parameter, not an operation
255
+ x-root: true
256
+ ```
257
+
258
+ This resource has no create endpoint; supply it in config `roots` / principal `roots`.
259
+
260
+ **Fallback:** inferred when a path param has no create op; everything beneath is `UNSEEDABLE`.
261
+
262
+ ### `x-cleanup`
263
+
264
+ ```yaml
265
+ x-cleanup: "DELETE /v1/projects/{project_id}/tables/{table_id}"
266
+ ```
267
+
268
+ Teardown route when the entity has no discoverable delete. Without it, leftover records are reported at end of run.
269
+
270
+ ### `x-cost` / `x-destructive` / `x-idempotent` / `x-fresh-principal`
271
+
272
+ ```yaml
273
+ x-cost: high # low | medium | high
274
+ x-destructive: true
275
+ x-idempotent: true
276
+ x-fresh-principal: true
277
+ ```
278
+
279
+ `x-cost` and `x-destructive` are consulted by `--profile` (below). `x-idempotent` and `x-fresh-principal` are parsed onto the operation model for the `plan`/`doctor` output but not yet consulted anywhere else. Replay safety is tested from a documented `Idempotency-Key` header (`idempotency.replay-does-not-duplicate`), not from `x-idempotent`.
280
+
281
+ ### `x-feature-gate`
282
+
283
+ ```yaml
284
+ x-feature-gate: webhooks # or custom_domain, audit_log, …
285
+ ```
286
+
287
+ Plan key this operation is sold behind. Honey/apps emit it as route meta → OpenAPI. `doctor` / `plan` show it. A non-string or missing tag is ignored (`null`).
288
+
289
+ This is not cost. `--profile cheap` skips expensive operations; a free principal hitting `webhook.create` is the real product. Skipping the op in a profile would hide the gate.
290
+
291
+ A 403 is a **documented feature-gate denial** only when all of:
292
+
293
+ 1. The operation has `x-feature-gate: <string>`.
294
+ 2. HTTP status is 403.
295
+ 3. JSON body `vars.type === "feature_gate"`.
296
+ 4. `vars.feature` equals the tag **when `vars.feature` is a string**. Absent `vars.feature` is enough together with (3). A string that disagrees with the tag is backend/tag drift — still a seed failure.
297
+
298
+ `error_key` / `feature_name` / `required_plan` are i18n/product and are not required. A typical body:
299
+
300
+ ```json
301
+ {
302
+ "success": false,
303
+ "status": 403,
304
+ "status_key": "forbidden",
305
+ "error_key": "forbidden",
306
+ "vars": {
307
+ "type": "feature_gate",
308
+ "feature": "webhooks",
309
+ "feature_name": "Webhooks",
310
+ "current_plan": "free",
311
+ "required_plan": "pro"
312
+ }
313
+ }
314
+ ```
315
+
316
+ A gated create is a **coverage gap, not a fail**. oat degrades the entity the same way `--profile` excluding `create` does: `COVERAGE_GAP` on `world.seed` naming `x-feature-gate: <key>` (and the plan vars if present); remaining checks that needed a seeded row `did not apply` / `BLOCKED` because of that gap, not a page of "returned 403" copies. If the list route already has rows, read-only checks still run.
317
+
318
+ A later write (`create` after seed, `update`, action) that returns a documented gate 403 is the same: the check that needed a 2xx is `COVERAGE_GAP` / did not apply, citing the tag — not `SECURITY`, not a schema defect. `validation.*` and `schema.error-response-matches-document` still apply: the 403 body must match the documented 403 schema. A gate 403 with an undeclared shape is still drift.
319
+
320
+ Every 403 is not a feature gate. No tag, or a tag that disagrees with `vars.feature`, stays a `SeedError`.
321
+
322
+ **Fallback:** none. Without the tag, a 403 is a failed create.
323
+
324
+ ### `x-unique`
325
+
326
+ ```yaml
327
+ x-unique:
328
+ - [email]
329
+ - [workspace_id, slug]
330
+ # or: { columns: [email] }
331
+ # or a single string[] meaning one set: [email]
332
+ ```
333
+
334
+ Column sets that must stay unique. Honey/apps emit this as route meta → OpenAPI. `doctor` / `plan` show the effective sets per entity the way they show `x-feature-gate`.
335
+
336
+ Accepted shapes: a list of column sets, `{ columns: [...] }` objects, and a single `string[]` meaning one set. Each set is a non-empty list of column names; empty sets are dropped. Malformed / non-array → treat as absent (`null`), unique checks do not run, `doctor` records an `x-unique` gap. `[]` after filtering → explicit none, checks do not run. No tag → unique checks do not run, `doctor` says so.
337
+
338
+ Do not infer uniqueness from 409s, unique-looking names, or JSON Schema `uniqueItems`. Index names and product `error_key` / `vars.type` are not required; HTTP **409** is the unique-conflict class.
339
+
340
+ `create.unique-conflict-rejected` — after a known row (seeded or adopted), a second create with the same unique-set values is 409, list cardinality does not grow, and a documented 409 body still matches the 409 schema. **2xx is `BACKEND_BUG`** (evidence: both exchanges, unique columns, list before/after when list resolved; tear down a returned id). Probe each probeable set separately (one 2xx fails the check; do not collide every set in one request). Probeable = at least one column on the create JSON or form body; path/tenant/`x-generated` columns may fill set identity from scope, but a set with no body columns is skipped, not failed. Do not reuse `Idempotency-Key` / `Idempotence-Key` / `X-Idempotency-Key` (omit, or send a fresh key if the document requires it). 402 / documented feature-gate 403 on the probe is `COVERAGE_GAP`, not a unique pass. Other 4xx / 5xx is not a unique pass. Verdict is never `SECURITY`. Leftover/extra rows do not skip the check.
341
+
342
+ `update.unique-conflict-rejected` — PATCH a **different** row onto another row's unique-set values is 409 (same scoring). PATCH of a row's own unique values unchanged is not this check. Skip update sets whose columns are all `x-immutable`.
343
+
344
+ **Fallback:** none. Without the tag, unique checks do not run, and a seed 409 is a failed create.
345
+
346
+ ### `--profile` — cost gating
347
+
348
+ ```bash
349
+ oat run --config oat.config.ts --profile cheap
350
+ ```
351
+
352
+ A profile restricts which operations a run is allowed to touch, filtering on `x-cost` and `x-destructive`. Two exist without being declared anywhere: `full` (no gating — the default, today's behaviour if you never mention a profile) and `cheap` (`{ maxCost: "low" }`). Reach for a profile when some operations are expensive to call every run — an extraction endpoint billed per request, a bulk job, anything you don't want fired on every `oat run`.
353
+
354
+ Anything more specific than a cost band is a named entry in config:
355
+
356
+ ```ts
357
+ export default defineConfig({
358
+ // ...
359
+ profiles: {
360
+ // skip everything above "low" cost, same as the built-in "cheap"
361
+ cheap: { maxCost: "low" },
362
+ // skip destructive operations and two specific extraction endpoints
363
+ safe: { excludeDestructive: true, exclude: ["report.extract", "report.summarize"] },
364
+ },
365
+ profile: "safe", // --profile on the CLI overrides this
366
+ })
367
+ ```
368
+
369
+ An excluded operation never silently narrows what gets reported. Excluding an entity's `create` degrades that entity the same way a real seeding failure does — a `COVERAGE_GAP` naming the reason, then read-only checks run against whatever the list route already returns; `BLOCKED` if nothing exists to fall back on. Excluding `read`/`update`/`delete` individually stands down just the checks that need that one operation.
370
+
371
+ Exclusion applies to every invocation, not only create/read/update/delete. `effects.declared-effect-occurs` and `async.reaches-terminal-state` drop ops the profile forbids and record `profile.skip` — they do not POST `extract.once` under `--profile cheap`. A check whose only targets were excluded did not apply / names the profile; it never looks like the backend returned 500.
372
+
373
+ The run summary states what a profile skipped: `skipped 12 operation(s) under --profile cheap (12 high-cost)` — the same principle as `did not apply` for a coverage gap: a report that only shows what ran invites the reader to assume the rest was verified.
374
+
375
+ `--profile cheap` also defaults the exchange journal **off**. `--save-exchanges` turns it back on; `--quiet` does not change it.
376
+
377
+ ### `x-rate-limit` — pacing oat's own traffic
378
+
379
+ ```yaml
380
+ x-rate-limit: { category: ai, rps: 3 }
381
+ ```
382
+
383
+ Groups operations sharing one throughput budget (`category`) and optionally the rate itself (`rps`). Without this, oat's matrix can hammer a `login` or paid-inference route past its real limit, collect 429s, and report them as backend defects — exactly the false-positive class that erodes trust fastest. With it, requests to that category are paced through a token bucket before they fire.
384
+
385
+ Tags are the **proactive** path. HTTP 429 is the **reactive** path and is honoured even when the operation has no `x-rate-limit` at all (untagged JWT writes, teardown DELETEs). oat reads `Retry-After` (delta-seconds or HTTP-date), otherwise backs off 1s, 2s, 4s… capped at 30s, and retries the same request up to five times. The wait is fed into the matching bucket, or into an implicit `untagged-write` bucket so the next call does not immediately re-trip.
386
+
387
+ A 429 is only ever reported when the request that drew it was demonstrably under the _declared_ rate — oat's own bucket had a free token, so it did not have to wait for one. A 429 that arrived only after oat's bucket made the request wait means oat's own rate model was too generous, which is paced around, never reported. A 429 against a config-supplied or implicit rate is never a finding.
388
+
389
+ `config.rateLimits` is checked first and needs no tag at all — it is what keeps oat usable against a backend that has not adopted `x-rate-limit` yet, or against an environment (staging, usually) whose real limit differs from what the document claims for production. A 429 against a config-supplied rate is never a finding: it is the operator's own belief about the environment, not a claim the API made.
390
+
391
+ ```ts
392
+ export default defineConfig({
393
+ // ...
394
+ rateLimits: [
395
+ { match: "POST /v1/auth/login", rps: 2 },
396
+ { match: "auth.login", rps: 2 }, // operationId works too
397
+ { match: "category:ai", rps: 1 }, // overrides every x-rate-limit-tagged "ai" operation at once
398
+ ],
399
+ })
400
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovrozagar/oat",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "OpenAPI Tester — matrix-test a live API against its OpenAPI spec",
5
5
  "keywords": [
6
6
  "api",
@@ -28,11 +28,13 @@
28
28
  "dist/index.js",
29
29
  "dist/index.d.ts",
30
30
  "dist/config",
31
+ "dist/conformance",
31
32
  "dist/reference",
32
33
  "dist/report",
33
34
  "dist/runtime",
34
35
  "dist/spec",
35
36
  "README.md",
37
+ "docs",
36
38
  "LICENSE",
37
39
  "labs/annotated-openapi.yaml",
38
40
  "labs/local.config.ts",