@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.
- package/LICENSE +21 -0
- package/README.md +401 -0
- package/bin/oat.js +2 -0
- package/dist/cli.js +386 -0
- package/dist/config/define-config.d.ts +165 -0
- package/dist/config/define-config.d.ts.map +1 -0
- package/dist/config/define-config.js +24 -0
- package/dist/config/define-config.js.map +1 -0
- package/dist/config/load.d.ts +10 -0
- package/dist/config/load.d.ts.map +1 -0
- package/dist/config/load.js +61 -0
- package/dist/config/load.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +9 -0
- package/dist/reference/defects.d.ts +254 -0
- package/dist/reference/defects.d.ts.map +1 -0
- package/dist/reference/defects.js +265 -0
- package/dist/reference/defects.js.map +1 -0
- package/dist/reference/dialect.d.ts +104 -0
- package/dist/reference/dialect.d.ts.map +1 -0
- package/dist/reference/dialect.js +174 -0
- package/dist/reference/dialect.js.map +1 -0
- package/dist/reference/http.d.ts +79 -0
- package/dist/reference/http.d.ts.map +1 -0
- package/dist/reference/http.js +889 -0
- package/dist/reference/http.js.map +1 -0
- package/dist/reference/model.d.ts +56 -0
- package/dist/reference/model.d.ts.map +1 -0
- package/dist/reference/model.js +156 -0
- package/dist/reference/model.js.map +1 -0
- package/dist/reference/query.d.ts +45 -0
- package/dist/reference/query.d.ts.map +1 -0
- package/dist/reference/query.js +353 -0
- package/dist/reference/query.js.map +1 -0
- package/dist/reference/spec.d.ts +13 -0
- package/dist/reference/spec.d.ts.map +1 -0
- package/dist/reference/spec.js +547 -0
- package/dist/reference/spec.js.map +1 -0
- package/dist/reference/store-api.d.ts +59 -0
- package/dist/reference/store-api.d.ts.map +1 -0
- package/dist/reference/store-api.js +76 -0
- package/dist/reference/store-api.js.map +1 -0
- package/dist/reference/stores/memory.d.ts +27 -0
- package/dist/reference/stores/memory.d.ts.map +1 -0
- package/dist/reference/stores/memory.js +97 -0
- package/dist/reference/stores/memory.js.map +1 -0
- package/dist/reference/stores/postgres.d.ts +61 -0
- package/dist/reference/stores/postgres.d.ts.map +1 -0
- package/dist/reference/stores/postgres.js +406 -0
- package/dist/reference/stores/postgres.js.map +1 -0
- package/dist/reference/stores/sqlite-driver.d.ts +54 -0
- package/dist/reference/stores/sqlite-driver.d.ts.map +1 -0
- package/dist/reference/stores/sqlite-driver.js +99 -0
- package/dist/reference/stores/sqlite-driver.js.map +1 -0
- package/dist/reference/stores/sqlite.d.ts +68 -0
- package/dist/reference/stores/sqlite.d.ts.map +1 -0
- package/dist/reference/stores/sqlite.js +464 -0
- package/dist/reference/stores/sqlite.js.map +1 -0
- package/dist/report/console.d.ts +33 -0
- package/dist/report/console.d.ts.map +1 -0
- package/dist/report/console.js +237 -0
- package/dist/report/console.js.map +1 -0
- package/dist/report/matrix.d.ts +131 -0
- package/dist/report/matrix.d.ts.map +1 -0
- package/dist/report/matrix.js +865 -0
- package/dist/report/matrix.js.map +1 -0
- package/dist/report/render.d.ts +66 -0
- package/dist/report/render.d.ts.map +1 -0
- package/dist/report/render.js +423 -0
- package/dist/report/render.js.map +1 -0
- package/dist/runtime/async.d.ts +34 -0
- package/dist/runtime/async.d.ts.map +1 -0
- package/dist/runtime/async.js +130 -0
- package/dist/runtime/async.js.map +1 -0
- package/dist/runtime/auth.d.ts +46 -0
- package/dist/runtime/auth.d.ts.map +1 -0
- package/dist/runtime/auth.js +236 -0
- package/dist/runtime/auth.js.map +1 -0
- package/dist/runtime/checks.d.ts +89 -0
- package/dist/runtime/checks.d.ts.map +1 -0
- package/dist/runtime/checks.js +3400 -0
- package/dist/runtime/checks.js.map +1 -0
- package/dist/runtime/client.d.ts +65 -0
- package/dist/runtime/client.d.ts.map +1 -0
- package/dist/runtime/client.js +122 -0
- package/dist/runtime/client.js.map +1 -0
- package/dist/runtime/finding.d.ts +50 -0
- package/dist/runtime/finding.d.ts.map +1 -0
- package/dist/runtime/finding.js +50 -0
- package/dist/runtime/finding.js.map +1 -0
- package/dist/runtime/fixture.d.ts +20 -0
- package/dist/runtime/fixture.d.ts.map +1 -0
- package/dist/runtime/fixture.js +175 -0
- package/dist/runtime/fixture.js.map +1 -0
- package/dist/runtime/progress.d.ts +41 -0
- package/dist/runtime/progress.d.ts.map +1 -0
- package/dist/runtime/progress.js +186 -0
- package/dist/runtime/progress.js.map +1 -0
- package/dist/runtime/run.d.ts +69 -0
- package/dist/runtime/run.d.ts.map +1 -0
- package/dist/runtime/run.js +575 -0
- package/dist/runtime/run.js.map +1 -0
- package/dist/runtime/teardown.d.ts +38 -0
- package/dist/runtime/teardown.d.ts.map +1 -0
- package/dist/runtime/teardown.js +129 -0
- package/dist/runtime/teardown.js.map +1 -0
- package/dist/runtime/validate.d.ts +31 -0
- package/dist/runtime/validate.d.ts.map +1 -0
- package/dist/runtime/validate.js +111 -0
- package/dist/runtime/validate.js.map +1 -0
- package/dist/runtime/world.d.ts +49 -0
- package/dist/runtime/world.d.ts.map +1 -0
- package/dist/runtime/world.js +119 -0
- package/dist/runtime/world.js.map +1 -0
- package/dist/spec/collection.d.ts +31 -0
- package/dist/spec/collection.d.ts.map +1 -0
- package/dist/spec/collection.js +121 -0
- package/dist/spec/collection.js.map +1 -0
- package/dist/spec/conventions.d.ts +103 -0
- package/dist/spec/conventions.d.ts.map +1 -0
- package/dist/spec/conventions.js +275 -0
- package/dist/spec/conventions.js.map +1 -0
- package/dist/spec/extensions.d.ts +74 -0
- package/dist/spec/extensions.d.ts.map +1 -0
- package/dist/spec/extensions.js +335 -0
- package/dist/spec/extensions.js.map +1 -0
- package/dist/spec/graph.d.ts +89 -0
- package/dist/spec/graph.d.ts.map +1 -0
- package/dist/spec/graph.js +318 -0
- package/dist/spec/graph.js.map +1 -0
- package/dist/spec/load.d.ts +36 -0
- package/dist/spec/load.d.ts.map +1 -0
- package/dist/spec/load.js +166 -0
- package/dist/spec/load.js.map +1 -0
- package/dist/spec/types.d.ts +57 -0
- package/dist/spec/types.d.ts.map +1 -0
- package/dist/spec/types.js +24 -0
- package/dist/spec/types.js.map +1 -0
- package/labs/README.md +130 -0
- package/labs/annotated-openapi.yaml +311 -0
- package/labs/anyrow.config.ts +107 -0
- package/labs/demo.mjs +124 -0
- package/labs/local.config.ts +41 -0
- package/labs/minimal.config.ts +32 -0
- 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()
|