@lovrozagar/oat 0.6.2 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +443 -146
- package/dist/cli.js +35 -29
- package/dist/config/define-config.d.ts +125 -3
- package/dist/config/define-config.d.ts.map +1 -1
- package/dist/config/define-config.js +6 -0
- package/dist/config/define-config.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.js +4 -1
- package/dist/reference/defects.d.ts +16 -0
- package/dist/reference/defects.d.ts.map +1 -1
- package/dist/reference/defects.js +16 -0
- package/dist/reference/defects.js.map +1 -1
- package/dist/reference/model.d.ts +9 -0
- package/dist/reference/model.d.ts.map +1 -1
- package/dist/reference/model.js +20 -0
- package/dist/reference/model.js.map +1 -1
- package/dist/reference/query.d.ts +5 -0
- package/dist/reference/query.d.ts.map +1 -1
- package/dist/reference/query.js +87 -21
- package/dist/reference/query.js.map +1 -1
- package/dist/reference/spec.d.ts.map +1 -1
- package/dist/reference/spec.js +47 -2
- package/dist/reference/spec.js.map +1 -1
- package/dist/reference/store-api.d.ts +5 -1
- package/dist/reference/store-api.d.ts.map +1 -1
- package/dist/reference/store-api.js +9 -1
- package/dist/reference/store-api.js.map +1 -1
- package/dist/reference/stores/memory.d.ts.map +1 -1
- package/dist/reference/stores/memory.js +11 -0
- package/dist/reference/stores/memory.js.map +1 -1
- package/dist/reference/stores/postgres.d.ts.map +1 -1
- package/dist/reference/stores/postgres.js +75 -19
- package/dist/reference/stores/postgres.js.map +1 -1
- package/dist/reference/stores/sqlite-driver.d.ts.map +1 -1
- package/dist/reference/stores/sqlite-driver.js +3 -0
- package/dist/reference/stores/sqlite-driver.js.map +1 -1
- package/dist/reference/stores/sqlite.d.ts.map +1 -1
- package/dist/reference/stores/sqlite.js +78 -17
- package/dist/reference/stores/sqlite.js.map +1 -1
- package/dist/report/console.d.ts +7 -1
- package/dist/report/console.d.ts.map +1 -1
- package/dist/report/console.js +210 -1
- package/dist/report/console.js.map +1 -1
- package/dist/report/render.d.ts.map +1 -1
- package/dist/report/render.js +4 -2
- package/dist/report/render.js.map +1 -1
- package/dist/runtime/auth.d.ts +22 -2
- package/dist/runtime/auth.d.ts.map +1 -1
- package/dist/runtime/auth.js +129 -16
- package/dist/runtime/auth.js.map +1 -1
- package/dist/runtime/body.d.ts.map +1 -1
- package/dist/runtime/body.js +6 -3
- package/dist/runtime/body.js.map +1 -1
- package/dist/runtime/checks.d.ts +9 -0
- package/dist/runtime/checks.d.ts.map +1 -1
- package/dist/runtime/checks.js +1414 -18
- package/dist/runtime/checks.js.map +1 -1
- package/dist/runtime/client.d.ts +23 -1
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js +35 -5
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/finding.d.ts +2 -0
- package/dist/runtime/finding.d.ts.map +1 -1
- package/dist/runtime/finding.js.map +1 -1
- package/dist/runtime/input.d.ts +7 -0
- package/dist/runtime/input.d.ts.map +1 -0
- package/dist/runtime/input.js +46 -0
- package/dist/runtime/input.js.map +1 -0
- package/dist/runtime/poll.d.ts +29 -0
- package/dist/runtime/poll.d.ts.map +1 -0
- package/dist/runtime/poll.js +59 -0
- package/dist/runtime/poll.js.map +1 -0
- package/dist/runtime/principals.d.ts +29 -0
- package/dist/runtime/principals.d.ts.map +1 -0
- package/dist/runtime/principals.js +97 -0
- package/dist/runtime/principals.js.map +1 -0
- package/dist/runtime/progress.d.ts +21 -0
- package/dist/runtime/progress.d.ts.map +1 -1
- package/dist/runtime/progress.js +63 -21
- package/dist/runtime/progress.js.map +1 -1
- package/dist/runtime/query-capabilities.d.ts +24 -0
- package/dist/runtime/query-capabilities.d.ts.map +1 -0
- package/dist/runtime/query-capabilities.js +66 -0
- package/dist/runtime/query-capabilities.js.map +1 -0
- package/dist/runtime/run.d.ts +14 -1
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js +198 -39
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/runs.d.ts +23 -0
- package/dist/runtime/runs.d.ts.map +1 -0
- package/dist/runtime/runs.js +53 -0
- package/dist/runtime/runs.js.map +1 -0
- package/dist/runtime/upload.d.ts +1 -0
- package/dist/runtime/upload.d.ts.map +1 -1
- package/dist/runtime/upload.js.map +1 -1
- package/dist/runtime/wait.d.ts +32 -0
- package/dist/runtime/wait.d.ts.map +1 -0
- package/dist/runtime/wait.js +125 -0
- package/dist/runtime/wait.js.map +1 -0
- package/dist/runtime/world.d.ts.map +1 -1
- package/dist/runtime/world.js +2 -0
- package/dist/runtime/world.js.map +1 -1
- package/dist/spec/conventions.d.ts +10 -2
- package/dist/spec/conventions.d.ts.map +1 -1
- package/dist/spec/conventions.js +61 -7
- package/dist/spec/conventions.js.map +1 -1
- package/dist/spec/extensions.d.ts +27 -0
- package/dist/spec/extensions.d.ts.map +1 -1
- package/dist/spec/extensions.js +40 -5
- package/dist/spec/extensions.js.map +1 -1
- package/dist/spec/graph.d.ts +2 -1
- package/dist/spec/graph.d.ts.map +1 -1
- package/dist/spec/graph.js +2 -1
- package/dist/spec/graph.js.map +1 -1
- package/dist/spec/query-capabilities.d.ts +154 -0
- package/dist/spec/query-capabilities.d.ts.map +1 -0
- package/dist/spec/query-capabilities.js +734 -0
- package/dist/spec/query-capabilities.js.map +1 -0
- package/labs/annotated-openapi.yaml +21 -2
- package/labs/demo.mjs +4 -2
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -48,6 +48,8 @@ This file is the operator manual. An agent that has read it can install oat, wri
|
|
|
48
48
|
- [Top-level fields](#top-level-fields)
|
|
49
49
|
- [Principals](#principals)
|
|
50
50
|
- [Auth flows](#auth-flows)
|
|
51
|
+
- [Harvested principal](#harvested-principal)
|
|
52
|
+
- [Secondary origins](#secondary-origins)
|
|
51
53
|
- [Hooks](#hooks)
|
|
52
54
|
- [Uploads](#uploads)
|
|
53
55
|
- [Environment interpolation](#environment-interpolation)
|
|
@@ -101,7 +103,7 @@ The first principal is the writer. Isolation needs a second principal with diffe
|
|
|
101
103
|
|
|
102
104
|
oat never needs ground truth about your data. 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.
|
|
103
105
|
|
|
104
|
-
oat does **not** use OpenAPI `security` / `securitySchemes`, `servers[]`, cookies, webhooks, callbacks, or `links`. Auth is the config. The origin is `baseUrl`. Request bodies follow the document: JSON, `multipart/form-data` (scalars + dummy / pool / `resolveUpload` files), or `application/x-www-form-urlencoded`.
|
|
106
|
+
oat does **not** use OpenAPI `security` / `securitySchemes`, `servers[]`, cookies, webhooks, callbacks, or `links`. Auth is the config. The primary origin is `baseUrl`. Extra hosts go in `origins[]`, each with its own spec — do not merge them into the primary document. Request bodies follow the document: JSON, `multipart/form-data` (scalars + dummy / pool / `resolveUpload` files), or `application/x-www-form-urlencoded`. `hooks.resolveInput` can replace a generated JSON field (a Stripe test `pm_…`); `hooks.resolveHeaders` can attach a one-shot header (Turnstile) per request.
|
|
105
107
|
|
|
106
108
|
## Quick start
|
|
107
109
|
|
|
@@ -188,7 +190,7 @@ Same object. `${NAME}` is interpolated after load. There is no `defineConfig` wr
|
|
|
188
190
|
],
|
|
189
191
|
"seed": 42,
|
|
190
192
|
"cohortSize": 7,
|
|
191
|
-
"outDir": "
|
|
193
|
+
"outDir": "./.oat/runs"
|
|
192
194
|
}
|
|
193
195
|
```
|
|
194
196
|
|
|
@@ -253,23 +255,23 @@ oat help
|
|
|
253
255
|
|
|
254
256
|
Requires `--config`. CLI flags override the same field in the config when both are set.
|
|
255
257
|
|
|
256
|
-
| flag | default
|
|
257
|
-
| ----------------- |
|
|
258
|
-
| `--config` | required
|
|
259
|
-
| `--base-url` | `config.baseUrl`
|
|
260
|
-
| `--only` | `config.only` or all entities
|
|
261
|
-
| `--seed` | `config.seed` or `1`
|
|
262
|
-
| `--out` | `config.outDir` or
|
|
263
|
-
| `--max-in-flight` | `config.maxInFlight` or `4`
|
|
264
|
-
| `--keep-fixtures` | `config.keepFixtures` or false
|
|
265
|
-
| `--quiet` | false
|
|
258
|
+
| flag | default | meaning |
|
|
259
|
+
| ----------------- | -------------------------------- | ---------------------------------------------------------------------- |
|
|
260
|
+
| `--config` | required | module or JSON file, default export |
|
|
261
|
+
| `--base-url` | `config.baseUrl` | backend origin |
|
|
262
|
+
| `--only` | `config.only` or all entities | comma-separated entity names as `oat plan` prints them (singularised) |
|
|
263
|
+
| `--seed` | `config.seed` or `1` | fixture generation seed (reproducible) |
|
|
264
|
+
| `--out` | `config.outDir` or `./.oat/runs` | history root; each run writes `<out>/<datetime>/` and updates `latest` |
|
|
265
|
+
| `--max-in-flight` | `config.maxInFlight` or `4` | HTTP requests allowed at once |
|
|
266
|
+
| `--keep-fixtures` | `config.keepFixtures` or false | do not DELETE what the run created |
|
|
267
|
+
| `--quiet` | false | no stderr progress; files under `--out` still update |
|
|
266
268
|
|
|
267
269
|
**Exit codes:** `0` no defects, `1` at least one root-cause finding (`BACKEND_BUG`, `SPEC_BUG`, `SECURITY`, `AMBIGUITY`), `2` usage error (missing `--config`, no principals, unknown flag). `COVERAGE_GAP` and `BLOCKED` do not fail the process.
|
|
268
270
|
|
|
269
271
|
Example:
|
|
270
272
|
|
|
271
273
|
```bash
|
|
272
|
-
oat run --config oat.config.ts --only store,product --out oat
|
|
274
|
+
oat run --config oat.config.ts --only store,product --out .oat/runs/prod
|
|
273
275
|
```
|
|
274
276
|
|
|
275
277
|
`--only store,product` matches the **entity names** from `oat plan`, not path segments. `/v1/stores` is usually the entity `store`. If a name is unknown, that entity is simply not tested (the others still run).
|
|
@@ -432,25 +434,47 @@ export default defineConfig({
|
|
|
432
434
|
maxInFlight: 4, // HTTP in flight
|
|
433
435
|
only: ["store", "product"], // restrict entities
|
|
434
436
|
keepFixtures: false,
|
|
435
|
-
outDir: "
|
|
437
|
+
outDir: "./.oat/runs",
|
|
438
|
+
outOfBand: { attempts: 20, initialMs: 1000, maxMs: 8000 },
|
|
439
|
+
origins: [{ id: "cdn", baseUrl: "https://cdn.example.com", spec: "https://cdn.example.com/openapi.json" }],
|
|
440
|
+
query: {
|
|
441
|
+
operators: ["eq", "neq", "gt", "gte", "lt", "lte", "in", "nin", "like", "ilike", "is"],
|
|
442
|
+
emptyIn: "match-none",
|
|
443
|
+
maxInValues: 100,
|
|
444
|
+
searchEmpty: "match-all",
|
|
445
|
+
sort: { nulls: ["first", "last"], maxKeys: 3 },
|
|
446
|
+
select: { unknown: "reject" },
|
|
447
|
+
},
|
|
448
|
+
entities: {
|
|
449
|
+
row: {
|
|
450
|
+
query: {
|
|
451
|
+
identityFilter: "_id",
|
|
452
|
+
filterable: [{ field: "_id", type: "string", ops: ["eq", "neq", "in"] }],
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
},
|
|
436
456
|
})
|
|
437
457
|
```
|
|
438
458
|
|
|
439
|
-
| field | required | default
|
|
440
|
-
| --------------- | -------- |
|
|
441
|
-
| `spec` | yes |
|
|
442
|
-
| `baseUrl` | yes |
|
|
443
|
-
| `principals` | yes |
|
|
444
|
-
| `hooks` | no |
|
|
445
|
-
| `uploads` | no |
|
|
446
|
-
| `globalHeaders` | no | `{}`
|
|
447
|
-
| `
|
|
448
|
-
| `
|
|
449
|
-
| `
|
|
450
|
-
| `
|
|
451
|
-
| `
|
|
452
|
-
| `
|
|
453
|
-
| `
|
|
459
|
+
| field | required | default | notes |
|
|
460
|
+
| --------------- | -------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
461
|
+
| `spec` | yes | | See [Spec loading](#spec-loading) |
|
|
462
|
+
| `baseUrl` | yes | | Primary origin. OpenAPI `servers[]` is ignored |
|
|
463
|
+
| `principals` | yes | | Non-empty. First is the writer |
|
|
464
|
+
| `hooks` | no | | See [Hooks](#hooks) |
|
|
465
|
+
| `uploads` | no | | `pool` globs, relative to the config file. JSON configs: pool only |
|
|
466
|
+
| `globalHeaders` | no | `{}` | Merged first. `resolveHeaders` then caller headers then auth |
|
|
467
|
+
| `origins` | no | `[]` | Extra `{ id, baseUrl, spec }` hosts. Auth JWT is reused. Do not merge those routes into `spec` |
|
|
468
|
+
| `outOfBand` | no | `{ attempts: 6, initialMs: 200, maxMs: 3000 }` | Backoff for `resolveOutOfBand` and `resolvePrincipalAuth`. See [Hooks](#hooks) |
|
|
469
|
+
| `roots` | no | `{}` | Shared path params (merged with each principal's `roots`) |
|
|
470
|
+
| `seed` | no | `1` | Integer. Same seed → same fixture bodies |
|
|
471
|
+
| `cohortSize` | no | `7` | Sliced from the 7 built-in variants. Larger repeats the pattern |
|
|
472
|
+
| `maxInFlight` | no | `4` | Across the whole run |
|
|
473
|
+
| `only` | no | all | Entity names from `oat plan` |
|
|
474
|
+
| `keepFixtures` | no | `false` | Skip DELETE at the end |
|
|
475
|
+
| `outDir` | no | `./.oat/runs` | History root. Each run writes `<outDir>/<datetime>/` and updates `latest`. Also writes `principals.json` after acquire |
|
|
476
|
+
| `query` | no | | Global query-catalog defaults. Overlay after `x-query`. Does not invent operators |
|
|
477
|
+
| `entities` | no | | Per-entity overlays. This release only reads `query`. Unknown names are ignored; `doctor` warns |
|
|
454
478
|
|
|
455
479
|
`spec` may be a path relative to `baseUrl` (`/v1/openapi/spec`) or an absolute URL or a file.
|
|
456
480
|
|
|
@@ -493,6 +517,7 @@ Rules that matter:
|
|
|
493
517
|
- Extra principals are not ignored. Isolation picks the first different-`roots` peer. Rank uses the same-tenant pair.
|
|
494
518
|
- `headers` and `auth` compose: static headers are sent, then the flow's credential header is merged on top.
|
|
495
519
|
- A principal with only `headers` (no `auth`) never hits a login route.
|
|
520
|
+
- `auth` may be a harvested credential instead of a step chain: `{ fromHook: "oauth-google" }`. See [Harvested principal](#harvested-principal).
|
|
496
521
|
|
|
497
522
|
Example — two tenants plus a same-tenant lattice:
|
|
498
523
|
|
|
@@ -654,6 +679,50 @@ principals: [
|
|
|
654
679
|
|
|
655
680
|
The address used for `teardownPrincipal` is `scope.address` or `scope.email` (set via `bind: { address }` or `bind: { email }`).
|
|
656
681
|
|
|
682
|
+
### Harvested principal
|
|
683
|
+
|
|
684
|
+
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`:
|
|
685
|
+
|
|
686
|
+
```ts
|
|
687
|
+
{
|
|
688
|
+
id: "google-user",
|
|
689
|
+
auth: { fromHook: "oauth-google" },
|
|
690
|
+
}
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
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.
|
|
694
|
+
|
|
695
|
+
### Secondary origins
|
|
696
|
+
|
|
697
|
+
One run, one primary `baseUrl`. A CDN (or any second host) keeps its own OpenAPI.
|
|
698
|
+
|
|
699
|
+
```ts
|
|
700
|
+
export default defineConfig({
|
|
701
|
+
spec: "https://api.example.com/openapi.json",
|
|
702
|
+
baseUrl: "https://api.example.com",
|
|
703
|
+
principals: [/* acquire JWT on the API */],
|
|
704
|
+
origins: [{ id: "cdn", baseUrl: "https://cdn.example.com", spec: "https://cdn.example.com/openapi.json" }],
|
|
705
|
+
})
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
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.
|
|
709
|
+
|
|
710
|
+
Do not merge CDN routes into the API gateway document.
|
|
711
|
+
|
|
712
|
+
A second `defineConfig` can reuse the first run's snapshot instead:
|
|
713
|
+
|
|
714
|
+
```ts
|
|
715
|
+
import { defineConfig, loadPersistedPrincipals } from "@lovrozagar/oat"
|
|
716
|
+
|
|
717
|
+
export default defineConfig({
|
|
718
|
+
spec: "https://cdn.example.com/openapi.json",
|
|
719
|
+
baseUrl: "https://cdn.example.com",
|
|
720
|
+
principals: loadPersistedPrincipals("./.oat/runs/latest/principals.json"),
|
|
721
|
+
})
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
The CLI writes `principals.json` into each run folder (and `.oat/runs/latest/principals.json` via the `latest` symlink).
|
|
725
|
+
|
|
657
726
|
### Hooks
|
|
658
727
|
|
|
659
728
|
```ts
|
|
@@ -675,13 +744,95 @@ hooks: {
|
|
|
675
744
|
}
|
|
676
745
|
return null
|
|
677
746
|
},
|
|
747
|
+
// After globalHeaders, before auth. Return null to add nothing.
|
|
748
|
+
resolveHeaders: async ({ operationId, method, url }) => {
|
|
749
|
+
if (operationId === "auth.register" || operationId === "auth.login") {
|
|
750
|
+
return { "cf-turnstile-response": await harvestTurnstile() }
|
|
751
|
+
}
|
|
752
|
+
return null
|
|
753
|
+
},
|
|
754
|
+
// Replace a generated JSON field. Null keeps the generator.
|
|
755
|
+
resolveInput: async ({ operationId, field }) => {
|
|
756
|
+
if (operationId === "billing.subscribe" && field === "payment_method_id") {
|
|
757
|
+
return process.env.STRIPE_TEST_PM
|
|
758
|
+
}
|
|
759
|
+
return null
|
|
760
|
+
},
|
|
761
|
+
// Harvested OAuth pair. Null retries with the outOfBand backoff.
|
|
762
|
+
resolvePrincipalAuth: async (fromHook) => {
|
|
763
|
+
if (fromHook !== "oauth-google") return null
|
|
764
|
+
const pair = await readHarvestedGoogle()
|
|
765
|
+
return pair === null ? null : { credential: pair.access_token, refreshToken: pair.refresh_token, expiresIn: pair.expires_in }
|
|
766
|
+
},
|
|
767
|
+
// After seed: add or replace any axis of the query catalog. Null keeps the merge.
|
|
768
|
+
resolveQueryCapabilities: async ({ entity, get }) => {
|
|
769
|
+
if (entity !== "row") return null
|
|
770
|
+
const body = await get("table.get")
|
|
771
|
+
return { filterable: /* harvest from body */ [] }
|
|
772
|
+
},
|
|
773
|
+
// Optional extra stop condition while x-wait polls.
|
|
774
|
+
awaitSideEffect: async ({ operationId, record }) => {
|
|
775
|
+
if (operationId !== "webhook.deliver") return null
|
|
776
|
+
return Array.isArray((record as { items?: unknown }).items) && (record as { items: unknown[] }).items.length > 0
|
|
777
|
+
? true
|
|
778
|
+
: null
|
|
779
|
+
},
|
|
678
780
|
}
|
|
679
781
|
```
|
|
680
782
|
|
|
681
|
-
Without `resolveOutOfBand`, an `outOfBand` step cannot complete. oat polls the hook
|
|
783
|
+
Without `resolveOutOfBand`, an `outOfBand` step cannot complete. oat polls the hook; the hook must not sleep. Returning `""` is treated like `null`.
|
|
784
|
+
|
|
785
|
+
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
|
|
786
|
+
|
|
787
|
+
`200 + 400 + 800 + 1600 + 3000 + 3000 = 9000` ms.
|
|
788
|
+
|
|
789
|
+
That is too short for real mail (often 10–60 s) and for a human finishing Google OAuth or a Turnstile harvest. Configure it:
|
|
790
|
+
|
|
791
|
+
```ts
|
|
792
|
+
outOfBand: { attempts: 20, initialMs: 1000, maxMs: 8000 }
|
|
793
|
+
// worst case: 1000 + 2000 + 4000 + 8000×17 = 143000 ms
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
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.
|
|
682
797
|
|
|
683
798
|
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.
|
|
684
799
|
|
|
800
|
+
`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.
|
|
801
|
+
|
|
802
|
+
`resolveInput` is the JSON twin of `resolveUpload`. Return a value to replace that field (`payment_method_id` on `billing.subscribe`); `null` keeps the generator.
|
|
803
|
+
|
|
804
|
+
`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.
|
|
805
|
+
|
|
806
|
+
`resolvePrincipalAuth` and `awaitSideEffect` are documented below.
|
|
807
|
+
|
|
808
|
+
Worked `query` overlay for a generic PostgREST-shaped API (not a named product):
|
|
809
|
+
|
|
810
|
+
```ts
|
|
811
|
+
export default defineConfig({
|
|
812
|
+
spec: "https://api.example.com/openapi.json",
|
|
813
|
+
baseUrl: "https://api.example.com",
|
|
814
|
+
principals: [{ id: "alpha", headers: { authorization: `Bearer ${process.env.TOKEN}` } }],
|
|
815
|
+
query: {
|
|
816
|
+
operators: ["eq", "neq", "gt", "gte", "lt", "lte", "in", "nin", "like", "ilike", "is"],
|
|
817
|
+
operatorsByType: {
|
|
818
|
+
string: ["eq", "neq", "like", "ilike", "in", "nin", "is"],
|
|
819
|
+
number: ["eq", "neq", "gt", "gte", "lt", "lte", "in", "nin", "is"],
|
|
820
|
+
date: ["eq", "neq", "gt", "gte", "lt", "lte", "is"],
|
|
821
|
+
boolean: ["eq", "neq", "is"],
|
|
822
|
+
},
|
|
823
|
+
aliases: { ne: "neq" },
|
|
824
|
+
emptyIn: "match-none",
|
|
825
|
+
maxInValues: 100,
|
|
826
|
+
maxFilterConditions: 20,
|
|
827
|
+
searchEmpty: "match-all",
|
|
828
|
+
sort: { nulls: ["first", "last"], maxKeys: 3 },
|
|
829
|
+
select: { nested: false, unknown: "reject" },
|
|
830
|
+
},
|
|
831
|
+
})
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
New surface still has to be listed. oat will not silently enable `in` / `ilike` / `is` / `contains` / search modes / `nullsfirst` on every PostgREST-shaped document.
|
|
835
|
+
|
|
685
836
|
### Uploads
|
|
686
837
|
|
|
687
838
|
Multipart and binary parts are filled in this order:
|
|
@@ -837,6 +988,7 @@ Checks do not look for a parameter _named_ `filter`. They resolve **roles** from
|
|
|
837
988
|
| order | `order`, `order_by`, `sort`, `sort_by`, `ordering` |
|
|
838
989
|
| select | `select`, `fields`, `field`, `include_fields`, `projection`, `only` |
|
|
839
990
|
| search | `q`, `search`, `query_text`, `term`, `keyword`, `text` |
|
|
991
|
+
| search mode | `search_mode`, `searchmode`, `search_type`, `mode` (only if a search role exists) |
|
|
840
992
|
| limit (page size) | `limit`, `per_page`, `page_size`, `pagesize`, `count`, `max_results`, `top`, `size` |
|
|
841
993
|
| page | `page`, `page_number`, `pagenum`, `p` |
|
|
842
994
|
| offset | `offset`, `skip`, `start`, `from` |
|
|
@@ -848,15 +1000,17 @@ A bounded integer with a default that matches no alias is still taken as page si
|
|
|
848
1000
|
|
|
849
1001
|
**Filter grammars** — how oat **writes** a term:
|
|
850
1002
|
|
|
851
|
-
| name |
|
|
852
|
-
| ----------- |
|
|
853
|
-
| `postgrest` | `
|
|
854
|
-
| `colon` | `filter=status=eq:active` (comma-joined terms; no grouping)
|
|
855
|
-
| `equality` | `?status=active` (one query param per field). Only `eq` is expressible
|
|
1003
|
+
| name | example | `and` / `or` |
|
|
1004
|
+
| ----------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------- |
|
|
1005
|
+
| `postgrest` | `status.eq.active`, `name.neq.x`, `id.in.(a,b)`, `name.ilike.FOO`, `note.is.null`, `tags.contains.x` | `and(a.eq.1,b.eq.2)`, `or(...)` |
|
|
1006
|
+
| `colon` | `filter=status=eq:active` (comma-joined terms; no grouping) | not expressible; those checks skip |
|
|
1007
|
+
| `equality` | `?status=active` (one query param per field). Only `eq` is expressible | not expressible |
|
|
1008
|
+
|
|
1009
|
+
Operators the **postgrest** writer can emit: `eq`, `ne`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `like`, `ilike`, `is`, `contains`. Colon stays on `eq` / `neq` / `gt` / `gte` / `lt` / `lte` / `like`. Equality is `eq` only. Anything a grammar cannot write becomes **did not apply**, not a failed request.
|
|
856
1010
|
|
|
857
|
-
|
|
1011
|
+
A check that needs `in`, `ilike`, `is`, `contains`, a search mode, or `nullsfirst` / `nullslast` also needs that capability **declared**. oat does not infer new operators, modes, or nulls tokens onto an API that never listed them.
|
|
858
1012
|
|
|
859
|
-
**Sort grammars:** `name.asc` (dotted), `-name` (prefixed / JSON:API; ascending is the bare name), `name:asc` (colon), `name asc` (spaced).
|
|
1013
|
+
**Sort grammars:** `name.asc` (dotted), `-name` (prefixed / JSON:API; ascending is the bare name), `name:asc` (colon), `name asc` (spaced). Dotted (PostgREST-shaped) can also emit `name.asc.nullsfirst` / `name.desc.nullslast` when the capability map allows that token. Colon / prefixed / spaced stay as they are.
|
|
860
1014
|
|
|
861
1015
|
**Select grammars:** `id,name` (csv) or `fields[table]=id,name` (bracketed). If the parameter is already named `fields[articles]`, that name is used verbatim.
|
|
862
1016
|
|
|
@@ -872,8 +1026,26 @@ Otherwise oat concatenates the filter/order/select parameter's `example`, `examp
|
|
|
872
1026
|
|
|
873
1027
|
Without `x-query`, if a filter/order/select/search role resolves, oat assumes **every scalar** is filterable/sortable/selectable (`string` / `number` / `integer` / `boolean`, including nullable unions). Searchable-without-tag is further narrowed to names matching `name|title|slug|label|description|email`. `doctor` warns. Pagination-only lists stay uncovered.
|
|
874
1028
|
|
|
1029
|
+
That heuristic runs **only** for an axis that was not tagged and not set in config. An explicit empty claim — `filterable: []`, `searchable: null`, `selectable: []`, `sortable: []` — is a claim of none. oat will not infer scalars over it.
|
|
1030
|
+
|
|
875
1031
|
Searchable / filterable / sortable / selectable from the tag are used as given. `maxLimit` is also taken from a page-size parameter's `schema.maximum` when the tag omits it.
|
|
876
1032
|
|
|
1033
|
+
### Declared or skip
|
|
1034
|
+
|
|
1035
|
+
Every list check consults one **effective capability map** per entity. Precedence, unchanged in spirit:
|
|
1036
|
+
|
|
1037
|
+
| order | source | what it contributes |
|
|
1038
|
+
| ----- | ------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
1039
|
+
| 1 | `x-query` on the list operation | fields, structured rows, operators, modes, caps, harvest |
|
|
1040
|
+
| 2 | `config.entities[name].query` | union of fields; overlay of ops / modes / caps |
|
|
1041
|
+
| 3 | `config.query` | global defaults for the same keys |
|
|
1042
|
+
| 4 | heuristic | today's scalars / name-regex searchable — **only if that axis was not tagged and not set in config** |
|
|
1043
|
+
| 5 | skip | the check does not apply |
|
|
1044
|
+
|
|
1045
|
+
`hooks.resolveQueryCapabilities` runs after 1–3 (and after any `*From` harvest) and may add or replace any axis. Return `null` to keep the merge. Called once per entity after seed, with the seeded parent scope so a follow-up GET can run.
|
|
1046
|
+
|
|
1047
|
+
Missing capability → the check does not apply / unresolved. Never `BACKEND_BUG` for an undeclared op, mode, or nulls option.
|
|
1048
|
+
|
|
877
1049
|
## Pagination and envelopes
|
|
878
1050
|
|
|
879
1051
|
Three page models, all first-class:
|
|
@@ -913,15 +1085,16 @@ oat doctor --spec node_modules/@lovrozagar/oat/labs/annotated-openapi.yaml
|
|
|
913
1085
|
|
|
914
1086
|
What each tag **unlocks** (otherwise the check cannot run):
|
|
915
1087
|
|
|
916
|
-
| tag | checks unlocked
|
|
917
|
-
| --------------- |
|
|
918
|
-
| `x-async` | `async.reaches-terminal-state`, `async.receipt-identifies-the-job`
|
|
919
|
-
| `x-effects` | `effects.declared-effect-occurs`
|
|
920
|
-
| `x-immutable` | `patch.immutable-field-rejected`
|
|
921
|
-
| `x-invalidate` | `invalidation.declared-route-changes` (when the list names another entity)
|
|
922
|
-
| `x-query` | `spec.declared-filterable-is-filterable`, `spec.declared-sortable-is-sortable`, `spec.declared-selectable-is-selectable` |
|
|
923
|
-
| `x-soft-delete` | `softdelete.absent-from-default-list`
|
|
924
|
-
| `x-invite` | `auth.invite-grants-then-revokes`
|
|
1088
|
+
| tag | checks unlocked |
|
|
1089
|
+
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1090
|
+
| `x-async` | `async.reaches-terminal-state`, `async.receipt-identifies-the-job` |
|
|
1091
|
+
| `x-effects` | `effects.declared-effect-occurs` |
|
|
1092
|
+
| `x-immutable` | `patch.immutable-field-rejected` |
|
|
1093
|
+
| `x-invalidate` | `invalidation.declared-route-changes` (when the list names another entity) |
|
|
1094
|
+
| `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 / …) |
|
|
1095
|
+
| `x-soft-delete` | `softdelete.absent-from-default-list` |
|
|
1096
|
+
| `x-invite` | `auth.invite-grants-then-revokes` |
|
|
1097
|
+
| `x-wait` | `effects.side-effect-arrives` |
|
|
925
1098
|
|
|
926
1099
|
What each tag **sharpens** (the check already runs, but the verdict changes):
|
|
927
1100
|
|
|
@@ -969,9 +1142,15 @@ x-invite:
|
|
|
969
1142
|
granteeField: key
|
|
970
1143
|
tokenPointer: $.token
|
|
971
1144
|
grantPointer: $.grant_id
|
|
1145
|
+
tokenFrom: response # or outOfBand
|
|
1146
|
+
tokenKind: org-invite # only when tokenFrom is outOfBand; default `${entity}-invite`
|
|
972
1147
|
```
|
|
973
1148
|
|
|
974
|
-
Put this on the invite operation. Config must give the invitee `inviteAs`. Defaults if omitted: `granteeField: key`, `tokenPointer: $.token`, `grantPointer: $.grant_id`. All three of `invite` / `accept` / `revoke` (operationIds) are required or the tag is ignored.
|
|
1149
|
+
Put this on the invite operation. Config must give the invitee `inviteAs`. Defaults if omitted: `granteeField: key`, `tokenPointer: $.token`, `grantPointer: $.grant_id`, `tokenFrom: response`. All three of `invite` / `accept` / `revoke` (operationIds) are required or the tag is ignored.
|
|
1150
|
+
|
|
1151
|
+
`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.
|
|
1152
|
+
|
|
1153
|
+
`tokenFrom: outOfBand` ignores the response token and calls `resolveOutOfBand({ address: inviteAs, kind })` 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.
|
|
975
1154
|
|
|
976
1155
|
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.
|
|
977
1156
|
|
|
@@ -983,23 +1162,65 @@ Accept and revoke send the documented JSON request body when the operation decla
|
|
|
983
1162
|
|
|
984
1163
|
### `x-query`
|
|
985
1164
|
|
|
1165
|
+
String arrays still work. Structured rows are optional. Either, not both required.
|
|
1166
|
+
|
|
986
1167
|
```yaml
|
|
987
1168
|
x-query:
|
|
988
|
-
|
|
989
|
-
|
|
1169
|
+
grammar: postgrest # postgrest | colon | equality
|
|
1170
|
+
filterable: [id, name, created_at]
|
|
1171
|
+
sortable: [name, created_at]
|
|
990
1172
|
searchable: [name, slug]
|
|
991
|
-
selectable: [id, name,
|
|
1173
|
+
selectable: [id, name, created_at]
|
|
992
1174
|
maxLimit: 100
|
|
993
1175
|
defaultOrder: created_at.desc
|
|
994
1176
|
stableTiebreak: id
|
|
995
|
-
|
|
1177
|
+
|
|
1178
|
+
# structured (optional) — unlocks per-field ops / types / nulls
|
|
1179
|
+
filterable:
|
|
1180
|
+
- { field: id, type: string, ops: [eq, neq, in, nin] }
|
|
1181
|
+
- { field: name, type: string, ops: [eq, neq, like, ilike, in] }
|
|
1182
|
+
- { field: created_at, type: date, ops: [eq, gt, gte, lt, lte, is] }
|
|
1183
|
+
- { field: tags, type: array, ops: [contains, eq, is] }
|
|
1184
|
+
sortable:
|
|
1185
|
+
- { field: name, type: string }
|
|
1186
|
+
- { field: created_at, type: date, nulls: [first, last] }
|
|
1187
|
+
|
|
1188
|
+
operators: [eq, ne, neq, gt, gte, lt, lte, in, nin, like, ilike, is, contains]
|
|
1189
|
+
operatorsByType:
|
|
1190
|
+
string: [eq, ne, neq, like, ilike, in, nin, is]
|
|
1191
|
+
number: [eq, ne, neq, gt, gte, lt, lte, in, nin, is]
|
|
1192
|
+
date: [eq, ne, neq, gt, gte, lt, lte, is]
|
|
1193
|
+
enum: [eq, ne, neq, in, nin, is]
|
|
1194
|
+
boolean: [eq, ne, neq, is]
|
|
1195
|
+
array: [contains, eq, is]
|
|
1196
|
+
aliases: { ne: neq }
|
|
1197
|
+
identityFilter: _id # filter field when it differs from the JSON identity
|
|
1198
|
+
emptyIn: match-none # reject | match-none; undeclared → empty-in check skips
|
|
1199
|
+
maxInValues: 100
|
|
1200
|
+
maxFilterConditions: 20
|
|
1201
|
+
searchModes: [keyword] # free-form; undeclared → mode checks skip
|
|
1202
|
+
searchEmpty: match-all # ignore | match-all | reject
|
|
1203
|
+
sortNulls: [first, last]
|
|
1204
|
+
maxSortKeys: 3
|
|
1205
|
+
selectNested: true # `rel(col)` grammar; undeclared → nested check skips
|
|
1206
|
+
selectUnknown: reject # reject | ignore; undeclared → unknown-select check skips
|
|
1207
|
+
|
|
1208
|
+
# harvest any axis from another GET — no baked path
|
|
1209
|
+
filterableFrom: { operationId: table.get, path: $.columns[*].name, typePath: $.columns[*].type, typeMap: { text: string, int: number } }
|
|
1210
|
+
sortableFrom: { operationId: table.get, path: $.columns[*].name }
|
|
1211
|
+
searchableFrom: { operationId: table.get, path: $.columns[?(@.searchable==true)].name }
|
|
1212
|
+
selectableFrom: { operationId: table.get, path: $.columns[*].name }
|
|
996
1213
|
```
|
|
997
1214
|
|
|
998
|
-
|
|
1215
|
+
`defaultOrder` / `stableTiebreak` / `maxLimit` stay. Harvest keys are generic JSON paths (`$`, dots, `[n]`, `[*]`, `[?(@.key==value)]`). Absent → no harvest.
|
|
999
1216
|
|
|
1000
|
-
|
|
1217
|
+
String arrays + `operatorsByType` is enough for most APIs. Per-field `ops` / `nulls` close the list that field actually accepts.
|
|
1001
1218
|
|
|
1002
|
-
**
|
|
1219
|
+
**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.
|
|
1220
|
+
|
|
1221
|
+
**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).
|
|
1222
|
+
|
|
1223
|
+
`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.
|
|
1003
1224
|
|
|
1004
1225
|
### `x-async`
|
|
1005
1226
|
|
|
@@ -1037,6 +1258,22 @@ x-effects:
|
|
|
1037
1258
|
|
|
1038
1259
|
**Fallback:** derived from `x-entity.action` for this entity only.
|
|
1039
1260
|
|
|
1261
|
+
### `x-wait`
|
|
1262
|
+
|
|
1263
|
+
```yaml
|
|
1264
|
+
x-wait:
|
|
1265
|
+
operationId: inbox.list
|
|
1266
|
+
until: $.items.0
|
|
1267
|
+
timeoutMs: 30000
|
|
1268
|
+
pollIntervalMs: 1000
|
|
1269
|
+
```
|
|
1270
|
+
|
|
1271
|
+
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.
|
|
1272
|
+
|
|
1273
|
+
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”.
|
|
1274
|
+
|
|
1275
|
+
**Fallback:** the check does not run.
|
|
1276
|
+
|
|
1040
1277
|
### `x-soft-delete`
|
|
1041
1278
|
|
|
1042
1279
|
```yaml
|
|
@@ -1195,71 +1432,108 @@ export default defineConfig({
|
|
|
1195
1432
|
|
|
1196
1433
|
## Checks
|
|
1197
1434
|
|
|
1198
|
-
|
|
1435
|
+
94 checks. A check that cannot run says so (`did not apply` + `needs`). A check that depends on a broken primitive is `BLOCKED`. A check that ran and stopped is inconclusive, not a pass.
|
|
1199
1436
|
|
|
1200
1437
|
Order is fixed (foundations first) so cascade suppression has a cause to point at. Mutating checks run alone; read-only checks may share in-flight requests under `maxInFlight`.
|
|
1201
1438
|
|
|
1202
1439
|
`depends` is the `dependsOn` list: if any of those already failed **for this entity**, this check is `BLOCKED` rather than reported as a second defect. Suppression is transitive.
|
|
1203
1440
|
|
|
1204
|
-
| id
|
|
1205
|
-
|
|
|
1206
|
-
| `list.read-after-write`
|
|
1207
|
-
| `create.persists-submitted-fields`
|
|
1208
|
-
| `payload.string-survives`
|
|
1209
|
-
| `create.status-matches-document`
|
|
1210
|
-
| `response.status-is-documented`
|
|
1211
|
-
| `schema.success-response-matches-document`
|
|
1212
|
-
| `schema.error-response-matches-document`
|
|
1213
|
-
| `pagination.limit-bounds-page-size`
|
|
1214
|
-
| `pagination.limit-respects-documented-max`
|
|
1215
|
-
| `pagination.has-more-is-accurate`
|
|
1216
|
-
| `pagination.page-walk-covers-set`
|
|
1217
|
-
| `pagination.cursor-agrees-with-page`
|
|
1218
|
-
| `filter.unknown-field-rejected`
|
|
1219
|
-
| `filter.equality-selects-exactly-one`
|
|
1220
|
-
| `filter.zero-match-returns-none`
|
|
1221
|
-
| `filter.negation-partitions-the-set`
|
|
1222
|
-
| `filter.and-composes-as-intersection`
|
|
1223
|
-
| `filter.or-composes-as-union`
|
|
1224
|
-
| `filter.like-metacharacters-escaped`
|
|
1225
|
-
| `filter.numeric-comparison-is-numeric`
|
|
1226
|
-
| `
|
|
1227
|
-
| `
|
|
1228
|
-
| `
|
|
1229
|
-
| `
|
|
1230
|
-
| `
|
|
1231
|
-
| `
|
|
1232
|
-
| `
|
|
1233
|
-
| `
|
|
1234
|
-
| `
|
|
1235
|
-
| `
|
|
1236
|
-
| `
|
|
1237
|
-
| `
|
|
1238
|
-
| `
|
|
1239
|
-
| `
|
|
1240
|
-
| `spec.declared-filterable-
|
|
1241
|
-
| `spec.declared-
|
|
1242
|
-
| `
|
|
1243
|
-
| `
|
|
1244
|
-
| `
|
|
1245
|
-
| `
|
|
1246
|
-
| `
|
|
1247
|
-
| `
|
|
1248
|
-
| `
|
|
1249
|
-
| `
|
|
1250
|
-
| `
|
|
1251
|
-
| `
|
|
1252
|
-
| `
|
|
1253
|
-
| `
|
|
1254
|
-
| `
|
|
1255
|
-
| `
|
|
1256
|
-
| `
|
|
1257
|
-
| `
|
|
1258
|
-
| `
|
|
1259
|
-
| `
|
|
1260
|
-
| `
|
|
1261
|
-
| `
|
|
1262
|
-
| `
|
|
1441
|
+
| id | asserts | needs | depends |
|
|
1442
|
+
| ---------------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | --------------------------------------- |
|
|
1443
|
+
| `list.read-after-write` | a just-created record appears on the list | create + a seeded record | — |
|
|
1444
|
+
| `create.persists-submitted-fields` | every writable field sent on create is echoed | create that echoes the record | `list.read-after-write` |
|
|
1445
|
+
| `payload.string-survives` | a documented-valid string is stored exactly; 4xx after an ASCII control is a fail | update or create+delete, item GET, unconstrained string | `list.read-after-write` |
|
|
1446
|
+
| `create.status-matches-document` | create status is one the document declared | create | — |
|
|
1447
|
+
| `response.status-is-documented` | every non-create exchange returns a status that operation names (`default` ≠ 201) | a modeled non-create operation oat invoked | — |
|
|
1448
|
+
| `schema.success-response-matches-document` | create body validates against the success schema | success schema on create | `create.status-matches-document` |
|
|
1449
|
+
| `schema.error-response-matches-document` | an error body validates against the documented error schema | error schema on the item route | — |
|
|
1450
|
+
| `pagination.limit-bounds-page-size` | page size ≤ the requested limit | page-size _role_ (aliases include `limit`, `per_page`, …) | `list.read-after-write` |
|
|
1451
|
+
| `pagination.limit-respects-documented-max` | requesting more than `maxLimit` does not return more | declared `maxLimit` and a larger cohort | `pagination.limit-bounds-page-size` |
|
|
1452
|
+
| `pagination.has-more-is-accurate` | `hasMore` / `Link rel=next` matches whether another page exists | page-forward + `hasMore` or `Link rel=next` | `pagination.limit-bounds-page-size` |
|
|
1453
|
+
| `pagination.page-walk-covers-set` | walking pages covers the collection with no gaps or dupes | page or offset + ≥3 records | `pagination.limit-bounds-page-size` |
|
|
1454
|
+
| `pagination.cursor-agrees-with-page` | cursor walk and page walk yield the same set | both cursor and page | `pagination.limit-bounds-page-size` |
|
|
1455
|
+
| `filter.unknown-field-rejected` | a filter on a field that does not exist is not silently ignored | a filter expression | — |
|
|
1456
|
+
| `filter.equality-selects-exactly-one` | `id.eq.<one>` returns that one record | equality on the identity | `list.read-after-write` |
|
|
1457
|
+
| `filter.zero-match-returns-none` | a filter that matches nothing returns an empty page, not the whole set | same | `list.read-after-write` |
|
|
1458
|
+
| `filter.negation-partitions-the-set` | `eq` ∪ `neq` = whole set, intersection empty | eq and neq | `list.read-after-write`, equality |
|
|
1459
|
+
| `filter.and-composes-as-intersection` | `and(A,B)` = A ∩ B | two filterable fields + AND | equality / list |
|
|
1460
|
+
| `filter.or-composes-as-union` | `or(A,B)` = A ∪ B | `or()` — **postgrest grammar only** | equality / list |
|
|
1461
|
+
| `filter.like-metacharacters-escaped` | `%` `_` `*` in a value are literals, not wildcards | like operator | `list.read-after-write` |
|
|
1462
|
+
| `filter.numeric-comparison-is-numeric` | `gt`/`lt` on a number uses numeric order, not TEXT (`1,10,2`) | numeric field + a filter param | `list.read-after-write`, unknown-field |
|
|
1463
|
+
| `filter.in-is-union-of-eq` | `in.(a,b)` = `eq.a` ∪ `eq.b` | field allows `in`; ≥2 distinct values | equality |
|
|
1464
|
+
| `filter.nin-complements-in` | `in` ∩ `nin` empty; union = set minus nulls | field allows `in` and `nin` | `in` |
|
|
1465
|
+
| `filter.gte-is-gt-or-eq` | `gte.x` = `gt.x` ∪ `eq.x` | field allows `gte` and `gt`; ordered type | numeric comparison |
|
|
1466
|
+
| `filter.lte-is-lt-or-eq` | `lte.x` = `lt.x` ∪ `eq.x` | field allows `lte` and `lt` | numeric comparison |
|
|
1467
|
+
| `filter.ordered-triple-partitions` | `lt` ∪ `eq` ∪ `gt` = set minus nulls; pairwise disjoint | field allows all three | numeric comparison |
|
|
1468
|
+
| `filter.ilike-is-case-insensitive` | case-flipped value: `ilike` matches; `like` does not (unresolved if both match) | field allows both | like |
|
|
1469
|
+
| `filter.is-null-selects-nulls` | `is.null` = nulls; `is.notnull` = complement | field allows `is`; cohort has a null | foundations |
|
|
1470
|
+
| `filter.contains-membership` | `contains.<el>` = records whose array value includes `el` | array field or ops include `contains` | foundations |
|
|
1471
|
+
| `filter.nested-and-or-distributes` | `and(A,or(B,C))` = (A∩B) ∪ (A∩C) | postgrest grammar; ≥2 filterable fields | `and` / `or` |
|
|
1472
|
+
| `filter.alias-matches-canonical` | each declared alias token selects the same id-set as its target | `aliases` non-empty | equality |
|
|
1473
|
+
| `filter.illegal-op-rejected` | one op **not** in that field's `ops` is 4xx | closed `ops` list | unknown-field |
|
|
1474
|
+
| `filter.empty-in` | `in.()` is 4xx (`reject`) or zero rows (`match-none`) | `emptyIn` set; field allows `in` | `in` |
|
|
1475
|
+
| `filter.in-over-limit-rejected` | `in` list of `maxInValues+1` is 4xx | `maxInValues` set | foundations |
|
|
1476
|
+
| `filter.condition-cap-rejected` | `maxFilterConditions+1` `eq` terms is 4xx | `maxFilterConditions` set | foundations |
|
|
1477
|
+
| `spec.declared-filterable-ops-accepted` | every declared field × every op in that field's `ops` returns <400 | closed `ops` | declared-filterable |
|
|
1478
|
+
| `spec.declared-filterable-illegal-op-rejected` | one illegal op per declared field is 4xx | closed `ops` | illegal-op |
|
|
1479
|
+
| `error.malformed-filter-not-5xx` | garbage filter text is 4xx, never 5xx | a filter expression | — |
|
|
1480
|
+
| `query.filter-selects-from-whole-set` | a filter is applied to the collection, not to the current page | filterable + ≥3 records | list / walk |
|
|
1481
|
+
| `sort.order-is-applied` | requesting a sort actually rearranges the page | order + a sortable field | `pagination.limit-bounds-page-size` |
|
|
1482
|
+
| `sort.reverse-symmetry` | desc is the reverse of asc (nulls included) | order + asc/desc | order-is-applied |
|
|
1483
|
+
| `sort.unknown-field-rejected` | `order` on an undeclared field is 4xx, not silent ignore | order param | — |
|
|
1484
|
+
| `sort.numeric-order-is-numeric` | `1,10,2` sorts as numbers, not text | numeric sortable field whose lexical order disagrees | order-is-applied |
|
|
1485
|
+
| `sort.nulls-first-last` | `nullsfirst` / `nullslast` put nulls at the start / end of asc | declared nulls token; dotted grammar; cohort has a null | order + reverse |
|
|
1486
|
+
| `sort.multi-key-tiebreak` | ties on the first key are ordered by the second | ≥2 sortable fields; `maxKeys` absent or ≥2 | order + reverse |
|
|
1487
|
+
| `sort.default-order-applied` | omitting `order` matches `defaultOrder` | `defaultOrder` set; walk complete | order + walk |
|
|
1488
|
+
| `sort.stable-tiebreak` | the same `order` twice yields the same sequence | `stableTiebreak` set | order-is-applied |
|
|
1489
|
+
| `spec.declared-sortable-nulls-accepted` | each declared nulls token returns <400 | some field/global declares `nulls` | order-is-applied |
|
|
1490
|
+
| `search.q-narrows-result` | a search term that matches one record does not return the whole set | search param + searchable fields | `list.read-after-write` |
|
|
1491
|
+
| `search.tokens-and` | `q=a b` = intersection (AND). Sameness of AND and OR passes | searchable fields; two tokens that split the cohort | `q-narrows` |
|
|
1492
|
+
| `search.case-insensitive` | case-flipped token matches the same set (unresolved if the backend is case-sensitive) | searchable field with a letter | `q-narrows` |
|
|
1493
|
+
| `search.empty-q` | `q=` is ignore / match-all / reject as declared | `searchEmpty` set | `q-narrows` |
|
|
1494
|
+
| `search.undeclared-field-not-required` | extra recall on a non-searchable field is not `SEARCH_IGNORED` | ≥1 searchable and ≥1 non-searchable string | `q-narrows` |
|
|
1495
|
+
| `search.mode-accepted` | each declared `searchModes` value is <400 on the mode param | `searchModes` set and a mode role | — |
|
|
1496
|
+
| `search.modes-differ` | two modes may differ; exact sameness is unresolved, not a fail | ≥2 `searchModes`; a mode param | `mode-accepted` |
|
|
1497
|
+
| `select.projection-honoured` | `select=id,name` does not return undeclared fields | select param | — |
|
|
1498
|
+
| `select.requested-fields-present` | every name in `select=` appears on each returned item | select param; ≥1 selectable field | projection |
|
|
1499
|
+
| `select.unknown-field-rejected` | unknown select name is 4xx (`reject`) or dropped (`ignore`) | `select.unknown` set | projection |
|
|
1500
|
+
| `select.nested-honoured` | `rel(col)` returns `rel` as an object/array carrying only `col` | `select.nested` and a named relation | projection |
|
|
1501
|
+
| `count.consistent-with-returned-page` | envelope total ≥ rows on this page, and is not zero when the page is not | envelope total | `list.read-after-write` |
|
|
1502
|
+
| `count.matches-filtered-set` | filtered total equals the size of the filtered walk | total + a filter | list, equality |
|
|
1503
|
+
| `query.axes-compose` | filter + sort together: filter still holds on the sorted page | filterable + sortable | filter + sort foundations |
|
|
1504
|
+
| `query.filter-and-select-compose` | filter + select together | filterable + select | same |
|
|
1505
|
+
| `query.search-and-filter-compose` | search + filter together | filterable + search | same |
|
|
1506
|
+
| `query.filter-sort-select-compose` | filter + sort + select | filter + sort + select | same |
|
|
1507
|
+
| `query.filter-search-sort-compose` | filter + search + sort | filter + search + sort | same |
|
|
1508
|
+
| `query.filter-search-select-compose` | filter + search + select | filter + search + select | same |
|
|
1509
|
+
| `query.sort-and-select-compose` | sort + select: order holds; extras dropped; requested fields present | sortable + selectable | sort + select |
|
|
1510
|
+
| `query.search-and-select-compose` | search + select: search membership holds; extras dropped | searchable + selectable | search + select |
|
|
1511
|
+
| `query.search-and-sort-compose` | search + sort: search membership holds; remaining rows ordered | searchable + sortable | search + sort |
|
|
1512
|
+
| `query.filter-search-sort-select-compose` | all four: filter ∩ search; order holds; extras dropped | all four axes declared | the triples |
|
|
1513
|
+
| `spec.declared-filterable-is-filterable` | every `x-query.filterable` field actually accepts a filter | `x-query` naming filterable fields | filter foundations |
|
|
1514
|
+
| `spec.declared-sortable-is-sortable` | every `x-query.sortable` field actually accepts a sort | `x-query` naming sortable fields | sort foundations |
|
|
1515
|
+
| `spec.declared-selectable-is-selectable` | every `x-query.selectable` field actually accepts a select | `x-query` naming selectable fields | select |
|
|
1516
|
+
| `tenant.item-not-readable-cross-tenant` | principal B cannot GET principal A's item | second principal, different `roots`, and a tagged or inferred tenant | — |
|
|
1517
|
+
| `tenant.denial-does-not-reveal-existence` | 404 vs 403 (or equivalent) does not distinguish "exists other tenant" from "missing" | second principal, and a tagged or inferred tenant | `tenant.item-not-readable-cross-tenant` |
|
|
1518
|
+
| `tenant.filter-does-not-bypass-scope` | `filter=id.eq.<other tenant>` does not return that row | second principal + a filter | `query.filter-selects-from-whole-set` |
|
|
1519
|
+
| `auth.rank-is-monotonic` | a lower rank cannot do what a higher rank is denied | two same-tenant principals at different `rank` | `list.read-after-write` |
|
|
1520
|
+
| `auth.invite-grants-then-revokes` | invite → accept grants; revoke takes it back | `x-invite` + peer with `inviteAs` | list, cross-tenant |
|
|
1521
|
+
| `patch.immutable-field-rejected` | PATCHing an `x-immutable` field is rejected or ignored | `x-immutable` | — |
|
|
1522
|
+
| `softdelete.absent-from-default-list` | a soft-deleted row is gone from the default list | `x-soft-delete` | `list.read-after-write` |
|
|
1523
|
+
| `invalidation.declared-route-changes` | after a write, the other entity's listed route actually changes | `x-invalidate` naming another entity | list, persist |
|
|
1524
|
+
| `effects.declared-effect-occurs` | `x-effects` cardinality delta is observed on the named list | `x-effects` | `list.read-after-write` |
|
|
1525
|
+
| `effects.side-effect-arrives` | after the write, the named GET’s JSON path is occupied before `timeoutMs` | `x-wait` | `list.read-after-write` |
|
|
1526
|
+
| `async.reaches-terminal-state` | `x-async` reaches `until` (poll, or a terminal SSE frame) before `timeoutMs` | `x-async` | — |
|
|
1527
|
+
| `async.receipt-identifies-the-job` | `idFrom` on the receipt (JSON object or SSE event JSON) resolves to a pollable job | `x-async` + `idFrom` | — |
|
|
1528
|
+
| `patch.minimality` | PATCH `{ name }` does not clear other writable fields | update + item route | — |
|
|
1529
|
+
| `idempotency.replay-does-not-duplicate` | same Idempotency-Key + same body does not create a second row | create + documented Idempotency-Key header | list, persist |
|
|
1530
|
+
| `delete.absent-record-returns-404` | DELETE of a missing id is 404, not 200 | delete | — |
|
|
1531
|
+
| `concurrency.no-lost-update` | two PATCHes to different fields do not clobber each other | update + two writable strings | persist + patch |
|
|
1532
|
+
| `validation.enum-enforced` | a value outside the enum is rejected | enum in the request schema | — |
|
|
1533
|
+
| `validation.max-length-enforced` | a string over `maxLength` is rejected | maxLength | — |
|
|
1534
|
+
| `validation.required-enforced` | omitting a required field is rejected | required field | — |
|
|
1535
|
+
| `validation.content-type-enforced` | a wrong Content-Type is 415 when 415 is documented | documented 415 | — |
|
|
1536
|
+
| `consistency.projections-agree` | list, item, and filtered views of the same field agree | item route + a comparable field | list + persist + filter |
|
|
1263
1537
|
|
|
1264
1538
|
On a typical untagged CRUD document (create, list, item, `page`/`limit`, maybe `sort`):
|
|
1265
1539
|
|
|
@@ -1283,6 +1557,12 @@ SECURITY table GET /tables/{id} readable with the other tenant's key
|
|
|
1283
1557
|
SPEC_BUG table x-query.filterable lists "ghost"; filter=ghost.eq.x is 400
|
|
1284
1558
|
spec.declared-filterable-is-filterable
|
|
1285
1559
|
|
|
1560
|
+
BACKEND_BUG table in() is not the union of the equalities it lists
|
|
1561
|
+
filter.in-is-union-of-eq
|
|
1562
|
+
|
|
1563
|
+
BACKEND_BUG table an illegal filter operator is accepted
|
|
1564
|
+
filter.illegal-op-rejected
|
|
1565
|
+
|
|
1286
1566
|
COVERAGE_GAP batch no x-async; receipt treated as the result
|
|
1287
1567
|
async.reaches-terminal-state
|
|
1288
1568
|
```
|
|
@@ -1327,19 +1607,26 @@ Console (stdout) after a run:
|
|
|
1327
1607
|
|
|
1328
1608
|
## Reports
|
|
1329
1609
|
|
|
1330
|
-
Written under `--out` (default
|
|
1610
|
+
Written under `--out` (default `./.oat/runs`). Each invocation creates a UTC timestamp folder and points `latest` at it:
|
|
1331
1611
|
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
|
1340
|
-
|
|
|
1341
|
-
| `
|
|
1342
|
-
| `
|
|
1612
|
+
```
|
|
1613
|
+
.oat/runs/2026-08-18T12-00-00Z/
|
|
1614
|
+
.oat/runs/latest -> 2026-08-18T12-00-00Z
|
|
1615
|
+
```
|
|
1616
|
+
|
|
1617
|
+
`--out` / `outDir` replace the root, not the leaf — `--out .oat/runs/prod` writes `.oat/runs/prod/<datetime>/`.
|
|
1618
|
+
|
|
1619
|
+
| file | |
|
|
1620
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------ |
|
|
1621
|
+
| `oat-report.md` | human report: summary, findings with request/response excerpts, coverage, latency p50/p95/max |
|
|
1622
|
+
| `oat-report.json` | same data for CI |
|
|
1623
|
+
| `matrix.html` | visual matrix of entities × checks |
|
|
1624
|
+
| `matrix.json` | the same graph (AI-friendly), including a mermaid string |
|
|
1625
|
+
| `issue-repro/*.sh` | one executable `curl` script per finding that has evidence. Directory is omitted when the run is clean |
|
|
1626
|
+
| `progress.log` | logfmt, one event per line, never truncated |
|
|
1627
|
+
| `progress.jsonl` | same events as JSON |
|
|
1628
|
+
| `progress.tsv` | same columns, tab-separated |
|
|
1629
|
+
| `progress.json` | latest snapshot only (overwritten ~1s) |
|
|
1343
1630
|
|
|
1344
1631
|
There is no HAR file. The JSON report and issue-repro scripts carry the exchanges.
|
|
1345
1632
|
|
|
@@ -1464,25 +1751,27 @@ curl -sS -X PATCH "$BASE/v1/products/p1" \
|
|
|
1464
1751
|
|
|
1465
1752
|
`progress.log` starts with a glossary. Fields:
|
|
1466
1753
|
|
|
1467
|
-
| key |
|
|
1468
|
-
| -------------------------- |
|
|
1469
|
-
| `ts` | ISO-8601 UTC when the line was written
|
|
1470
|
-
| `status` | `ok
|
|
1471
|
-
| `done` / `total` | entity index / how many entities
|
|
1472
|
-
| `phase` | `load` \| `auth` \| `seed` \| `test` \| `teardown` \| `done`
|
|
1473
|
-
| `entity` / `check` | current entity and check id
|
|
1474
|
-
| `msg` | phase note
|
|
1475
|
-
| `req` / `find` | request count, finding count
|
|
1476
|
-
| `method` / `path` / `http` | last
|
|
1477
|
-
| `last_ms` | duration of
|
|
1478
|
-
| `idle_ms` | ms since
|
|
1479
|
-
| `elapsed_ms` | wall clock since start
|
|
1754
|
+
| key | |
|
|
1755
|
+
| -------------------------- | -------------------------------------------------------------------------------- |
|
|
1756
|
+
| `ts` | ISO-8601 UTC when the line was written |
|
|
1757
|
+
| `status` | `ok`, `stall` (`idle_ms` ≥ 15000 after the last call returned), or `in_flight` |
|
|
1758
|
+
| `done` / `total` | entity index / how many entities |
|
|
1759
|
+
| `phase` | `load` \| `auth` \| `seed` \| `test` \| `teardown` \| `done` |
|
|
1760
|
+
| `entity` / `check` | current entity and check id |
|
|
1761
|
+
| `msg` | phase note. In-flight is `status`, not `msg` |
|
|
1762
|
+
| `req` / `find` | request count, finding count |
|
|
1763
|
+
| `method` / `path` / `http` | last completed call, or the in-flight call when `status=in_flight` (`http=-`) |
|
|
1764
|
+
| `last_ms` | duration of the last completed call. `-` while `in_flight` |
|
|
1765
|
+
| `idle_ms` | while `in_flight`: ms since this request started. otherwise ms since it returned |
|
|
1766
|
+
| `elapsed_ms` | wall clock since start |
|
|
1480
1767
|
|
|
1481
1768
|
`--quiet` keeps the files and drops stderr.
|
|
1482
1769
|
|
|
1483
|
-
|
|
1770
|
+
Every HTTP call emits a **start** line (`status=in_flight`, `http=-`, `last_ms=-`) when `fetch` is issued, then a **completed** line when it returns (`http=200`, `last_ms` ≈ wall time, `idle_ms=0`). The 5 s heartbeat keeps the in-flight path and climbs `idle_ms` until the call returns; after return it climbs on the completed call (and becomes `stall` at 15 s). A 300 s `POST /extract` therefore stays `/extract` in the log, not the previous refresh or GET.
|
|
1771
|
+
|
|
1772
|
+
Lines are also written when the phase/entity/check/message changes, or every 2 s, or on `load`/`done`. `progress.json` is rewritten about once a second.
|
|
1484
1773
|
|
|
1485
|
-
If `idle_ms` climbs through a long poll (`x-async`) that is expected. If it climbs on a simple GET
|
|
1774
|
+
If `idle_ms` climbs through a long poll (`x-async`) that is expected. If it climbs on a simple GET while `status` is not `in_flight`, the process or the network is stuck. oat's own `fetch` has **no timeout**.
|
|
1486
1775
|
|
|
1487
1776
|
## Programmatic API
|
|
1488
1777
|
|
|
@@ -1511,7 +1800,7 @@ const result = await run({
|
|
|
1511
1800
|
seed: 1,
|
|
1512
1801
|
maxInFlight: 4,
|
|
1513
1802
|
onProgress: (snap) => {
|
|
1514
|
-
// snap.phase, snap.entity, snap.check, snap.message, …
|
|
1803
|
+
// snap.phase, snap.entity, snap.check, snap.inflight, snap.last, snap.message, …
|
|
1515
1804
|
},
|
|
1516
1805
|
})
|
|
1517
1806
|
|
|
@@ -1531,7 +1820,7 @@ const { doc: resolved, externalRefs } = dereference(doc)
|
|
|
1531
1820
|
const model = buildModel(resolved)
|
|
1532
1821
|
```
|
|
1533
1822
|
|
|
1534
|
-
Types exported: `OatConfig`, `Principal`, `AuthFlow`, `AuthRefresh`, `AuthStep`, `Hooks`, `Uploads`, `UploadRequest`, `UploadFile`, `RunOptions`, `RunResult`, `Finding`, `Verdict`, `Actor`, `SpecModel`, `EntityModel`, `OperationModel`, `OpenApiDocument`, `AuthRefreshRequiredError`, matrix types.
|
|
1823
|
+
Types exported: `OatConfig`, `Principal`, `AuthFlow`, `AuthRefresh`, `AuthStep`, `HookAuth`, `Hooks`, `Uploads`, `UploadRequest`, `UploadFile`, `HeaderRequest`, `InputRequest`, `OriginSpec`, `OutOfBandConfig`, `RunOptions`, `RunResult`, `Finding`, `Verdict`, `Actor`, `SpecModel`, `EntityModel`, `OperationModel`, `OpenApiDocument`, `AuthRefreshRequiredError`, `loadPersistedPrincipals`, `worstCaseWaitMs`, `allocateRunDir`, `DEFAULT_RUNS_ROOT`, matrix types.
|
|
1535
1824
|
|
|
1536
1825
|
## CI
|
|
1537
1826
|
|
|
@@ -1551,7 +1840,7 @@ Against **your** API:
|
|
|
1551
1840
|
API_TOKEN_B: ${{ secrets.API_TOKEN_B }}
|
|
1552
1841
|
```
|
|
1553
1842
|
|
|
1554
|
-
Gate on exit code 1 (defects). Read
|
|
1843
|
+
Gate on exit code 1 (defects). Read `.oat/runs/latest/oat-report.json` if you need to classify verdicts.
|
|
1555
1844
|
|
|
1556
1845
|
Wipe leftover rows on a shared database between runs if a previous `--keep-fixtures` or a crashed teardown left data. Leftover rows make numeric/filter checks look like type bugs (`1, 10, 2` from old TEXT-sorted leftovers mixed with a fresh numeric cohort).
|
|
1557
1846
|
|
|
@@ -1598,6 +1887,14 @@ Comma-separated. Each is one named lie the demo API can tell. Primary check is w
|
|
|
1598
1887
|
| `SPEC_OVERCLAIMS_FILTERABLE` | `spec.declared-filterable-is-filterable` | `x-query` lists a field that 400s |
|
|
1599
1888
|
| `SPEC_OVERCLAIMS_SORTABLE` | `spec.declared-sortable-is-sortable` | same for sort |
|
|
1600
1889
|
| `SPEC_OVERCLAIMS_SELECTABLE` | `spec.declared-selectable-is-selectable` | same for select |
|
|
1890
|
+
| `FILTER_IN_FIRST_ONLY` | `filter.in-is-union-of-eq` | `in.(a,b)` matches only the first value |
|
|
1891
|
+
| `FILTER_GTE_IS_GT` | `filter.gte-is-gt-or-eq` | `gte` is compiled as `gt` |
|
|
1892
|
+
| `FILTER_ILIKE_IS_LIKE` | `filter.ilike-is-case-insensitive` | `ilike` is compiled as `like` |
|
|
1893
|
+
| `FILTER_IS_NULL_MATCHES_ALL` | `filter.is-null-selects-nulls` | `is.null` matches every row |
|
|
1894
|
+
| `FILTER_ILLEGAL_OP_IGNORED` | `filter.illegal-op-rejected` | an operator outside the allowlist is ignored |
|
|
1895
|
+
| `SORT_NUMERIC_AS_TEXT` | `sort.numeric-order-is-numeric` | a numeric field is ordered lexicographically |
|
|
1896
|
+
| `SORT_MULTI_KEY_IGNORED` | `sort.multi-key-tiebreak` | the second sort key is ignored |
|
|
1897
|
+
| `SELECT_FIELD_MISSING` | `select.requested-fields-present` | a requested select field is dropped |
|
|
1601
1898
|
| `CROSS_TENANT_READ` | `tenant.item-not-readable-cross-tenant` | item GET is global by id |
|
|
1602
1899
|
| `EXISTENCE_LEAK_VIA_STATUS` | `tenant.denial-does-not-reveal-existence` | 403 vs 404 reveals the other tenant's row |
|
|
1603
1900
|
| `TENANT_LEAK_VIA_FILTER` | `tenant.filter-does-not-bypass-scope` | filter drops the tenant predicate |
|
|
@@ -1633,10 +1930,10 @@ oat run --config labs/local.config.ts --base-url <url>
|
|
|
1633
1930
|
|
|
1634
1931
|
These are deliberate. An agent should not invent a flag for them.
|
|
1635
1932
|
|
|
1636
|
-
- **No request timeout.** `fetch` waits until the server answers. Watch `idle_ms
|
|
1933
|
+
- **No request timeout.** `fetch` waits until the server answers. Watch `status=in_flight` and `idle_ms` on that request.
|
|
1637
1934
|
- **No retry on 5xx / 429.** One 401 → force refresh + single retry. A second 401 is evidence.
|
|
1638
1935
|
- **No OpenAPI `security`.** Put credentials in `principals`. Cookie auth is a `headers: { cookie: "…" }` (or a flow that sets that header).
|
|
1639
|
-
- **No `servers[]`.** Always set `baseUrl`.
|
|
1936
|
+
- **No `servers[]`.** Always set `baseUrl`. Extra hosts are `origins[]`, each with its own `spec`.
|
|
1640
1937
|
- **No OCR.** Multipart and file parts are sent as dummy / pool / `resolveUpload` bytes. oat checks HTTP status and JSON responses, not whether a PDF is a real invoice.
|
|
1641
1938
|
- **No webhook / callback / link-object following.**
|
|
1642
1939
|
- **External `$ref`s are not fetched.** In-document `$ref`s are.
|