@lovrozagar/oat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +401 -0
  3. package/bin/oat.js +2 -0
  4. package/dist/cli.js +386 -0
  5. package/dist/config/define-config.d.ts +165 -0
  6. package/dist/config/define-config.d.ts.map +1 -0
  7. package/dist/config/define-config.js +24 -0
  8. package/dist/config/define-config.js.map +1 -0
  9. package/dist/config/load.d.ts +10 -0
  10. package/dist/config/load.d.ts.map +1 -0
  11. package/dist/config/load.js +61 -0
  12. package/dist/config/load.js.map +1 -0
  13. package/dist/index.d.ts +17 -0
  14. package/dist/index.js +9 -0
  15. package/dist/reference/defects.d.ts +254 -0
  16. package/dist/reference/defects.d.ts.map +1 -0
  17. package/dist/reference/defects.js +265 -0
  18. package/dist/reference/defects.js.map +1 -0
  19. package/dist/reference/dialect.d.ts +104 -0
  20. package/dist/reference/dialect.d.ts.map +1 -0
  21. package/dist/reference/dialect.js +174 -0
  22. package/dist/reference/dialect.js.map +1 -0
  23. package/dist/reference/http.d.ts +79 -0
  24. package/dist/reference/http.d.ts.map +1 -0
  25. package/dist/reference/http.js +889 -0
  26. package/dist/reference/http.js.map +1 -0
  27. package/dist/reference/model.d.ts +56 -0
  28. package/dist/reference/model.d.ts.map +1 -0
  29. package/dist/reference/model.js +156 -0
  30. package/dist/reference/model.js.map +1 -0
  31. package/dist/reference/query.d.ts +45 -0
  32. package/dist/reference/query.d.ts.map +1 -0
  33. package/dist/reference/query.js +353 -0
  34. package/dist/reference/query.js.map +1 -0
  35. package/dist/reference/spec.d.ts +13 -0
  36. package/dist/reference/spec.d.ts.map +1 -0
  37. package/dist/reference/spec.js +547 -0
  38. package/dist/reference/spec.js.map +1 -0
  39. package/dist/reference/store-api.d.ts +59 -0
  40. package/dist/reference/store-api.d.ts.map +1 -0
  41. package/dist/reference/store-api.js +76 -0
  42. package/dist/reference/store-api.js.map +1 -0
  43. package/dist/reference/stores/memory.d.ts +27 -0
  44. package/dist/reference/stores/memory.d.ts.map +1 -0
  45. package/dist/reference/stores/memory.js +97 -0
  46. package/dist/reference/stores/memory.js.map +1 -0
  47. package/dist/reference/stores/postgres.d.ts +61 -0
  48. package/dist/reference/stores/postgres.d.ts.map +1 -0
  49. package/dist/reference/stores/postgres.js +406 -0
  50. package/dist/reference/stores/postgres.js.map +1 -0
  51. package/dist/reference/stores/sqlite-driver.d.ts +54 -0
  52. package/dist/reference/stores/sqlite-driver.d.ts.map +1 -0
  53. package/dist/reference/stores/sqlite-driver.js +99 -0
  54. package/dist/reference/stores/sqlite-driver.js.map +1 -0
  55. package/dist/reference/stores/sqlite.d.ts +68 -0
  56. package/dist/reference/stores/sqlite.d.ts.map +1 -0
  57. package/dist/reference/stores/sqlite.js +464 -0
  58. package/dist/reference/stores/sqlite.js.map +1 -0
  59. package/dist/report/console.d.ts +33 -0
  60. package/dist/report/console.d.ts.map +1 -0
  61. package/dist/report/console.js +237 -0
  62. package/dist/report/console.js.map +1 -0
  63. package/dist/report/matrix.d.ts +131 -0
  64. package/dist/report/matrix.d.ts.map +1 -0
  65. package/dist/report/matrix.js +865 -0
  66. package/dist/report/matrix.js.map +1 -0
  67. package/dist/report/render.d.ts +66 -0
  68. package/dist/report/render.d.ts.map +1 -0
  69. package/dist/report/render.js +423 -0
  70. package/dist/report/render.js.map +1 -0
  71. package/dist/runtime/async.d.ts +34 -0
  72. package/dist/runtime/async.d.ts.map +1 -0
  73. package/dist/runtime/async.js +130 -0
  74. package/dist/runtime/async.js.map +1 -0
  75. package/dist/runtime/auth.d.ts +46 -0
  76. package/dist/runtime/auth.d.ts.map +1 -0
  77. package/dist/runtime/auth.js +236 -0
  78. package/dist/runtime/auth.js.map +1 -0
  79. package/dist/runtime/checks.d.ts +89 -0
  80. package/dist/runtime/checks.d.ts.map +1 -0
  81. package/dist/runtime/checks.js +3400 -0
  82. package/dist/runtime/checks.js.map +1 -0
  83. package/dist/runtime/client.d.ts +65 -0
  84. package/dist/runtime/client.d.ts.map +1 -0
  85. package/dist/runtime/client.js +122 -0
  86. package/dist/runtime/client.js.map +1 -0
  87. package/dist/runtime/finding.d.ts +50 -0
  88. package/dist/runtime/finding.d.ts.map +1 -0
  89. package/dist/runtime/finding.js +50 -0
  90. package/dist/runtime/finding.js.map +1 -0
  91. package/dist/runtime/fixture.d.ts +20 -0
  92. package/dist/runtime/fixture.d.ts.map +1 -0
  93. package/dist/runtime/fixture.js +175 -0
  94. package/dist/runtime/fixture.js.map +1 -0
  95. package/dist/runtime/progress.d.ts +41 -0
  96. package/dist/runtime/progress.d.ts.map +1 -0
  97. package/dist/runtime/progress.js +186 -0
  98. package/dist/runtime/progress.js.map +1 -0
  99. package/dist/runtime/run.d.ts +69 -0
  100. package/dist/runtime/run.d.ts.map +1 -0
  101. package/dist/runtime/run.js +575 -0
  102. package/dist/runtime/run.js.map +1 -0
  103. package/dist/runtime/teardown.d.ts +38 -0
  104. package/dist/runtime/teardown.d.ts.map +1 -0
  105. package/dist/runtime/teardown.js +129 -0
  106. package/dist/runtime/teardown.js.map +1 -0
  107. package/dist/runtime/validate.d.ts +31 -0
  108. package/dist/runtime/validate.d.ts.map +1 -0
  109. package/dist/runtime/validate.js +111 -0
  110. package/dist/runtime/validate.js.map +1 -0
  111. package/dist/runtime/world.d.ts +49 -0
  112. package/dist/runtime/world.d.ts.map +1 -0
  113. package/dist/runtime/world.js +119 -0
  114. package/dist/runtime/world.js.map +1 -0
  115. package/dist/spec/collection.d.ts +31 -0
  116. package/dist/spec/collection.d.ts.map +1 -0
  117. package/dist/spec/collection.js +121 -0
  118. package/dist/spec/collection.js.map +1 -0
  119. package/dist/spec/conventions.d.ts +103 -0
  120. package/dist/spec/conventions.d.ts.map +1 -0
  121. package/dist/spec/conventions.js +275 -0
  122. package/dist/spec/conventions.js.map +1 -0
  123. package/dist/spec/extensions.d.ts +74 -0
  124. package/dist/spec/extensions.d.ts.map +1 -0
  125. package/dist/spec/extensions.js +335 -0
  126. package/dist/spec/extensions.js.map +1 -0
  127. package/dist/spec/graph.d.ts +89 -0
  128. package/dist/spec/graph.d.ts.map +1 -0
  129. package/dist/spec/graph.js +318 -0
  130. package/dist/spec/graph.js.map +1 -0
  131. package/dist/spec/load.d.ts +36 -0
  132. package/dist/spec/load.d.ts.map +1 -0
  133. package/dist/spec/load.js +166 -0
  134. package/dist/spec/load.js.map +1 -0
  135. package/dist/spec/types.d.ts +57 -0
  136. package/dist/spec/types.d.ts.map +1 -0
  137. package/dist/spec/types.js +24 -0
  138. package/dist/spec/types.js.map +1 -0
  139. package/labs/README.md +130 -0
  140. package/labs/annotated-openapi.yaml +311 -0
  141. package/labs/anyrow.config.ts +107 -0
  142. package/labs/demo.mjs +124 -0
  143. package/labs/local.config.ts +41 -0
  144. package/labs/minimal.config.ts +32 -0
  145. package/package.json +80 -0
package/labs/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # labs — real backends, always D1
2
+
3
+ Each world is a Hono API this repo owns. **Storage is Cloudflare D1**, never
4
+ local SQLite. Local `serve.ts` talks to D1 over HTTP. After oat is green
5
+ locally, `deploy.mjs` puts the same app on a Worker bound to that D1 and oat
6
+ runs again against `https://`.
7
+
8
+ ```
9
+ node labs/provision.mjs tiny # create oat-labs-tiny, apply schema
10
+ LAB=tiny LAB_PORT=8790 node --experimental-strip-types labs/serve.ts
11
+ LAB_URL=http://127.0.0.1:8790 node --experimental-sqlite dist/cli.js run --config labs/oat.config.ts
12
+ node labs/deploy.mjs tiny # Worker + D1 binding
13
+ LAB_URL=https://oat-labs-tiny.<subdomain>.workers.dev ...
14
+ ```
15
+
16
+ Wipe leftover rows (schema stays) before a clean re-run:
17
+
18
+ ```
19
+ node --experimental-strip-types labs/wipe.mjs huge
20
+ ```
21
+
22
+ A live `oat run` writes `progress.log` and `progress.json` under `--out`
23
+ (entity, check, last request, age). If those stop moving, the run is stuck.
24
+ `--quiet` keeps the files and drops stderr.
25
+
26
+ Or the loop:
27
+
28
+ ```
29
+ bash labs/iterate.sh # tiny + probe cases on D1
30
+ bash labs/iterate.sh new # planted worlds not in the original eight
31
+ bash labs/iterate.sh shop campus
32
+ bash labs/probe-cases.sh
33
+ ```
34
+
35
+ | world | entities | what it is for |
36
+ | --- | --- | --- |
37
+ | `tiny` | 1 | one collection, invite, ranks, soft-delete |
38
+ | `shop` | 5 | store → product → review, plus order / customer. Cross-entity `x-invalidate` |
39
+ | `campus` | 10 | nested campus / building / room / booking / course |
40
+ | `platform` | 20 | a workspace SaaS. Many parents, many derived counts |
41
+ | `huge` / `vast` | 200 / 2000 | schema-only unless `LAB_SCALE=1`. Do not oat-run on D1 by default. |
42
+
43
+ Credentials: repo-root `.env` (`CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN`).
44
+ Provision writes `CLOUDFLARE_D1_<WORLD>=<uuid>` into `.env` and `labs/.d1.json`
45
+ (gitignored).
46
+
47
+ ## Correct vs planted bugs
48
+
49
+ `bash labs/probe-cases.sh` — each case is its own D1.
50
+
51
+ | world | planted | oat must report |
52
+ | --- | --- | --- |
53
+ | `ok-pair` / `ok-classic` | none | nothing |
54
+ | `ok-jobs` | none (async start + artifact effect) | nothing |
55
+ | `bug-stale` | child write does not bump `product_count` | `invalidation.declared-route-changes` |
56
+ | `bug-nofilter` | filter parses, then is dropped | `filter.equality-selects-exactly-one` |
57
+ | `bug-leak` | item GET is global by id | `tenant.item-not-readable-cross-tenant` |
58
+ | `bug-overclaim` | `x-query.filterable` lists `ghost` | `spec.declared-filterable-is-filterable` |
59
+ | `bug-pagefilter` | page first, then filter the page | `query.filter-selects-from-whole-set` |
60
+ | `bug-cursor` | `nextCursor` advertised, ignored | `pagination.cursor-agrees-with-page` |
61
+ | `bug-lostupdate` | PATCH writes the whole row | `concurrency.no-lost-update` |
62
+ | `bug-invite` | accept does not grant | `auth.invite-grants-then-revokes` |
63
+ | `bug-tombstone` | soft-deleted row stays in the default list | `softdelete.absent-from-default-list` |
64
+ | `bug-rank` | viewer can read a record member cannot | `auth.rank-is-monotonic` |
65
+ | `bug-filterleak` | a filter drops the tenant predicate | `tenant.filter-does-not-bypass-scope` |
66
+ | `bug-hasmore` | `hasMore` is always false | `pagination.has-more-is-accurate` |
67
+ | `bug-maxlimit` | documented `maxLimit` is not capped | `pagination.limit-respects-documented-max` |
68
+ | `bug-search` | `q` is accepted and ignored | `search.q-narrows-result` |
69
+ | `bug-select` | sparse fieldset is ignored | `select.projection-honoured` |
70
+ | `bug-sort` | `order` is accepted and ignored | `sort.order-is-applied` |
71
+ | `bug-idem` | `Idempotency-Key` is ignored | `idempotency.replay-does-not-duplicate` |
72
+ | `bug-immutable` | `x-immutable` fields accept writes | `patch.immutable-field-rejected` |
73
+ | `bug-enum` | enum is not enforced | `validation.enum-enforced` |
74
+ | `bug-maxlen` | maxLength is not enforced | `validation.max-length-enforced` |
75
+ | `bug-required` | required fields are not enforced | `validation.required-enforced` |
76
+ | `bug-revoke` | revoke leaves the grant in place | `auth.invite-grants-then-revokes` |
77
+ | `bug-offset` | page/offset is ignored | `pagination.page-walk-covers-set` |
78
+ | `bug-oracle` | 403 vs 404 reveals existence | `tenant.denial-does-not-reveal-existence` |
79
+ | `bug-like` | LIKE `%` is treated as a wildcard | `filter.like-metacharacters-escaped` |
80
+ | `bug-async` | job start stays `pending` forever | `async.reaches-terminal-state` |
81
+ | `bug-effect` | `x-effects` create does not insert | `effects.declared-effect-occurs` |
82
+ | `bug-oversort` | `x-query.sortable` lists `ghost` | `spec.declared-sortable-is-sortable` |
83
+ | `bug-overselect` | `x-query.selectable` lists `ghost` | `spec.declared-selectable-is-selectable` |
84
+ | `bug-receipt` | job start receipt omits `id` | `async.receipt-identifies-the-job` |
85
+ | `bug-count` | envelope `count` is always 0 | `count.consistent-with-returned-page` |
86
+ | `bug-widen` | PATCH also writes `kind` | `patch.minimality` |
87
+ | `bug-unknown` | unknown filter field is ignored | `filter.unknown-field-rejected` |
88
+ | `bug-neq` | `neq` is a no-op | `filter.negation-partitions-the-set` |
89
+ | `bug-and` | `and()` compiles as OR | `filter.and-composes-as-intersection` |
90
+ | `bug-or` | `or()` compiles as AND | `filter.or-composes-as-union` |
91
+ | `bug-numeric` | numeric `gt` compares as text | `filter.numeric-comparison-is-numeric` |
92
+ | `bug-limit` | `limit` does not bound the page | `pagination.limit-bounds-page-size` |
93
+ | `bug-dropfield` | create drops submitted `note` | `create.persists-submitted-fields` |
94
+ | `bug-status` | create returns 200 not 201 | `create.status-matches-document` |
95
+ | `bug-del404` | DELETE of a missing id is 204 | `delete.absent-record-returns-404` |
96
+ | `bug-ctype` | `text/plain` accepted on JSON create | `validation.content-type-enforced` |
97
+ | `bug-500` | malformed filter returns 500 | `error.malformed-filter-not-5xx` |
98
+ | `bug-errschema` | error body omits `error_key` | `schema.error-response-matches-document` |
99
+
100
+ Invites live in D1 (`invites` table). An in-memory map would die on Worker isolate hops.
101
+
102
+ Schema is generated, not mocked. `labs/worlds/catalog.ts` is the source;
103
+ `labs/kit/schema.ts` turns it into `CREATE TABLE` statements that
104
+ `provision.mjs` runs on a real D1. To read the SQL:
105
+
106
+ ```
107
+ node --experimental-strip-types labs/dump-schema.mjs # writes labs/schema/<world>.sql
108
+ ```
109
+
110
+ OpenAPI is generated the same way (`kit/spec.ts`) and served at
111
+ `/v1/openapi/spec`. There is no local SQLite path.
112
+
113
+ ## Configs and the fixture demo
114
+
115
+ These also live here — there is no top-level `examples/`.
116
+
117
+ | file | what it is |
118
+ | --- | --- |
119
+ | `oat.config.ts` | principals for a labs world (D1) |
120
+ | `local.config.ts` | point oat at `oat serve` (the fixture) |
121
+ | `minimal.config.ts` | smallest useful config, static API key |
122
+ | `anyrow.config.ts` | multi-step login against a stranger API |
123
+ | `annotated-openapi.yaml` | every `x-*` tag in one document |
124
+ | `demo.mjs` | one-process fixture demo (`npm run demo`) |
125
+
126
+ ```
127
+ node dist/cli.js serve --defects STALE_LIST,PATCH_REPLACES
128
+ node --experimental-strip-types dist/cli.js run --config labs/local.config.ts --base-url http://127.0.0.1:PORT
129
+ node dist/cli.js doctor --spec labs/annotated-openapi.yaml
130
+ ```
@@ -0,0 +1,311 @@
1
+ # A complete, valid OpenAPI 3.1 document showing every tag oat reads, in place.
2
+ #
3
+ # This file is not illustrative — the conformance suite loads it and asserts that oat resolves
4
+ # what the comments claim, so it cannot drift away from the implementation. Run it yourself:
5
+ #
6
+ # oat plan --spec labs/annotated-openapi.yaml --base-url https://api.example.com
7
+ #
8
+ # Every `x-` tag below is OPTIONAL. Each one either tells oat something it cannot otherwise know,
9
+ # or replaces a guess that oat would report as a coverage gap. Nothing here is oat-specific
10
+ # vocabulary you are locked into: they are plain OpenAPI extensions, and any other tool ignores
11
+ # them.
12
+
13
+ openapi: 3.1.0
14
+ info:
15
+ title: Widgets API
16
+ version: "1.0"
17
+
18
+ paths:
19
+ /v1/workspaces/{workspace_id}/widgets:
20
+ get:
21
+ operationId: widget.list
22
+ summary: List widgets
23
+
24
+ # ── x-entity ─────────────────────────────────────────────────────────────
25
+ # Which resource this operation reads, and which property identifies it.
26
+ # Without this oat guesses the entity from the path and the identity from the
27
+ # response schema — usually right, and reported as a guess when it has to.
28
+ x-entity:
29
+ name: widget
30
+ action: list
31
+ identity: id
32
+
33
+ # ── x-tenant ─────────────────────────────────────────────────────────────
34
+ # Names the parameter that scopes this route to a tenant. This is what turns a
35
+ # cross-tenant read from an AMBIGUITY ("oat inferred a boundary") into a SECURITY
36
+ # finding ("the document states a boundary and it was crossed").
37
+ x-tenant: workspace_id
38
+
39
+ # ── x-query ──────────────────────────────────────────────────────────────
40
+ # The one oat cannot work around. `filter` is declared below as `type: string`,
41
+ # which says nothing about WHICH fields it accepts — so without this tag oat
42
+ # assumes every scalar property is filterable and warns that it is guessing.
43
+ x-query:
44
+ grammar: postgrest # postgrest | colon | equality — how filter values are written
45
+ filterable: [id, workspace_id, name, status, priority, created_at]
46
+ sortable: [name, priority, created_at]
47
+ searchable: [name, description] # the fields `q` actually searches
48
+ selectable: [id, name, status, priority, created_at]
49
+ maxLimit: 100
50
+ defaultOrder: created_at.desc
51
+ # Load-bearing: without a total order, keyset pagination is unsound and page
52
+ # walks silently drop or duplicate rows. Declaring it lets oat assert it;
53
+ # omitting it makes oat probe for the instability instead.
54
+ stableTiebreak: id
55
+
56
+ parameters:
57
+ - { name: workspace_id, in: path, required: true, schema: { type: string } }
58
+ # The description is not decoration: oat infers the filter grammar from the
59
+ # syntax demonstrated here when `x-query.grammar` is absent.
60
+ - name: filter
61
+ in: query
62
+ schema: { type: string }
63
+ description: "PostgREST-style filter: field.op.value, and(...), or(...). e.g. status.eq.active"
64
+ - name: order
65
+ in: query
66
+ schema: { type: string }
67
+ description: "Sort: field[.asc|.desc], comma-separated. e.g. created_at.desc"
68
+ - name: select
69
+ in: query
70
+ schema: { type: string }
71
+ description: Comma-separated sparse fieldset
72
+ - name: q
73
+ in: query
74
+ schema: { type: string }
75
+ description: Free-text search
76
+ # `limit` carries a maximum and a default, which is how oat tells a page size
77
+ # from a total when a name is ambiguous (`count` is both, in the wild).
78
+ - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
79
+ - { name: page, in: query, schema: { type: integer, minimum: 1 } }
80
+ - { name: cursor, in: query, schema: { type: string } }
81
+
82
+ responses:
83
+ "200":
84
+ description: A page of widgets
85
+ content:
86
+ application/json:
87
+ schema:
88
+ type: object
89
+ properties:
90
+ widgets: { type: array, items: { $ref: "#/components/schemas/Widget" } }
91
+ count: { type: integer }
92
+ hasMore: { type: boolean }
93
+ nextCursor: { type: [string, "null"] }
94
+ required: [widgets, count, hasMore]
95
+
96
+ post:
97
+ operationId: widget.create
98
+ summary: Create a widget
99
+ x-entity: { name: widget, action: create, identity: id }
100
+ x-tenant: workspace_id
101
+
102
+ # ── x-invalidate ─────────────────────────────────────────────────────────
103
+ # Which read routes this write changes. oat inverts these into the entity's
104
+ # read surface — it is how the entity graph is built — AND verifies them: a
105
+ # route named here that returns a byte-identical body after the write is either
106
+ # a stale projection or a wrong declaration.
107
+ x-invalidate:
108
+ - "GET /v1/workspaces/{workspace_id}/widgets"
109
+ - "GET /v1/workspaces/{workspace_id}/summary"
110
+
111
+ # ── x-generated ──────────────────────────────────────────────────────────
112
+ # Server-owned fields. Excluded from PATCH-minimality comparisons, because a
113
+ # value the server maintains may legitimately change between two reads.
114
+ x-generated: [id, workspace_id, created_at, updated_at]
115
+
116
+ parameters:
117
+ - { name: workspace_id, in: path, required: true, schema: { type: string } }
118
+ # A declared idempotency header is a promise oat will test by replaying the
119
+ # request: the replay must return the original record, not create a second.
120
+ - { name: Idempotency-Key, in: header, required: false, schema: { type: string } }
121
+ requestBody:
122
+ required: true
123
+ content:
124
+ application/json:
125
+ schema:
126
+ type: object
127
+ additionalProperties: false
128
+ properties:
129
+ name: { type: string, maxLength: 128 }
130
+ description: { type: string, maxLength: 512 }
131
+ status: { type: string, enum: [draft, active, archived] }
132
+ priority: { type: integer, minimum: 0, maximum: 10 }
133
+ required: [name]
134
+ responses:
135
+ "201":
136
+ description: Created
137
+ content:
138
+ application/json:
139
+ schema: { $ref: "#/components/schemas/Widget" }
140
+
141
+ /v1/workspaces/{workspace_id}/widgets/{widget_id}:
142
+ get:
143
+ operationId: widget.read
144
+ x-entity: { name: widget, action: read, identity: id }
145
+ x-tenant: workspace_id
146
+ parameters:
147
+ - { name: workspace_id, in: path, required: true, schema: { type: string } }
148
+ - { name: widget_id, in: path, required: true, schema: { type: string } }
149
+ responses:
150
+ "200":
151
+ description: A widget
152
+ content:
153
+ application/json:
154
+ schema: { $ref: "#/components/schemas/Widget" }
155
+
156
+ patch:
157
+ operationId: widget.update
158
+ x-entity: { name: widget, action: update, identity: id }
159
+ x-tenant: workspace_id
160
+ x-invalidate: ["GET /v1/workspaces/{workspace_id}/widgets"]
161
+
162
+ # ── x-immutable ──────────────────────────────────────────────────────────
163
+ # Fields a client must never be able to write. oat PATCHes each one and expects
164
+ # a rejection; a backend that accepts a write to the tenant key here is how a
165
+ # record silently moves between tenants.
166
+ x-immutable: [id, workspace_id, created_at]
167
+ x-generated: [updated_at]
168
+
169
+ parameters:
170
+ - { name: workspace_id, in: path, required: true, schema: { type: string } }
171
+ - { name: widget_id, in: path, required: true, schema: { type: string } }
172
+ requestBody:
173
+ required: true
174
+ content:
175
+ application/json:
176
+ schema:
177
+ type: object
178
+ additionalProperties: false
179
+ properties:
180
+ name: { type: string, maxLength: 128 }
181
+ description: { type: string, maxLength: 512 }
182
+ status: { type: string, enum: [draft, active, archived] }
183
+ priority: { type: integer, minimum: 0, maximum: 10 }
184
+ responses:
185
+ "200":
186
+ description: Updated
187
+ content:
188
+ application/json:
189
+ schema: { $ref: "#/components/schemas/Widget" }
190
+
191
+ delete:
192
+ operationId: widget.delete
193
+ x-entity: { name: widget, action: delete, identity: id }
194
+ x-tenant: workspace_id
195
+ x-invalidate: ["GET /v1/workspaces/{workspace_id}/widgets"]
196
+
197
+ # ── x-soft-delete ────────────────────────────────────────────────────────
198
+ # Names the tombstone column. oat then asserts a deleted record disappears from
199
+ # the default listing rather than expecting a hard 404 that will never come.
200
+ x-soft-delete: deleted_at
201
+
202
+ # ── x-destructive ────────────────────────────────────────────────────────
203
+ # Advisory: oat will not run this against data it did not create.
204
+ x-destructive: true
205
+
206
+ parameters:
207
+ - { name: workspace_id, in: path, required: true, schema: { type: string } }
208
+ - { name: widget_id, in: path, required: true, schema: { type: string } }
209
+ responses:
210
+ "204": { description: Deleted }
211
+
212
+ # The route `widget.create` declares it invalidates. It exposes a count derived from
213
+ # another entity, which is exactly the case `x-invalidate` exists to make testable:
214
+ # oat snapshots this route, creates a widget, and re-reads it. A byte-identical body
215
+ # means the derived value is stale or the declaration is wrong.
216
+ /v1/workspaces/{workspace_id}/summary:
217
+ get:
218
+ operationId: workspace.summary
219
+ summary: Workspace totals
220
+ # Declared as a read of `workspace`, so oat models it as that entity's read
221
+ # surface rather than inferring a third entity with no way to verify it.
222
+ x-entity: { name: workspace, action: read, identity: workspace_id }
223
+ x-tenant: workspace_id
224
+ parameters:
225
+ - { name: workspace_id, in: path, required: true, schema: { type: string } }
226
+ responses:
227
+ "200":
228
+ description: Totals for the workspace
229
+ content:
230
+ application/json:
231
+ schema:
232
+ type: object
233
+ properties:
234
+ workspace_id: { type: string }
235
+ widget_count: { type: integer }
236
+ required: [workspace_id, widget_count]
237
+
238
+ /v1/workspaces/{workspace_id}/exports:
239
+ post:
240
+ operationId: export.start
241
+ summary: Start an export
242
+
243
+ # ── x-async ──────────────────────────────────────────────────────────────
244
+ # The request returns a receipt, not a result. Without this oat asserts against
245
+ # the receipt and reports the missing data as a bug; with it, oat follows the
246
+ # job to a terminal state and asserts on the outcome.
247
+ x-async:
248
+ poll: export.read # operationId that reports progress
249
+ idFrom: $.export_id # where the receipt carries the job id
250
+ until: $.status in [complete, failed]
251
+ successWhen: $.status == complete
252
+ timeoutMs: 30000
253
+ pollIntervalMs: 500
254
+
255
+ # ── x-effects ────────────────────────────────────────────────────────────
256
+ # What this operation changes elsewhere. `x-invalidate` says a route's response
257
+ # changes; `x-effects` says how, which is what makes it assertable.
258
+ x-effects:
259
+ - entity: widget
260
+ change: none # exporting must not mutate what it exports
261
+
262
+ x-tenant: workspace_id
263
+ parameters:
264
+ - { name: workspace_id, in: path, required: true, schema: { type: string } }
265
+ responses:
266
+ "202":
267
+ description: Accepted
268
+ content:
269
+ application/json:
270
+ schema:
271
+ type: object
272
+ properties:
273
+ export_id: { type: string }
274
+ status: { type: string, enum: [pending, running, complete, failed] }
275
+ required: [export_id, status]
276
+
277
+ /v1/workspaces/{workspace_id}/exports/{export_id}:
278
+ get:
279
+ operationId: export.read
280
+ x-entity: { name: export, action: read, identity: export_id }
281
+ x-tenant: workspace_id
282
+ parameters:
283
+ - { name: workspace_id, in: path, required: true, schema: { type: string } }
284
+ - { name: export_id, in: path, required: true, schema: { type: string } }
285
+ responses:
286
+ "200":
287
+ description: Export status
288
+ content:
289
+ application/json:
290
+ schema:
291
+ type: object
292
+ properties:
293
+ export_id: { type: string }
294
+ status: { type: string, enum: [pending, running, complete, failed] }
295
+ required: [export_id, status]
296
+
297
+ components:
298
+ schemas:
299
+ Widget:
300
+ type: object
301
+ properties:
302
+ id: { type: string }
303
+ workspace_id: { type: string }
304
+ name: { type: string }
305
+ description: { type: [string, "null"] }
306
+ status: { type: string, enum: [draft, active, archived] }
307
+ priority: { type: integer }
308
+ created_at: { type: string, format: date-time }
309
+ updated_at: { type: string, format: date-time }
310
+ deleted_at: { type: [string, "null"] }
311
+ required: [id, workspace_id, name, status, priority, created_at, updated_at]
@@ -0,0 +1,107 @@
1
+ /**
2
+ * A config against a live API that needs a multi-step login.
3
+ *
4
+ * This file is the entire coupling surface between oat and one specific backend: which route
5
+ * issues a credential, how a verification token that never travels over HTTP is collected, which
6
+ * header carries it, and which path parameters the credential itself identifies. Delete it and
7
+ * oat still runs against any other OpenAPI document.
8
+ *
9
+ * ANYROW_TESTER_KEY=... oat run --config labs/anyrow.config.ts
10
+ *
11
+ * Written as `from "../dist/index.js"` only because this file lives inside the repository —
12
+ * in your own project it is `from "@lovrozagar/oat"`.
13
+ */
14
+
15
+ import { defineConfig, type AuthFlow, type OutOfBandRequest } from "../dist/index.js"
16
+
17
+ const API = process.env.ANYROW_API ?? "https://api.anyrow.ai"
18
+ const TESTER_KEY = process.env.ANYROW_TESTER_KEY ?? ""
19
+ const PASSWORD = "OatSpec123!x"
20
+
21
+ /** A distinct address per principal per run, so tenants never collide between runs. */
22
+ function freshEmail(label: string): string {
23
+ return `oat-${label}-${Date.now()}-${Math.floor(Math.random() * 10_000)}@anyrow-spec.test`
24
+ }
25
+
26
+ /**
27
+ * Register, collect the emailed verification token, verify, keep the credential.
28
+ *
29
+ * Registration provisions an organisation and a project, so each principal arrives owning its
30
+ * own tenant — which is what makes the isolation checks meaningful without a single fixture
31
+ * identifier being configured by hand.
32
+ */
33
+ function signUp(label: string): AuthFlow {
34
+ const email = freshEmail(label)
35
+ return {
36
+ credentialFrom: "$.access_token",
37
+ expiresInFrom: "$.access_token_expires_in",
38
+ steps: [
39
+ /* Raw paths rather than operationIds: this API's published document contains no auth
40
+ * operations at all, so there is nothing in the spec to reference. `oat doctor`
41
+ * reports that as a gap — a document describing a protected API should describe how
42
+ * to authenticate to it. */
43
+ {
44
+ /* Binding the address is what lets teardown cascade the account away afterwards. */
45
+ bind: { address: email },
46
+ body: { email, name: `Oat ${label}`, password: PASSWORD },
47
+ method: "POST",
48
+ path: "/v1/auth/register/email",
49
+ },
50
+ { outOfBand: { address: email, as: "verifyToken", kind: "email-verify" } },
51
+ {
52
+ body: { token: "{verifyToken}" },
53
+ method: "POST",
54
+ path: "/v1/auth/email/verify",
55
+ saveAs: { credential: "$.access_token" },
56
+ /* The credential names the org and project registration just provisioned. */
57
+ saveClaimsFrom: {
58
+ bind: { orgId: "orgs.0.oid", projectId: "orgs.0.pids.0" },
59
+ token: "$.access_token",
60
+ },
61
+ },
62
+ ],
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Reads a token this deployment stashes for test traffic. Every backend does this differently —
68
+ * a mail catcher, a KV tap, a webhook sink — which is exactly why oat cannot do it for you.
69
+ * Returning null asks oat to retry: such stores are usually eventually consistent.
70
+ */
71
+ async function resolveOutOfBand({ address, kind }: OutOfBandRequest): Promise<string | null> {
72
+ const url = new URL(`${API}/v1/test/last-token`)
73
+ url.searchParams.set("email", address)
74
+ url.searchParams.set("type", kind)
75
+ const response = await fetch(url, { headers: { "x-ia-tester-key": TESTER_KEY } })
76
+ if (!response.ok) return null
77
+ const body = (await response.json()) as { token?: unknown }
78
+ return typeof body.token === "string" ? body.token : null
79
+ }
80
+
81
+ /** Cascade-deletes a principal this run provisioned, and everything it created with it. */
82
+ async function teardownPrincipal(address: string): Promise<void> {
83
+ const url = new URL(`${API}/v1/test/cleanup-user`)
84
+ url.searchParams.set("email", address)
85
+ await fetch(url, { headers: { "x-ia-tester-key": TESTER_KEY }, method: "DELETE" })
86
+ }
87
+
88
+ export default defineConfig({
89
+ baseUrl: API,
90
+ /* Sent on every request. Opaque to oat — it never inspects these. */
91
+ globalHeaders: { "x-ia-tester-key": TESTER_KEY },
92
+ hooks: { resolveOutOfBand, teardownPrincipal },
93
+ principals: [
94
+ {
95
+ auth: signUp("alpha"),
96
+ id: "alpha",
97
+ rootsFromFlow: { organization_id: "orgId", project_id: "projectId" },
98
+ },
99
+ {
100
+ auth: signUp("beta"),
101
+ id: "beta",
102
+ rootsFromFlow: { organization_id: "orgId", project_id: "projectId" },
103
+ },
104
+ ],
105
+ seed: 42,
106
+ spec: `${API}/v1/openapi/spec`,
107
+ })
package/labs/demo.mjs ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Self-contained demo — no credentials, no external service, nothing to configure.
3
+ *
4
+ * Boots the reference backend with a handful of deliberate defects, points oat at it, and writes
5
+ * a full report. This is the fastest way to see what oat actually produces before wiring it to
6
+ * anything of your own.
7
+ *
8
+ * node labs/demo.mjs # a few defects, see the report
9
+ * node labs/demo.mjs --clean # a correct backend — should find nothing
10
+ * node labs/demo.mjs --defects A,B # pick your own, names are in DEFECTS below
11
+ * node --experimental-sqlite labs/demo.mjs --backend sqlite
12
+ *
13
+ * The backend is a test fixture that ships with the repo, not part of the published package —
14
+ * oat itself never talks to a database, only to HTTP.
15
+ */
16
+
17
+ import { mkdir, writeFile } from "node:fs/promises"
18
+ import { dirname, resolve } from "node:path"
19
+ import { fileURLToPath } from "node:url"
20
+ import { DEFECTS } from "../dist/reference/defects.js"
21
+ import { createMemoryServer, createPostgresServer, createSqliteServer } from "../dist/reference/http.js"
22
+ import { renderConsole, renderJson, renderMarkdown, renderRepros } from "../dist/report/render.js"
23
+ import { run } from "../dist/runtime/run.js"
24
+
25
+ const here = dirname(fileURLToPath(import.meta.url))
26
+ const outDir = resolve(here, "../oat-out")
27
+
28
+ function flag(name) {
29
+ const index = process.argv.indexOf(`--${name}`)
30
+ if (index === -1) return undefined
31
+ const next = process.argv[index + 1]
32
+ return next !== undefined && !next.startsWith("--") ? next : true
33
+ }
34
+
35
+ /* A deliberately mixed bag: one of each severity, so the report shows how findings are ranked
36
+ * rather than a wall of one kind. */
37
+ const DEFAULT_DEFECTS = [
38
+ "STALE_LIST",
39
+ "PATCH_REPLACES",
40
+ "TENANT_LEAK_VIA_FILTER",
41
+ "CREATED_201_AS_200",
42
+ "SELECT_IGNORED",
43
+ ]
44
+
45
+ const defects =
46
+ flag("clean") === true
47
+ ? []
48
+ : typeof flag("defects") === "string"
49
+ ? flag("defects").split(",")
50
+ : DEFAULT_DEFECTS
51
+
52
+ for (const name of defects) {
53
+ if (!Object.hasOwn(DEFECTS, name)) {
54
+ console.error(`unknown defect "${name}". Available:\n ${Object.keys(DEFECTS).join("\n ")}`)
55
+ process.exit(2)
56
+ }
57
+ }
58
+
59
+ const backend = flag("backend") ?? "memory"
60
+ const createServer =
61
+ backend === "memory"
62
+ ? createMemoryServer
63
+ : backend === "sqlite"
64
+ ? createSqliteServer
65
+ : createPostgresServer
66
+
67
+ const server = await createServer({ defects })
68
+
69
+ /* Two principals in different tenants — the second is what makes the isolation checks possible. */
70
+ const principals = [
71
+ {
72
+ auth: {
73
+ credentialFrom: "$.access_token",
74
+ steps: [{ body: { key: "key_alpha" }, operationId: "auth.token" }],
75
+ },
76
+ id: "alpha",
77
+ roots: { project_id: "proj_alpha" },
78
+ },
79
+ {
80
+ auth: {
81
+ credentialFrom: "$.access_token",
82
+ steps: [{ body: { key: "key_beta" }, operationId: "auth.token" }],
83
+ },
84
+ id: "beta",
85
+ roots: { project_id: "proj_beta" },
86
+ },
87
+ ]
88
+
89
+ console.log(`\n backend ${backend}`)
90
+ console.log(` defects ${defects.length > 0 ? defects.join(", ") : "none — correct backend"}`)
91
+
92
+ const startedAt = new Date()
93
+ const began = performance.now()
94
+ const result = await run({
95
+ baseUrl: server.url,
96
+ principals,
97
+ seed: 42,
98
+ spec: `${server.url}/v1/openapi/spec`,
99
+ })
100
+ const durationMs = performance.now() - began
101
+
102
+ const input = {
103
+ baseUrl: server.url,
104
+ checksRun: result.checksRun,
105
+ client: result.client,
106
+ durationMs,
107
+ entitiesTested: result.entitiesTested,
108
+ findings: result.findings,
109
+ model: result.model,
110
+ startedAt,
111
+ }
112
+
113
+ await mkdir(resolve(outDir, "repro"), { recursive: true })
114
+ await writeFile(resolve(outDir, "oat-report.md"), renderMarkdown(input))
115
+ await writeFile(resolve(outDir, "oat-report.json"), renderJson(input))
116
+ for (const script of renderRepros(result.findings, server.url)) {
117
+ await writeFile(resolve(outDir, "repro", script.filename), script.content, { mode: 0o755 })
118
+ }
119
+
120
+ process.stdout.write(renderConsole(input))
121
+ console.log(` report ${resolve(outDir, "oat-report.md")}`)
122
+ console.log(` repro ${resolve(outDir, "repro")}/\n`)
123
+
124
+ await server.close()