diff --git a/.env.example b/.env.example index 0864699..9f26d33 100644 --- a/.env.example +++ b/.env.example @@ -75,8 +75,8 @@ QUERY_WORKER_POOL_SIZE=2 QUERY_QUEUE_LIMIT=64 # Register the Schema Commons types (org.haverstack/note, bookmark, task, -# contact, article, place, page, photo) from @haverstack/commons on startup -# (default: false). Off by default because the package is Draft status and +# contact, article, place, page, image, post, site) from @haverstack/commons +# on startup (default: false). Off by default because the package is Draft status and # registering types is not free — they show up in GET /types and in every # app's type cache. See docs/deployment.md#schema-commons-seeding. SEED_COMMONS_TYPES=false diff --git a/docs/api.md b/docs/api.md index a0b1f0e..6cbd793 100644 --- a/docs/api.md +++ b/docs/api.md @@ -19,12 +19,17 @@ A query param or JSON body key an endpoint doesn't define is refused with `400` "entityId": "did:key:z6Mk...", "timezone": "America/New_York", "capabilities": { - "filter": { "content": "path", "contentPresent": true, "search": true }, - "sort": { "fields": ["createdAt", "updatedAt", "version"], "contentField": true }, + "filter": { "content": "path", "contentPresent": true, "contentByType": true, "search": true }, + "sort": { + "fields": ["createdAt", "updatedAt", "version"], + "contentField": true, + "contentFieldByType": true + }, "limits": { "attachmentBytes": 52428800, "contentBytes": 1048576 } }, "auth": { "methods": ["did-challenge"] }, - "changes": { "transports": ["sse"], "resume": true, "records": true } + "changes": { "transports": ["sse"], "resume": true, "records": true }, + "installs": { "requests": true } } ``` @@ -32,7 +37,9 @@ A query param or JSON body key an endpoint doesn't define is refused with `400` `changes` is a top-level field, not part of `capabilities` — a client checks it and fails locally at `open()` rather than discovering a missing feed as a 404 partway through a connection. `transports` lists what it speaks (`sse` is the only one this version defines); `resume` is `true` — `GET /changes` mints and honors resume cursors; `records` is `true` because `GET /changes` already honors `?include=record` unconditionally. Both `resume` and `records` false is also a fully conformant response for a server that doesn't implement either — see [Change feed](#change-feed) below. -`capabilities.filter.content` is `"path"`, spread through from the underlying sqlite adapter: a `content` filter key may be a dot-separated path (`"profile.email"`), not just a top-level field — see [Content filter keys](#content-filter-keys) below. `capabilities` and its shape are defined by `@haverstack/core`'s `AdapterCapabilities`; see core's `wire-format.md` § Discovery for the normative reach ladder and field-by-field meaning. +`installs: { requests: true }` is always present — this server always takes [install requests](#installs). + +`capabilities.filter.content` is `"path"`, spread through from the underlying sqlite adapter: a `content` filter key may be a dot-separated path (`"profile.email"`), not just a top-level field — see [Content filter keys](#content-filter-keys) below. `capabilities` and its shape are defined by `@haverstack/core`'s `StackCapabilities`; see core's `wire-format.md` § Discovery for the normative reach ladder and field-by-field meaning. ## Authentication @@ -76,62 +83,44 @@ The retryable column is why these carry a `code` at all rather than being bodyle ## Records -| Method | Path | Auth | Description | -| ------ | ---------------------------------- | ---------- | ----------------------------------------- | -| GET | `/records` | Optional | Query records via URL params | -| POST | `/records/query` | Optional | Query records with content filters | -| POST | `/records` | Required | Create a record | -| GET | `/records/:id` | Optional | Get a record by ID | -| PATCH | `/records/:id` | Required | Apply a change set to a record | -| DELETE | `/records/:id` | Required | Soft-delete (or purge with `?purge=true`) | -| POST | `/records/:id/undelete` | Required | Reverse a soft delete | -| GET | `/records/:id/permissions` | Optional | Get permissions | -| POST | `/records/:id/permissions` | Required | Grant one permission element | -| POST | `/records/:id/permissions/delete` | Required | Revoke one permission element | -| GET | `/records/:id/associations` | Optional | List associations | -| POST | `/records/:id/associations` | Required | Add an association | -| POST | `/records/:id/associations/delete` | Required | Remove an association | -| GET | `/records/:id/journal` | Optional | Read the change journal | -| GET | `/records/:id/versions` | Optional | List version history | -| GET | `/records/:id/versions/:version` | Optional | Get a specific version | -| POST | `/records/:id/restore/:version` | Required | Restore a previous version | -| POST | `/records/:id/migrate` | Owner only | Change a record's typeId | - -### The change set - -`PATCH /records/:id` takes a **change set**: an envelope naming any combination of a record's native aspects, applied as one atomic write that produces exactly one version. - -```json -{ - "contentPatch": { "title": "Q3 plan" }, - "parentId": "1hk153x0000f", - "permissions": [{ "kind": "anyone", "label": "read" }], - "associations": [{ "kind": "tag", "label": "planning" }], - "unlisted": false -} -``` +| Method | Path | Auth | Description | +| ------ | -------------------------------- | -------- | ----------------------------------------- | +| GET | `/records` | Optional | Query records via URL params | +| POST | `/records/query` | Optional | Query records with content filters | +| POST | `/records` | Required | Create a record | +| GET | `/records/:id` | Optional | Get a record by ID | +| PATCH | `/records/:id/content` | Required | Merge a content patch | +| PUT | `/records/:id/parent` | Required | Move into a container, or to the root | +| PUT | `/records/:id/unlisted` | Required | Withhold from enumeration, or relist | +| DELETE | `/records/:id` | Required | Soft-delete (or purge with `?purge=true`) | +| POST | `/records/:id/undelete` | Required | Reverse a soft delete | +| GET | `/records/:id/permissions` | Optional | Get permissions | +| POST | `/records/:id/permissions` | Required | Apply a list of permission edits | +| PUT | `/records/:id/permissions` | Required | Replace the permission set | +| GET | `/records/:id/associations` | Optional | List associations | +| POST | `/records/:id/associations` | Required | Apply a list of association edits | +| PUT | `/records/:id/associations` | Required | Replace the association set | +| GET | `/records/:id/journal` | Optional | Read the change journal | +| GET | `/records/:id/versions` | Optional | List version history | +| GET | `/records/:id/versions/:version` | Optional | Get a specific version | +| POST | `/records/:id/restore/:version` | Required | Restore a previous version | +| POST | `/records/:id/migrate` | Required | Change a record's typeId | -Keys are read for **presence, not truthiness** — `unlisted: false` and `parentId: null` name aspects and are applied, while an omitted key is left alone. A change set naming no key at all is `400`: it addresses nothing, so there is nothing it could have failed to satisfy. One already satisfied in every key writes nothing and answers with the record unchanged. +### One write endpoint per aspect -`contentPatch` is the one key that **merges**; every other key replaces the aspect it names. The merge is one level deep — a nested object in a patch replaces rather than merges — and a `null` field value removes the field. Because content is a key rather than the whole body, a `parentId` naming a container is unambiguously distinct from a content field of that name. +A record has five aspects — content, containment, listing, associations and permissions — and each has its own write endpoint, so no request can change more than its caller means to. Every one of them answers `200` with the record it produced, reads `If-Match` (see [Optimistic concurrency](#optimistic-concurrency)), and is refused with `409` on a soft-deleted record. Every body is parsed by core's parser for that endpoint (`@haverstack/core/wire`): a body that is not an object, carries a key the endpoint does not define, or lacks one it requires is `400`; a known field of the wrong type is `422`. -**Only `contentPatch` bumps `version`.** `parentId`, `permissions`, `associations` and `unlisted` are no-bump aspects: each one's inverse is the same shape as the forward operation, so none of them needs a snapshot to be recoverable from, and a change set naming only those leaves `version` and `updatedAt` exactly where they stand and writes no version row. The [journal](#journal) carries what each of them moved. An `If-Match` sent beside such a change set is therefore not checked — there is no bump for it to fence — while one sent beside a set that also names `contentPatch` fences the whole call as usual. +**Content.** `PATCH /records/:id/content` takes the bare patch — every key a content key — and merges it one level deep: an omitted field keeps its value, `null` removes one, and a nested object replaces rather than merges. A key the type does not declare is `422`, including one named like a native field (`parentId`, `typeId`), which is a content field of that name. It is the one aspect write that bumps `version` and snapshots. A patch the content already satisfies answers `200` having written nothing. -Authority resolves **per key, against the record as it stands**: `contentPatch` and `associations` are reachable by a write-holder or an `update-*` grantee; `parentId` needs that same write access _and_ a destination the requester can read; `permissions` and `unlisted` need the owner or the record's own creator. A requester who may perform every key may perform the set; a requester who may not perform one of them may not perform any of it. Every gate reads the record's pre-change state, so a widened `permissions` in a change set never satisfies the read check on a `parentId` named in the same call. A refused key refuses the whole call — nothing is partially applied and no key is silently dropped. +**Containment.** `PUT /records/:id/parent` takes `{ "parentId": "" | null }` — a container, or `null` for the root. It needs write on the record and read on the destination. A malformed `parentId` (the empty string included) is `400`; one naming a record that does not exist, or a move that would make the record its own ancestor, is `409`. **Which of those a requester sees depends on who they are**: for anyone but the owner, a destination they cannot read and one that is not there are a single indistinguishable `403`, which is what stops the gate being an existence oracle. -An unrecognized top-level key is `400`; a key whose value is the wrong shape is `422`, as is a key the type does not declare inside `contentPatch`. A `parentId` a caller names must be well-formed (`400` otherwise — the empty string is not a spelling of the root, `null` is) and must name a record that exists (`409` otherwise). +**Listing.** `PUT /records/:id/unlisted` takes `{ "unlisted": true | false }` — see [Unlisted](#unlisted). -**Which of those two a requester sees depends on who they are.** Both answers are the owner's: for anyone else, a destination they cannot read and one that is not there are a single indistinguishable `403`, which is what stops the gate being an existence oracle. So a non-owner write-holder naming a missing container gets `403`, not `409` — they learn they may not name it, never whether it exists. +**Associations and permissions** each have two spellings: `POST` takes `{ "changes": [...] }`, a list of `{ "op": "add" | "remove", "association": }` applied as one write, and `PUT` takes the whole set (`{ "associations": [...] }` or `{ "permissions": [...] }`) and replaces it. The edit spelling is what survives two clients editing one set at once; the set spelling states the whole of it. `repoint` is something the [journal](#journal) records, never something a caller sends, so it is `400`; an `add` naming an attachment the record already holds re-points it in place. See [Permissions](#permissions). -`associate()` / `dissociate()` keep their own endpoints: they amend the association set where the `associations` key replaces it, so two clients tagging one record both succeed through `POST .../associations` and race through the key. +Only content bumps `version`. Containment, listing, associations and permissions each have an inverse of the same shape as the forward write, so none needs a snapshot to be recoverable: they leave `version` and `updatedAt` where they stand and write no version row. The [journal](#journal) carries what each of them moved. -Version history and the [journal](#journal) require the same access `PATCH`/`DELETE` require — a write-holder, or the owner — not plain read. A read-only requester gets `403`. - -**A snapshot carries content and the `typeId` it was read under, and nothing else.** No version has ever captured `parentId`, `unlistedAt`, `permissions` or `associations`, since none of those bumps a version, so `POST /records/:id/restore/:version` settles content and `typeId` alone: the record comes back in whatever container it is in now, listed however it is listed now, reachable by whoever reaches it now. Undoing a move is a change set's `parentId` — the journal entry for the move is what names where it came from. - -`POST /records/:id/migrate` is the only way a record's `typeId` changes after creation, and is owner-acting-alone only — a non-owner gets `403` regardless of any write grant or record-level permission they hold. Body is `{ toTypeId, content }`: `content` is the full post-migration content, computed client-side by the type's owning app; the server validates it against `toTypeId`'s schema before writing and leaves a pre-migration snapshot in version history. Accepts the same `If-Match` header as `PATCH`. - -Removing an association is `POST /records/:id/associations/delete`, not a body-bearing `DELETE` — a `DELETE` request body has no defined semantics (RFC 9110 §9.3.5) and is a portability landmine for proxies/gateways that drop or reject it. +**`permissions` and `associations` are separate aspects over one store.** A permission or `anyone` element sent to an association endpoint, or a data element sent to a permission endpoint, is `400` with code `bad_request`: the refusal says the caller named the wrong surface rather than sent a malformed value. Each endpoint replaces or edits only within its own aspect, so an app editing tags is never handed the ACL and cannot drop it. A `relationship` association's `target` is a discriminated union naming which identifier space the value belongs to — a Record (in this Stack or another), an identity, or something outside the Stack entirely: @@ -141,21 +130,33 @@ A `relationship` association's `target` is a discriminated union naming which id { "kind": "relationship", "label": "syndicated-to", "target": { "kind": "external", "ns": "atproto", "id": "at://..." } } ``` -A change set's `permissions` key replaces every permission on the record and answers `200` with the updated record. An empty array makes the record private (owner-only). `GET /records/:id/permissions` reads them back under the `{ "permissions": [...] }` envelope, and `POST /records/:id/permissions[/delete]` amend the set one element at a time — see [Permissions](#permissions). +`GET /records/:id/associations` takes `?kind=` and `?label=`; an unrecognized `kind` is `400` rather than read as absent, which would answer with every kind. `GET /records/:id/associations` and `GET /records/:id/permissions` read the record as `GET /records/:id` does without `includeDeleted`, so a soft-deleted record answers `404` on both. Its tombstone, permissions included, is `GET /records/:id?includeDeleted=true`. + +### History, restore and migrate + +Version history and the [journal](#journal) require the same access a content write requires — a write-holder, or the owner — not plain read. A read-only requester gets `403`. -**`permissions` and `associations` are separate domains over one store.** A permission element named in the `associations` key — or an `association` element in `permissions`, or either through the wrong pair of endpoints — is `400` with code `bad_request`: the refusal says the caller named the wrong surface rather than sent a malformed value. Each key replaces only within its own domain, so an app editing tags is never handed the ACL and cannot drop it. +`GET /records/:id/versions` answers `{ "versions": [...], "cursor": ... }`, newest first, and takes `?limit=` and `?beforeVersion=` (exclusive). `cursor` is the `version` to send back as `beforeVersion`, and `null` once nothing older follows. This server caps a page at 500 versions; omitting `limit` reads the whole history by contract, so no default page size is supplied on the caller's behalf — the cap is announced through `cursor` instead, exactly as on the [journal](#journal). A `limit` of `0` is `400`. + +**A snapshot carries content and the `typeId` it was read under, and nothing else.** No version has ever captured `parentId`, `unlistedAt`, `permissions` or `associations`, since none of those bumps a version, so `POST /records/:id/restore/:version` settles content and `typeId` alone: the record comes back in whatever container it is in now, listed however it is listed now, reachable by whoever reaches it now. Undoing a move is a `PUT .../parent` — the journal entry for the move is what names where it came from. + +`POST /records/:id/migrate` is the only way a record's `typeId` changes after creation. Body is `{ toTypeId, content }`: `content` is the full post-migration content, computed client-side by the type's owning app; the server validates it against `toTypeId`'s schema before writing and leaves a pre-migration snapshot in version history. It is served to the stack owner acting alone, and to an [installed app](#installs) migrating within the families its install claims; anyone else gets `403` whatever grant or record-level permission they hold. An undefined `toTypeId` is `400`: there is no schema for the content to fail against. + +### Create, undelete and optimistic concurrency `POST /records/:id/undelete` reverses a soft delete and returns the record as it now stands (`deletedAt` absent). Idempotent — a second call on an already-active record returns the same result. -`POST /records` returns `200`, not `201` — the response is the created record, same shape as every other write. The body is the full record: `id` is client-minted (12 lowercase Crockford base-32 characters, no reserved `_` prefix — omit it to let the server generate one) and, when supplied, must encode a creation timestamp within the server's clock-skew tolerance; `version` is never accepted from the client — that, like `createdBy`/`updatedBy`, is always server-assigned. A duplicate `id` returns `409` with code `conflict`. +`POST /records` returns `200`, not `201` — the response is the created record, same shape as every other write. The body is the full record: `id` is client-minted (12 lowercase Crockford base-32 characters, no reserved `_` prefix — omit it to let the server generate one) and, when supplied, must encode a creation timestamp within the server's clock-skew tolerance; `version` is never accepted from the client — that, like `createdBy`/`updatedBy`, is always server-assigned. A duplicate `id` returns `409` with code `conflict`. A key no wire record carries is `400`. `createdAt`/`updatedAt` are honored only from the stack owner acting alone (undelegated, authenticated as the owner) — the same tier that gates purge, `commitMigration()`, and `includeUnlisted`. From anyone else, both are dropped rather than forwarded: forwarding them would otherwise turn an ordinary grantee's create into a `403`, since a non-owner backdating attempt is refused outright rather than silently ignored. For an owner-acting-alone create: `updatedAt` defaults to `createdAt` (never to now), so a plain import doesn't fabricate an edit; `updatedAt` earlier than `createdAt` is a `422` validation error, including when `createdAt` itself defaulted to now; omitting `id` derives it from `createdAt` instead of the current time; supplying both `id` and `createdAt` checks them against each other under the same clock-skew tolerance. An owner's plain `id`-only create (no `createdAt`) is unaffected — it still gets the ordinary id-vs-now check. A malformed `createdAt`/`updatedAt` value returns `422` with code `validation`. -`If-Match: ""` is accepted for optimistic concurrency on every endpoint that bumps a record's version — `PATCH /records/:id`, `DELETE /records/:id`, `POST /records/:id/undelete`, `POST /records/:id/restore/:version`, and `POST /records/:id/migrate`. One `If-Match` fences a whole multi-aspect change set. The association and permission endpoints read no `If-Match`, and one sent to any of them is ignored rather than refused: a set add or remove composes whatever the write order, so there is no race for a precondition to guard. A mismatch returns `412` with code `version_conflict` and a `versionConflict: { recordId, expectedVersion, actualVersion }` payload; omitting the header keeps last-writer-wins. A malformed value (not a bare, optionally quoted version — a weak comparator like `W/"5"` included) is `400`, not read as absent: a header sent to fence a write that silently degrades to unconditional last-writer-wins defeats the only thing it was sent to do. +#### Optimistic concurrency + +**Every write endpoint reads `If-Match: ""` and always checks it** — the aspect writes above, `DELETE /records/:id`, `POST .../undelete`, `POST .../restore/:version`, `POST .../migrate` and `PATCH /entity/content`. That includes the writes that never move `version`: a precondition is a claim about the record as last read, not about what the write moves, which is what lets a publish widen access only over the content it reviewed. A mismatch returns `412` with code `version_conflict` and a `versionConflict: { recordId, expectedVersion, actualVersion }` payload, and changes nothing; omitting the header keeps last-writer-wins. A malformed value (not a bare, optionally quoted version — a weak comparator like `W/"5"` included) is `400`, not read as absent: a header sent to fence a write that silently degrades to unconditional last-writer-wins defeats the only thing it was sent to do. ### Query parameters -`GET /records` accepts, among others: `typeId`, `parentId`, `appId`, `createdBySubject`, `createdByPrincipal`, `tag`, `attachmentLabel`, `attachmentFileId`, `referencesFileId`, `relatedTo` (+ `relatedToStack`, `relatedToLabel`), `relatedToEntity` (+ `relatedToLabel`), `relatedToNs` (+ `relatedToId`, `relatedToLabel`), `search`, `createdBefore`/`createdAfter`, `updatedBefore`/`updatedAfter`, `includeDeleted`, `includeUnlisted`, `sort`/`sortContent`/`direction`, `limit`, `cursor`. `createdBySubject` filters by the record's author; `createdByPrincipal` by the principal behind that create, if delegated (see [Permissions](#permissions) below). Both are repeatable. `attachmentLabel` and `attachmentFileId` together match a single attachment association (either is valid alone); `referencesFileId` matches any record referencing the file, through an attachment association under any label or a top-level `file-ref` field. `includeUnlisted` is owner-only — see [Unlisted](#unlisted) below. `POST /records/query` accepts the same filters as a JSON body, plus `content` (field-equality) and `contentPresent` (field-presence) filters. Omitting `limit` returns one default-sized page (50 records), never the whole result set — `cursor` is the only end-of-results signal; a `limit` above this server's own ceiling (1000) is clamped down to it rather than rejected. +`GET /records` accepts, among others: `typeId`, `parentId`, `appId`, `createdBySubject`, `createdByPrincipal`, `tag`, `attachmentLabel`, `attachmentFileId`, `referencesFileId`, `relatedTo` (+ `relatedToStack`, `relatedToLabel`), `relatedToEntity` (+ `relatedToLabel`), `relatedToNs` (+ `relatedToId`, `relatedToLabel`), `search`, `createdBefore`/`createdAfter`, `updatedBefore`/`updatedAfter`, `includeDeleted`, `includeUnlisted`, `sort`/`sortContent`/`direction`, `limit`, `cursor`. `createdBySubject` filters by the record's author; `createdByPrincipal` by the principal behind that create, if delegated (see [Permissions](#permissions) below). Both are repeatable. `attachmentLabel` and `attachmentFileId` together match a single attachment association (either is valid alone); `referencesFileId` matches any record referencing the file, through an attachment association under any label or a top-level `file-ref` field. `baseId` (repeatable) matches every version of a type family. `includeUnlisted` is owner-only — see [Unlisted](#unlisted) below. `POST /records/query` accepts the same filters as a JSON body, plus `content` (field-equality), `contentPresent` (field-presence) and `contentByType` (a content predicate per `typeId`) filters, and `sort.contentFieldByType`. A query with no sort is `createdAt`, newest first; a named sort with no `direction` is ascending. Omitting `limit` returns one page of 50 records, never the whole result set — `cursor` is the only end-of-results signal — and a `limit` above 1000 is read as 1000. All three defaults are `Stack`'s, so this server applies none of its own. Parsing and encoding these parameters is `@haverstack/core/wire`'s job, not this server's — `parseQueryParams`, `parseQueryBody`, and `parseChangeParams` there are the exact inverse of what `@haverstack/adapter-api` builds, and are the normative reference for every rule below. `docs/spec/wire-format.md` § Query parameters in `haverstack/core` documents the full parameter set and its edge cases; what follows here is a summary, kept for this server's own reference. @@ -180,9 +181,9 @@ A relationship association's target names one of three scopes — a Record, an i ### Soft delete and tombstones -`DELETE /records/:id` without `?purge=true` leaves a **tombstone**: the record continues to exist and remains reachable by `id`, but its `content` is projected as `{}` and `deletedAt` is set. This applies uniformly everywhere the record is served with content attached — `GET /records/:id`, `GET`/`POST /records/query` with `filter.includeDeleted: true`, and a `deleted`-kind change feed frame with `?include=record` — so a client sees the same emptied shape regardless of which route it came through. `GET /records/:id/versions` and `GET /records/:id/versions/:version` are the deliberate exception: version history is never tombstoned and continues to serve full content. +`DELETE /records/:id` without `?purge=true` leaves a **tombstone**: the record continues to exist, but its `content` is projected as `{}` and `deletedAt` is set. `GET /records/:id` hides it unless called with `?includeDeleted=true`, as `GET /records` does — without the parameter a soft-deleted record answers `404`. The projection applies uniformly everywhere the record is served — `GET /records/:id?includeDeleted=true`, `GET`/`POST /records/query` with `includeDeleted`, the body a soft `DELETE` answers with, and a `removed`-kind change feed frame with `?include=record` — so a client sees the same emptied shape regardless of which route it came through. `GET /records/:id/versions` and `GET /records/:id/versions/:version` are the deliberate exception: version history is never tombstoned and continues to serve full content. -A soft-deleted record refuses further mutation: `PATCH`, `POST .../associations`, `POST .../associations/delete`, `POST .../migrate`, and `POST .../restore/:version` all return `409` with code `conflict` until `POST /records/:id/undelete` reverses the delete. `GET` and version-history reads are unaffected — the refusal applies only to mutation. +A soft-deleted record refuses further mutation: every aspect write and `POST .../restore/:version` return `409` with code `conflict` until `POST /records/:id/undelete` reverses the delete. `POST .../migrate` is exempt. The refusal comes after the read check, so a requester who cannot read the record still gets `404`. Version-history reads are unaffected. **A purge answers `200` with the record it destroyed**, as it last stood, and `404` for a record that was not there. It is the one response that is not the record a write produced, because this write produces none: the attachment associations and `file-ref` fields in that body are the files the purge stranded, and the purge has just destroyed every other row naming them, so a client answered an empty body could not name the bytes it may now need to erase. A `purged` change-feed frame still carries nothing — that fans out to every subscriber, while this body goes only to the owner who authorized the purge. @@ -247,18 +248,18 @@ A record the server does not have — never created, or purged — is `404`, nev `unlistedAt` is orthogonal to `permissions`: it says nothing about who may read a record, only whether it is enumerable. A record with `unlistedAt` set is still reachable by `GET /records/:id` for anyone who may already read it — unlisted withholds a record from enumeration, never from access. ``` -PATCH /records/:id body: { "unlisted": boolean } +PUT /records/:id/unlisted body: { "unlisted": boolean } ``` -Answers `200` with the updated record, carrying `unlistedAt` when `true` and absent when `false`. A listing transition is its own inverse, so it bumps no `version` and takes no snapshot — the [journal](#journal) is where it is recorded — and an `If-Match` beside a change set naming only this key is not checked. Returns `409` on a soft-deleted record like the other mutating endpoints above. Gated exactly like the `permissions` key: owner or creator, asked of both identities under delegation. A non-boolean value is `422`, the shape error every change-set key answers with. +Answers `200` with the updated record, carrying `unlistedAt` when `true` and absent when `false`. A listing transition is its own inverse, so it bumps no `version` and takes no snapshot — the [journal](#journal) is where it is recorded. Gated exactly like the permission endpoints: owner or creator, asked of both identities under delegation. A non-boolean value is `422`. **`includeUnlisted` is owner-only.** `GET /records`, `POST /records/query`, and `GET /changes` all accept it (excluded by default, like `includeDeleted`), and all three refuse it with `403` for any requester but the owner acting alone — enumeration standing rests on nothing but ownership, so no grant or delegation carries it. On `GET /changes` the refusal happens before the SSE stream opens. -A `POST /records` body may carry `unlistedAt` to create the record already unlisted, so there is no window where it exists and is enumerable before a later change set catches up — presence of the field is what matters; its value is not honored (`unlistedAt` on the response is stamped to the real create time, like `version`). This is **not** owner-only, unlike `createdAt`/`updatedAt`: it's gated the same way the `permissions` field is at create time — refused with `403` for a delegated app acting for anyone but the owner, but available to any other creator holding a create grant for the type. +A `POST /records` body may carry `unlistedAt` to create the record already unlisted, so there is no window where it exists and is enumerable before a later write catches up — presence of the field is what matters; its value is not honored (`unlistedAt` on the response is stamped to the real create time, like `version`). This is **not** owner-only, unlike `createdAt`/`updatedAt`: it's gated the same way the `permissions` field is at create time — refused with `403` for a delegated app acting for anyone but the owner, but available to any other creator holding a create grant for the type. ### The feed transitions -`unlist` and `list` are new change-feed ops (`unlist` maps to `kind: "deleted"`, `list` to `kind: "changed"` — see [Change feed](#change-feed)). Only the `unlist` transition needs special-casing; every other row falls out of checking the record's current `unlistedAt` against the subscriber's `includeUnlisted`: +`unlist` and `list` are change-feed ops (`unlist` maps to `kind: "removed"`, `list` to `kind: "changed"` — see [Change feed](#change-feed)). Only the `unlist` transition needs special-casing; every other row falls out of checking the record's current `unlistedAt` against the subscriber's `includeUnlisted`: | Transition | Emits to a default subscriber? | | ---------------------------- | :----------------------------: | @@ -280,11 +281,11 @@ This server mints resume cursors: `ready` carries the current head as `cursor`, Resume is per-connection state, not global: each distinct (session, filter) pairing gets its own buffer, retained for a bounded time and depth past the last connection using it — long enough for an ordinary reconnect, not indefinitely. A gap resumption can't close is exactly what `reset` exists for; a client's repair is the same either way — reconcile by querying `GET /records` / `POST /records/query`. A `purged` frame in a replayed backlog is never re-checked against current permissions (there is no record left to check, and the mutation-time decision is the only one that will ever exist); every other replayed frame is. -Query parameters, all optional and composable: `typeId` (repeatable, exact match as on `GET /records`), `baseId` (repeatable, the whole type family — so a type-version bump never orphans a subscription), `parentId` (`"null"` selects root records, same as `GET /records`), `createdBySubject` / `createdByPrincipal` (repeatable; the record's author, not who made the change), `kind` (repeatable: `created`, `changed`, `deleted`, `purged`), `include=record` (attach the record as of the change; ignored for `kind=purged`), `includeUnlisted` (owner-only — see [Unlisted](#unlisted)). Filtering is exact, not advisory — a filtered connection never receives a frame it did not match, and an unrecognized `kind` or `include` value is a `400`. A `parentId` filter is the one place where matching and frame contents come apart, and a subscriber that reads the two as the same thing will misroute a move: +Query parameters, all optional and composable: `typeId` (repeatable, exact match as on `GET /records`), `baseId` (repeatable, the whole type family — so a type-version bump never orphans a subscription), `parentId` (`"null"` selects root records, same as `GET /records`), `createdBySubject` / `createdByPrincipal` (repeatable; the record's author, not who made the change), `kind` (repeatable: `created`, `changed`, `removed`, `purged`), `include=record` (attach the record as of the change; ignored for `kind=purged`), `includeUnlisted` (owner-only — see [Unlisted](#unlisted)). Filtering is exact, not advisory — a filtered connection never receives a frame it did not match, and an unrecognized `kind` or `include` value is a `400`. A `parentId` filter is the one place where matching and frame contents come apart, and a subscriber that reads the two as the same thing will misroute a move: -A `reparent` is matched against **both** containers the move concerns, so a subscription filtered on the origin learns the record left it — the record's post-change state alone would answer only for the destination. The frame carries the destination in `parentId`, as every frame carries the record's state at the moment of the change, so a subscriber compares it against its own filter to tell a departure from an arrival. Its `kind` is `changed`, not `deleted`: the record is still there and still readable, only its container moved. +A `reparent` is matched against **both** containers the move concerns, so a subscription filtered on the origin learns the record left it — the record's post-change state alone would answer only for the destination. The frame carries the destination in `parentId`, as every frame carries the record's state at the moment of the change, so a subscriber compares it against its own filter to tell a departure from an arrival. Its `kind` is `changed`, not `removed`: the record is still there and still readable, only its container moved. -Every change (`event: record`) carries `kind`, `ops`, `recordId`, `typeId`, `version`, `updatedAt`, and — when known — `actor` (who made the change; never the record's own author, which is what `createdBySubject` filters on). `kind` is the coarse branch a handler can be complete on; `ops` names every aspect the write actually moved (`create`, `patch`, `associate`, `dissociate`, `reshare`, `reparent`, `migrate`, `restore`, `delete`, `undelete`, `purge`, `unlist`, `list`) for a consumer that distinguishes, say, a reshare from an edit. It is derived by diffing the record against its prior state rather than read off the request, so a change set naming an aspect without moving it is never reported as moving it; it is never empty, and is multi-entry only for a change set, since every other op names a whole-record transition and is emitted alone. Most ops bump no `version` — only `patch` and the whole-record transitions do — so a frame for one of the rest carries whatever `version`/`updatedAt` the record already held. `kind` resolves to the most conservative entry — a change set carrying `unlist` is `deleted` whatever else it carries, because a subscriber holding the record still has to drop it. A frame whose `ops` include `associate` or `dissociate` carries `associationsAdded` / `associationsRemoved`, reporting what the call moved: an added association as it now stands, annotation included, and a removed one by identity only, since its annotation no longer describes anything current. Both report only what is true now — the [journal](#journal) is where an overwritten or removed annotation survives. +Every change (`event: record`) carries `kind`, `ops`, `recordId`, `typeId`, `version`, `updatedAt`, and — when known — `actor` (who made the change; never the record's own author, which is what `createdBySubject` filters on). `kind` is the coarse branch a handler can be complete on; `ops` names every aspect the write actually moved (`create`, `patch`, `associate`, `dissociate`, `reshare`, `reparent`, `migrate`, `restore`, `delete`, `undelete`, `purge`, `unlist`, `list`) for a consumer that distinguishes, say, a reshare from an edit. It is derived by diffing the record against its prior state rather than read off the request, so a write naming an aspect without moving it is never reported as moving it; it is never empty. Most ops bump no `version` — only `patch` and the whole-record transitions do — so a frame for one of the rest carries whatever `version`/`updatedAt` the record already held. `kind` resolves to the most conservative entry — anything carrying `unlist` is `removed`, because a subscriber holding the record still has to drop it. A frame whose `ops` include `associate` or `dissociate` carries `associationsAdded` / `associationsRemoved`, reporting what the call moved: an added association as it now stands, annotation included, and a removed one by identity only, since its annotation no longer describes anything current. Both report only what is true now — the [journal](#journal) is where an overwritten or removed annotation survives. A `purged` frame — from a purge — carries none of `record`, `parentId`, or the record's own author, whatever the connection asked for: purge is the erasure primitive, and a frame naming what was destroyed is deliberately all that survives it. @@ -302,6 +303,8 @@ This server is single-process: `GET /changes` reports only writes made through t | GET | `/types/:id` | None | Get a type by ID | | POST | `/types` | Owner only | Register a new type | +`POST /types` takes a whole Type, as `GET /types/:id` returns one: `id`, `name`, `schema` and an optional `migratesFrom` are read, and `baseId`, `version`, `schemaHash` and `createdAt` are accepted and ignored, since `defineType()` derives or stamps each. Redefining an existing `id` with a non-additive schema is `409` with code `schema_drift`; a malformed schema is `422`. + ## Attachments | Method | Path | Auth | Description | @@ -317,17 +320,33 @@ This server is single-process: `GET /changes` reports only writes made through t `POST /attachments/gc` sweeps for attachment bytes unreachable from any record — live or soft-deleted — and deletes both the bytes and their `_attachment@1` metadata. Body is `{ graceMs?, dryRun? }`, both optional: `graceMs` is how recently-uploaded an unreferenced file must be to survive collection (default 24 hours, covering the upload-then-associate window; `0` collects immediately), `dryRun` computes the result without deleting anything. Returns `{ deletedFileIds: [fileId...], reclaimedBytes }`. No built-in scheduling — invoke it directly, or drive it from an external cron; `dryRun` makes a probe-first workflow safe. -An `attachment` association (`{ kind: "attachment", label, fileId }`, set via `POST /records/:id/associations`) may carry an optional `attachmentRecordId`, naming the `_attachment` record whose upload established that particular reference — useful when several records share one `fileId` but each wants its own uploader's filename. The pointer annotates the reference rather than identifying it: association identity stays `(kind, label, fileId)`, so `dissociate()` matches without it, and re-associating with a different (or absent) `attachmentRecordId` re-points the existing association in place rather than adding a second one. `GET /attachments/:fileId` itself never resolves this pointer — a plain download names a fileId, not a reference — so a client holding the association resolves the filename itself (via `resolveReferencedAttachment()` from `@haverstack/core/wire`) and passes it as `?filename`. +An `attachment` association (`{ kind: "attachment", label, fileId }`, added through `POST /records/:id/associations`) may carry an optional `attachmentRecordId`, naming the `_attachment` record whose upload established that particular reference — useful when several records share one `fileId` but each wants its own uploader's filename. The pointer annotates the reference rather than identifying it: association identity stays `(kind, label, fileId)`, so a `remove` edit matches without it, and re-associating with a different (or absent) `attachmentRecordId` re-points the existing association in place rather than adding a second one. `GET /attachments/:fileId` itself never resolves this pointer — a plain download names a fileId, not a reference — so a client holding the association resolves the filename itself (via `resolveReferencedAttachment()` from `@haverstack/core/wire`) and passes it as `?filename`. + +## Installs + +| Method | Path | Auth | Description | +| ------ | -------------------------------- | ---------- | ---------------------------------------- | +| POST | `/installs` | Required | Ask the owner to install an app manifest | +| GET | `/installs/requests` | Owner only | List install requests awaiting approval | +| GET | `/installs/requests/:id` | Owner only | Review what approving a request changes | +| POST | `/installs/requests/:id/approve` | Owner only | Install the request's manifest | +| DELETE | `/installs/requests/:id` | Owner only | Decline a request | + +An install has two halves. **The app's half** is `POST /installs`, pinned by core's wire contract: a key presents `{ "manifest": { appId, name, version?, types, requests } }` and answers `202 { "status": "pending" }` until the owner approves it for that key, then `200 { "status": "installed", "install": <_install record> }` once applying it would change nothing. Re-sending the same manifest is how an app checks; a changed manifest — an upgrade — is pending again. The key being installed is the session's principal, never named in the body, and a delegated session is `403`. A manifest defining a type outside the app's own namespace and the commons is `422`; a key whose `_app` card names a different `appId` is `409`. + +**The owner's half** is this server's own: `/installs/requests`. A request is not an approval, so it lives beside the stack file rather than in it (`.installs`) and writes nothing into the owner's data. Each key holds at most one request; a request keeps its `id` while the key re-sends the same manifest and gets a new one when the manifest changes, so the id the owner reviewed always names what they reviewed. `GET /installs/requests/:id` returns the request with its current `plan` — the families it would claim, the type versions and grants it would add or remove, requests on families other apps own, and whether the key is new — computed afresh on each read, since it depends on the stack as it stands. Approving plans the stored manifest again and applies it (`Stack.planInstall()` then `installApp()`), answering `200` with `{ "status": "installed", "install": <_install record> }`, the body `POST /installs` answers the key with once the plan is empty. Declining drops the request (`204`). Approving or declining acts only on the request its id names: once the key sends a different manifest, the old id answers `404`. Manifests are compared as canonical JSON, so re-sending the same one with its keys in another order keeps the request's id. A request no one re-sends for 7 days is dropped. ## Entity & Tokens -| Method | Path | Auth | Description | -| ------ | ------------- | ---------- | ------------------------------ | -| GET | `/entity` | Required | Get the owner entity record | -| PATCH | `/entity` | Owner only | Update the owner entity record | -| GET | `/tokens` | Owner only | List API tokens | -| POST | `/tokens` | Owner only | Create an API token | -| DELETE | `/tokens/:id` | Owner only | Revoke an API token | +| Method | Path | Auth | Description | +| ------ | ----------------- | ---------- | ------------------------------------- | +| GET | `/entity` | Required | Get the owner entity record | +| PATCH | `/entity/content` | Owner only | Merge a content patch into the record | +| GET | `/tokens` | Owner only | List API tokens | +| POST | `/tokens` | Owner only | Create an API token | +| DELETE | `/tokens/:id` | Owner only | Revoke an API token | + +`PATCH /entity/content` is an alias for `PATCH /records/:id/content` on the owner's entity record: the same bare patch, the same `If-Match`. `POST /tokens` accepts `{ principalId?, subjectId?, label?, expiresAt? }` — the same `Actor` names the response and `GET /tokens` report. `principalId` is the token's principal (who authenticates), defaulting to the owner; `subjectId` optionally asserts a delegation — the subject the principal acts for. Both, when given, must be DIDs (`422` otherwise). The response always reports both: `{ id, token, principalId, subjectId, label, createdAt, expiresAt }`. Omitting `subjectId` issues an undelegated token, where `subjectId` equals `principalId`. @@ -344,12 +363,12 @@ Records are private by default (readable only by the stack owner). A record's `p A group grantee always names a `role` — `member` is the wider set, `admin` the narrower, matching the roster labels where admin implies member. There is no absent-role spelling to fall back on. `anyone` spells reach to the world affirmatively and carries `read` or nothing, so no dropped field on a `permission` element can produce it. ``` -GET /records/:id/permissions — read the set -POST /records/:id/permissions — grant one element -POST /records/:id/permissions/delete — revoke one element (by body) +GET /records/:id/permissions — read the set +POST /records/:id/permissions — { "changes": [{ "op": "add" | "remove", "association": ... }] } +PUT /records/:id/permissions — { "permissions": [...] }; [] makes the record private ``` -The two `POST`s each take one element as their body, **amend** the set, and answer `200` with the updated record — where a change set's `permissions` key replaces the set wholesale. Amending is what survives two admins sharing one record at once. They carry the **reshare gate** rather than the write bit: a write-holder who is neither owner nor creator gets `403`, because a write-holder who could grant would escalate to deciding who else reaches the record. Permission elements are associations, so neither endpoint bumps `version` or reads `If-Match`. +`POST` edits the set as one write — so granting `read` and `write` together never passes through a state holding only the `write` — and `PUT` replaces it. Editing is what survives two admins sharing one record at once. Both carry the **reshare gate** rather than the write bit: a write-holder who is neither owner nor creator gets `403`, because a write-holder who could grant would escalate to deciding who else reaches the record. Permission elements are associations, so neither endpoint bumps `version`; both check `If-Match` as every write does. **Write implies read**, as a cross-element invariant over the set a write would produce. A body producing a set where some grantee holds `write` with no `read` beside it is `422` with code `validation` — here and anywhere else a request body carries `permissions` — so revoking a `read` while its `write` stands earns the same refusal. An `anyone` element satisfies it for every grantee: a world-readable record leaves no writer blind. `permissions: []` is an ordinary one-call write, since it names nothing at all. @@ -368,20 +387,20 @@ DELETE /records/:id — withdraw one (soft delete) POST /records/:id/undelete — put it back ``` -A write is an ordinary `POST /records` whose `typeId` is `_grant@1` and whose content is `{ typeId, actions, grantee }`: +A write is an ordinary `POST /records` whose `typeId` is `_grant@1` and whose content is `{ baseId, actions, grantee }`. `baseId` names a type family, so a grant reaches every version of it; a versioned value (`com.example/comment@1`) or a family a grant cannot target (`_grant`, `_config`, `_app`, `_install`) is `422`: ```json { "typeId": "_grant@1", "content": { - "typeId": "com.example/comment@1", + "baseId": "com.example/comment", "actions": ["create", "read-own", "update-own", "delete-own"], "grantee": { "kind": "entity", "entityId": "did:key:..." } } } ``` -**Only the stack owner acting alone writes a grant.** No grant can confer it — `_grant` is among the families a grant cannot target — and no record-level `write` element reaches it either, so a delegated token is refused however the owner issued it. Editing an existing grant is refused on the same terms as minting a fresh one, since rewriting a grant's `actions`, `typeId` or `grantee` reaches the same escalation by another route. The fence covers every write verb, not merely the content one: `PATCH /records/:id`, both `/permissions` endpoints, both `/associations` endpoints, `DELETE /records/:id`, `POST /records/:id/undelete` and `POST /records/:id/restore/:version` are all refused. A requester who can read the grant gets `403`; one who cannot gets `404`, per the disclosure rule above. +**Only the stack owner acting alone writes a grant.** No grant can confer it — `_grant` is among the families a grant cannot target — and no record-level `write` element reaches it either, so a delegated token is refused however the owner issued it. Editing an existing grant is refused on the same terms as minting a fresh one, since rewriting a grant's `actions`, `baseId` or `grantee` reaches the same escalation by another route. The fence covers every write verb, not merely the content one: every aspect write, `DELETE /records/:id`, `POST /records/:id/undelete` and `POST /records/:id/restore/:version` are all refused. A requester who can read the grant gets `403`; one who cannot gets `404`, per the disclosure rule above. Reads are not fenced: `GET /records/:id`, `GET /records/:id/versions` and `GET /records/:id/versions/:version` on a grant stay on their ordinary gates, so a write-holder can audit the grant they hold. History is the mutate surface, so a `read` element alone does not reach it. @@ -391,4 +410,4 @@ Withdrawal is a soft delete, and only deletion withdraws a grant. `DELETE /recor ### Principal and subject -A token names two identities: the **principal**, who authenticated (governs authority — grant lookups, a change set's `permissions` key), and the **subject**, who the principal acts for (governs attribution — `record.createdBy.subjectId` on writes, `-own` matching). They're equal unless the token was issued with a distinct `subjectId`. A delegated write stamps both into `createdBy` (and `updatedBy`): `subjectId` is the subject, and `principalId` is present when the two differ. `GET /records` and `POST /records/query` can filter on either via `createdBySubject`/`createdByPrincipal`. Effective authority under delegation is the intersection of both parties' grants — a delegated app can't act beyond what the subject itself also permits. +A token names two identities: the **principal**, who authenticated (governs authority — grant lookups, the reshare gate), and the **subject**, who the principal acts for (governs attribution — `record.createdBy.subjectId` on writes, `-own` matching). They're equal unless the token was issued with a distinct `subjectId`. A delegated write stamps both into `createdBy` (and `updatedBy`): `subjectId` is the subject, and `principalId` is present when the two differ. `GET /records` and `POST /records/query` can filter on either via `createdBySubject`/`createdByPrincipal`. Effective authority under delegation is the intersection of both parties' grants — a delegated app can't act beyond what the subject itself also permits. diff --git a/docs/deployment.md b/docs/deployment.md index 19fa28f..d0eda6e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -160,7 +160,7 @@ What bounds the waiting instead is `QUERY_QUEUE_LIMIT` (default 64): the number A worker that dies _unexpectedly_ — as opposed to one deliberately recycled at a deadline — is respawned with exponential backoff (100ms doubling to 30s), and after five consecutive failures the pool reports itself unhealthy and fails searches fast with the underlying error. This is what keeps a fatal misconfiguration (a `DB_PATH` that has become unreadable, say) from spinning the process respawning threads forever, and makes it surface as itself rather than as a stream of timeouts. The pool recovers on its own once the cause clears. -Every other route (`GET /records/:id`, `POST /records`, `PATCH /records/:id`, etc.) still runs on the main request thread — those are index-bound lookups and writes, not full-index scans, so they aren't the unbounded-cost case this section covers. +Every other route (`GET /records/:id`, `POST /records`, `PATCH /records/:id/content`, etc.) still runs on the main request thread — those are index-bound lookups and writes, not full-index scans, so they aren't the unbounded-cost case this section covers. Raise `QUERY_TIMEOUT_MS` if your store is large enough that legitimate searches routinely take longer than the default, and raise `QUERY_WORKER_POOL_SIZE` if concurrent searches should run in parallel rather than queue behind each other — each pool worker is a full second (or third, ...) connection to the database, so size it against expected concurrent search load, not total request volume. Raise `QUERY_QUEUE_LIMIT` only if you would rather hold bursts than shed them; lower it to fail fast under overload. @@ -172,7 +172,7 @@ Note that `GET /records` and `POST /records/query` are both reachable without au The `data` volume (`DB_PATH`'s directory) holds more than the database file itself: `stack.db-wal` and `stack.db-shm` are SQLite's write-ahead-log sidecar files, present whenever the process has the database open, and `stack.db.lock` is a storage-ownership lock recording the PID of the process that opened it. Back up or copy the whole directory, not just `stack.db` — a `stack.db` file copied without its `-wal` sidecar can be missing recently-committed data. -The directory also holds `stack.db.tokens` — bearer-token issuance and lookup, kept in its own file rather than inside `stack.db` on purpose: auth material shouldn't travel with the portable stack data. Include it in operational backups (revoking a token should stay revoked across a restore), but exclude it from any "export your data" or stack-sharing flow — a copy of `stack.db` alone is the correct unit for that, and restoring one should never resurrect tokens the owner revoked. +The directory also holds `stack.db.tokens` — bearer-token issuance and lookup, kept in its own file rather than inside `stack.db` on purpose: auth material shouldn't travel with the portable stack data. Include it in operational backups (revoking a token should stay revoked across a restore), but exclude it from any "export your data" or stack-sharing flow — a copy of `stack.db` alone is the correct unit for that, and restoring one should never resurrect tokens the owner revoked. The same holds for the other two sibling stores: `stack.db.nonces` (in-flight DID handshake challenges) and `stack.db.installs` ([install requests](./api.md#installs) awaiting the owner, which are not approvals and so live outside the stack). A given `DB_PATH` may be held open by only one process at a time. If a second server instance (a botched redeploy, a stuck old container, a manual `pnpm start` alongside a running one) tries to open the same path, it fails at startup with an error naming the PID already holding it, rather than corrupting the database or silently serving alongside it — WAL and real file locking make concurrent _storage_ access safer than the old single-file adapter, but the single-writer topology stands regardless: permissions only exist behind this one server process, so a second writer would be a second, unenforced trust boundary. If you see this error on a routine restart, the previous process likely hasn't exited yet — give it time to shut down cleanly, or confirm it has actually stopped, before starting the replacement. @@ -198,6 +198,7 @@ Two consequences worth being deliberate about: - **The `authenticated` tier is a grant to the public.** A grant whose grantee is `{ "kind": "authenticated" }` resolves for any authenticated entity, and with the handshake open that is anyone at all. See [Permissions](./api.md#permissions). A grant naming a DID (`{ "kind": "entity", "entityId": "did:key:..." }`) is unaffected — that is the vouching mechanism. No tier is reachable by omission: a grant whose grantee is absent or unrecognized is refused, and confers nothing if it reaches storage some other way, so this is a reach you have to ask for by name. - **Rate limiting is what bounds the token table.** A stranger's handshake writes a token row with a 7-day expiry. Expired rows are reclaimed, but live ones are bounded only by issuance rate × TTL, so the proxy-level rate limit configured above is what actually caps the table. The reverse-proxy examples in this guide already cover `/auth/*`; if you write your own, do not exempt it. +- **Install requests are bounded the same way.** Any authenticated key can ask to be installed, which stores one pending request per key until the owner acts on it or it goes 7 days without being re-sent. Rate-limiting `/auth/*` bounds how many keys can ask. Read access is unaffected either way: `GET /records`, `POST /records/query`, `GET /records/:id` and `GET /types` already serve anonymous requests, subject to record-level permissions, and a token changes nothing about what they return. @@ -207,6 +208,6 @@ Read access is unaffected either way: `GET /records`, `POST /records/query`, `GE ## Schema Commons seeding -`SEED_COMMONS_TYPES=true` registers the [Schema Commons](https://github.com/haverstack/core/blob/main/docs/commons/README.md) types (`org.haverstack/note`, `bookmark`, `task`, `contact`, `article`, `place`, `page`, `photo`) from `@haverstack/commons` on every boot, via `defineType()`, which is idempotent by construction — safe to leave on permanently, and safe to flip on for an already-running stack. +`SEED_COMMONS_TYPES=true` registers the [Schema Commons](https://github.com/haverstack/core/blob/main/docs/commons/README.md) types (`org.haverstack/note`, `bookmark`, `task`, `contact`, `article`, `place`, `page`, `image`, `post`, `site`) from `@haverstack/commons` on every boot, via `defineType()`, which is idempotent by construction — safe to leave on permanently, and safe to flip on for an already-running stack. It defaults to **off**. Registering a type is not free: it shows up in `GET /types` and gets cached by every app that talks to this stack, whether or not that app uses it. More importantly, `@haverstack/commons` is Draft status — the [governance doc](https://github.com/haverstack/core/blob/main/docs/commons/README.md) explicitly reserves the right to change a Draft type's definition in place, without a version bump, until there's an install base to break. Opting in is the honest posture for a package with that status; turning it on is a statement that you want this reference server to demonstrate commons interop, not a default every deployer should inherit silently. diff --git a/package.json b/package.json index 961f756..e06b591 100644 --- a/package.json +++ b/package.json @@ -41,10 +41,10 @@ "format:check": "prettier --check ." }, "dependencies": { - "@haverstack/adapter-local": "^0.37.0", - "@haverstack/commons": "^0.32.0", - "@haverstack/core": "^0.38.0", - "@haverstack/wire-types": "^0.37.0", + "@haverstack/adapter-local": "^0.42.0", + "@haverstack/commons": "^0.38.0", + "@haverstack/core": "^0.43.0", + "@haverstack/wire-types": "^0.42.0", "@hono/node-server": "^2.1.1", "hono": "^4.13.7", "pino": "^10.3.1", @@ -52,7 +52,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@haverstack/conformance-fixtures": "^0.32.0", + "@haverstack/conformance-fixtures": "^0.37.0", "@types/node": "^26.2.0", "eslint": "^10.8.1", "eslint-config-prettier": "^10.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fb913b..317b30b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,17 +9,17 @@ importers: .: dependencies: '@haverstack/adapter-local': - specifier: ^0.37.0 - version: 0.37.0 + specifier: ^0.42.0 + version: 0.42.0 '@haverstack/commons': - specifier: ^0.32.0 - version: 0.32.0 - '@haverstack/core': specifier: ^0.38.0 version: 0.38.0 + '@haverstack/core': + specifier: ^0.43.0 + version: 0.43.0 '@haverstack/wire-types': - specifier: ^0.37.0 - version: 0.37.0 + specifier: ^0.42.0 + version: 0.42.0 '@hono/node-server': specifier: ^2.1.1 version: 2.1.1(hono@4.13.7) @@ -37,8 +37,8 @@ importers: specifier: ^10.0.1 version: 10.0.1(eslint@10.8.1) '@haverstack/conformance-fixtures': - specifier: ^0.32.0 - version: 0.32.0 + specifier: ^0.37.0 + version: 0.37.0 '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -264,28 +264,28 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@haverstack/adapter-local@0.37.0': - resolution: {integrity: sha512-uUQR3xkuiIlHdI4OIsP+qNnkGpW6NOiQVOosCEvIiSqZYO3ewBOOhFhQqPigrUQ814/92OgqhN633KqRmqRWnA==} + '@haverstack/adapter-local@0.42.0': + resolution: {integrity: sha512-HJiIl/sK8BdNyfKRYCMMJVA93zdjeMAzO4pC1bCn6NiqEbt/k+wumqbphNnWsC4484hu4NFDbOcYs75fSL1AiQ==} engines: {node: '>=22.5.0'} - '@haverstack/blob-adapter-disk@0.36.0': - resolution: {integrity: sha512-OfjZMQ3fAl4K5gkzQOPxH3XoBM8FaCLY2wUdFqKe2yI6bFqhXWlXkNhGue8syCX6BHn8DR566MwWgWo/qCJYBA==} + '@haverstack/blob-adapter-disk@0.41.0': + resolution: {integrity: sha512-Lro40nYbfHS9kydMmLwWx2LliAv5zNLt855s9s3zRtj3nCYVN/YsMJSDQnMAOlFxG8+zl5eiXgZ4cQg9fgT8Qg==} - '@haverstack/commons@0.32.0': - resolution: {integrity: sha512-60J73vmQhu+PePMngDwxM6jjK0o3/Y4jWQhf/8c68hXAMfIy28LrrQqRB6r8bBpD/SX2VZODjh5DX2WqDrwlmA==} + '@haverstack/commons@0.38.0': + resolution: {integrity: sha512-8gh7QK/b0wxl5Ur10TGwabeV8CgM+4wZVCe8Qw9CeYNUOqN2jDmnjwGh2Lt1c9PsW3kiJbz/r9KzpC42ZRCYqA==} - '@haverstack/conformance-fixtures@0.32.0': - resolution: {integrity: sha512-NpYsfzdt2/cecgtQOzPMuqNdsprD9baQpbvY1lwbygkZteQvPjpCqsjNYPtsTaAww1upvs3iDx9LVs5ItLE+hQ==} + '@haverstack/conformance-fixtures@0.37.0': + resolution: {integrity: sha512-3f3fmJG1sPsuVJjKjO5/2rDz8nW1DpYp0Jq3hhZOyAPC3aLdVuXHlHgMxKcuEeEOQWLlxbLZyYPTZBoQbZIKAw==} - '@haverstack/core@0.38.0': - resolution: {integrity: sha512-9ltPYIU/gc0h+bZntc2IFZ4ke+yodbT6R2ct1hWMMnh6ZBZ7I+MeJDVPPZKxNi5UcEGi+NVdqSEYiIlwq2vBFA==} + '@haverstack/core@0.43.0': + resolution: {integrity: sha512-+B49HsoE9Io8A1IZWGlu1dkLxzVN3GyQEHeQQ0hHvIdS4RLLV7liz/27b8ZF+bN7SH6m0dgPVdu31hbvzIHXFQ==} - '@haverstack/record-adapter-sqlite@0.30.0': - resolution: {integrity: sha512-ZoCeWrxWQau/xc2UXZxb4tUWBFLgu+IlRZEp687ZKI14+Jf+ruWZs38yyw8lG7AZzlsgUtKnxu7hx4FGT2K9Yw==} + '@haverstack/record-adapter-sqlite@0.35.0': + resolution: {integrity: sha512-5teWlNQzZKO4KPioMkEZwjmF/IzWZhmYzURpnwpC+f7MO6X8EuTzcyCnb7OSAopbRYiRLXe5xOcglN3MRtWYtA==} engines: {node: '>=22.5.0'} - '@haverstack/wire-types@0.37.0': - resolution: {integrity: sha512-5x9LC+hIMSO31BvOWc2cjcJ2t9sKRhqW9+IzAqjjLAxTtTOef3Z7w3RBJd6BPYn9SHVW22wJo1tjAhdrjSBU1A==} + '@haverstack/wire-types@0.42.0': + resolution: {integrity: sha512-OnI38AbSdAPzuT5T5qdnUTXjdB7h+SmYwqz3I8d0Tb2A35K3I+xfdj7jYJBsOw8A1UAqeCzUbSfS/rMEhcoTug==} '@hono/node-server@2.1.1': resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} @@ -1261,33 +1261,33 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@haverstack/adapter-local@0.37.0': + '@haverstack/adapter-local@0.42.0': dependencies: - '@haverstack/blob-adapter-disk': 0.36.0 - '@haverstack/core': 0.38.0 - '@haverstack/record-adapter-sqlite': 0.30.0 + '@haverstack/blob-adapter-disk': 0.41.0 + '@haverstack/core': 0.43.0 + '@haverstack/record-adapter-sqlite': 0.35.0 - '@haverstack/blob-adapter-disk@0.36.0': + '@haverstack/blob-adapter-disk@0.41.0': dependencies: - '@haverstack/core': 0.38.0 + '@haverstack/core': 0.43.0 - '@haverstack/commons@0.32.0': + '@haverstack/commons@0.38.0': dependencies: - '@haverstack/core': 0.38.0 + '@haverstack/core': 0.43.0 - '@haverstack/conformance-fixtures@0.32.0': + '@haverstack/conformance-fixtures@0.37.0': dependencies: - '@haverstack/wire-types': 0.37.0 + '@haverstack/wire-types': 0.42.0 - '@haverstack/core@0.38.0': {} + '@haverstack/core@0.43.0': {} - '@haverstack/record-adapter-sqlite@0.30.0': + '@haverstack/record-adapter-sqlite@0.35.0': dependencies: - '@haverstack/core': 0.38.0 + '@haverstack/core': 0.43.0 - '@haverstack/wire-types@0.37.0': + '@haverstack/wire-types@0.42.0': dependencies: - '@haverstack/core': 0.38.0 + '@haverstack/core': 0.43.0 '@hono/node-server@2.1.1(hono@4.13.7)': dependencies: diff --git a/src/app.ts b/src/app.ts index c3da25c..5de24aa 100644 --- a/src/app.ts +++ b/src/app.ts @@ -17,6 +17,7 @@ import { attachmentRoutes } from './routes/attachments.js'; import { entityRoutes } from './routes/entity.js'; import { tokenRoutes } from './routes/tokens.js'; import { authRoutes } from './routes/auth.js'; +import { installRoutes } from './routes/installs.js'; import { wireError } from './wireError.js'; export type { AppEnv }; @@ -79,6 +80,7 @@ export function createApp(ctx: StackContext, config: Config, logger: Logger): Ho app.route('/entity', entityRoutes(ctx)); app.route('/tokens', tokenRoutes(ctx)); app.route('/auth', authRoutes(ctx, config.authOrigin, logger)); + app.route('/installs', installRoutes(ctx)); app.notFound((c) => wireError(c, 404, 'not_found', 'Not found')); diff --git a/src/lib/canonicalJson.ts b/src/lib/canonicalJson.ts new file mode 100644 index 0000000..ea17078 --- /dev/null +++ b/src/lib/canonicalJson.ts @@ -0,0 +1,18 @@ +/** + * JSON with every object's keys in sorted order, so two values that mean + * the same thing serialize the same way whatever order a client wrote + * their keys in. Array order is kept, since it is part of the value. + */ +export function canonicalJson(value: unknown): string { + return JSON.stringify(sortKeys(value)); +} + +function sortKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeys); + if (value === null || typeof value !== 'object') return value; + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sortKeys((value as Record)[key])]), + ); +} diff --git a/src/lib/historyPage.ts b/src/lib/historyPage.ts new file mode 100644 index 0000000..53c2293 --- /dev/null +++ b/src/lib/historyPage.ts @@ -0,0 +1,26 @@ +// A record's version history and its journal are the two reads with no +// ceiling when `limit` is omitted: omitting it reads everything by +// contract, so a page size can't be supplied on the caller's behalf +// without silently truncating exactly the caller reading the whole thing. +// This server bounds a page and says so in `cursor` instead, as the spec +// lets it. See docs/spec/wire-format.md § Versions and § Journal. +export const MAX_HISTORY_PAGE = 500; + +/** + * Reads one page of a history, one row past it to learn whether anything + * follows, so `cursor` is null exactly when the history is exhausted. A + * `limit` below 1 is passed through as given: whether it is in range is + * `Stack`'s to judge. + */ +export async function readHistoryPage( + limit: number | undefined, + read: (limit: number) => Promise, + cursorOf: (row: T) => number, +): Promise<{ rows: T[]; cursor: number | null }> { + const page = Math.min(limit ?? MAX_HISTORY_PAGE, MAX_HISTORY_PAGE); + if (page < 1) return { rows: await read(page), cursor: null }; + const rows = await read(page + 1); + if (rows.length <= page) return { rows, cursor: null }; + const kept = rows.slice(0, page); + return { rows: kept, cursor: cursorOf(kept[kept.length - 1]!) }; +} diff --git a/src/lib/ifMatch.ts b/src/lib/ifMatch.ts new file mode 100644 index 0000000..1aa6c1d --- /dev/null +++ b/src/lib/ifMatch.ts @@ -0,0 +1,14 @@ +import type { Context } from 'hono'; +import type { IfVersionOptions } from '@haverstack/core'; +import { parseIfMatch } from '@haverstack/core/wire'; +import type { AppEnv } from '../types.js'; + +/** + * The `ifVersion` an `If-Match` header carries. Every record write reads + * one and checks it when present; a malformed header is core's 400, never a + * fallthrough to unconditional. See docs/spec/wire-format.md + * § Optimistic concurrency. + */ +export function ifMatchVersion(c: Context): IfVersionOptions { + return { ifVersion: parseIfMatch(c.req.header('If-Match')) }; +} diff --git a/src/lib/installRequestStore.ts b/src/lib/installRequestStore.ts new file mode 100644 index 0000000..70334c3 --- /dev/null +++ b/src/lib/installRequestStore.ts @@ -0,0 +1,129 @@ +import { DatabaseSync } from 'node:sqlite'; +import { randomUUID } from 'node:crypto'; +import type { AppManifest, EntityId } from '@haverstack/core'; +import { canonicalJson } from './canonicalJson.js'; +import { SELF_SERVICE_TTL_MS } from './selfService.js'; + +/** Conventional sibling path for the install request store beside a stack's main .db file. */ +export const defaultInstallRequestStorePath = (dbPath: string): string => `${dbPath}.installs`; + +export type InstallRequestEntry = { + /** Names this manifest as sent; a key sending a different one gets a new id. */ + id: string; + /** The key asking to be installed — the session's principal. */ + did: EntityId; + manifest: AppManifest; + requestedAt: Date; + expiresAt: Date; +}; + +type Row = { id: string; did: string; manifest: string; requestedAt: number; expiresAt: number }; + +/** + * Install requests awaiting the owner, one per key: the app's half of an + * install (POST /installs) leaves one here, and the owner's half + * (/installs/requests) reviews and approves it. A request is not an + * approval, so it lives with the server rather than in the stack — a key + * that merely authenticated writes nothing into the owner's data. Its own + * sibling file, like AuthNonceStore. See docs/spec/apps.md § Over the wire. + */ +export class InstallRequestStore { + private constructor(private readonly db: DatabaseSync) {} + + static open(path: string): InstallRequestStore { + const db = new DatabaseSync(path); + db.exec('PRAGMA journal_mode = WAL;'); + db.exec( + `CREATE TABLE IF NOT EXISTS install_requests ( + did TEXT PRIMARY KEY, + id TEXT NOT NULL UNIQUE, + manifest TEXT NOT NULL, + requestedAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL + );`, + ); + db.exec( + 'CREATE INDEX IF NOT EXISTS idx_install_requests_expiresAt ON install_requests (expiresAt);', + ); + return new InstallRequestStore(db); + } + + /** + * Records `manifest` as `did`'s pending request, replacing any other it + * had. Re-sending the same manifest — compared as canonical JSON, so key + * order is not a difference — keeps its id and refreshes its expiry, so + * an id the owner reviewed always names what they reviewed. + */ + put(did: EntityId, manifest: AppManifest): InstallRequestEntry { + this.sweepExpired(); + const json = canonicalJson(manifest); + const now = Date.now(); + const expiresAt = now + SELF_SERVICE_TTL_MS; + const existing = this.db.prepare('SELECT * FROM install_requests WHERE did = ?').get(did) as + Row | undefined; + if (existing && existing.manifest === json) { + this.db + .prepare('UPDATE install_requests SET expiresAt = ? WHERE did = ?') + .run(expiresAt, did); + return toEntry({ ...existing, expiresAt }); + } + const row: Row = { id: randomUUID(), did, manifest: json, requestedAt: now, expiresAt }; + this.db + .prepare( + `INSERT OR REPLACE INTO install_requests (did, id, manifest, requestedAt, expiresAt) + VALUES (?, ?, ?, ?, ?)`, + ) + .run(row.did, row.id, row.manifest, row.requestedAt, row.expiresAt); + return toEntry(row); + } + + get(id: string): InstallRequestEntry | null { + const row = this.db + .prepare('SELECT * FROM install_requests WHERE id = ? AND expiresAt >= ?') + .get(id, Date.now()) as Row | undefined; + return row ? toEntry(row) : null; + } + + /** Every live request, oldest first. */ + list(): InstallRequestEntry[] { + const rows = this.db + .prepare('SELECT * FROM install_requests WHERE expiresAt >= ? ORDER BY requestedAt, id') + .all(Date.now()) as Row[]; + return rows.map(toEntry); + } + + /** The pending request `did` holds, if any. */ + forKey(did: EntityId): InstallRequestEntry | null { + const row = this.db + .prepare('SELECT * FROM install_requests WHERE did = ? AND expiresAt >= ?') + .get(did, Date.now()) as Row | undefined; + return row ? toEntry(row) : null; + } + + /** + * Removes the request `id` names — once it is installed or declined, there + * is nothing left to ask. A request its key has since replaced has a new + * id, so removing the old one leaves the new one standing. + */ + remove(id: string): void { + this.db.prepare('DELETE FROM install_requests WHERE id = ?').run(id); + } + + private sweepExpired(): void { + this.db.prepare('DELETE FROM install_requests WHERE expiresAt < ?').run(Date.now()); + } + + close(): void { + this.db.close(); + } +} + +function toEntry(row: Row): InstallRequestEntry { + return { + id: row.id, + did: row.did, + manifest: JSON.parse(row.manifest) as AppManifest, + requestedAt: new Date(row.requestedAt), + expiresAt: new Date(row.expiresAt), + }; +} diff --git a/src/lib/queryLimit.ts b/src/lib/queryLimit.ts deleted file mode 100644 index 8b94f23..0000000 --- a/src/lib/queryLimit.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { StackQuery } from '@haverstack/core'; - -// core's parsers (@haverstack/core/wire) report `limit` as requested and -// never clamp it — a ceiling is deployment policy, not wire contract. This -// server's ceilings, applied at the call sites in records.ts. -export const MAX_QUERY_LIMIT = 1000; - -export function clampLimit(query: StackQuery): StackQuery { - if (query.limit === undefined) return query; - return { ...query, limit: Math.min(query.limit, MAX_QUERY_LIMIT) }; -} - -// The journal is the one read with no ceiling when `limit` is omitted, so -// a page size can't be supplied on the caller's behalf: omitting `limit` -// reads the whole log by contract, and truncating it silently would betray -// exactly the caller reconstructing an association's full history. This -// server bounds a page and says so in `cursor` instead. See -// docs/spec/wire-format.md § Journal. -export const MAX_JOURNAL_LIMIT = 500; - -export function clampJournalLimit(limit: number | undefined): number { - return limit === undefined ? MAX_JOURNAL_LIMIT : Math.min(limit, MAX_JOURNAL_LIMIT); -} diff --git a/src/lib/queryWorker/protocol.ts b/src/lib/queryWorker/protocol.ts index be19c49..50a7f6e 100644 --- a/src/lib/queryWorker/protocol.ts +++ b/src/lib/queryWorker/protocol.ts @@ -15,7 +15,7 @@ import type { WireError } from '@haverstack/wire-types'; * Passed as `workerData` when a query worker is spawned. dbPath is the * whole of it: a worker calls LocalAdapter.open(), which reads timezone * (and the rest of the stack config) out of the database itself — only - * the initialize path takes one as input, and workers never initialize. + * creating a store takes one as input, and workers never create one. */ export type QueryWorkerInit = { dbPath: string; diff --git a/src/lib/queryWorker/worker.ts b/src/lib/queryWorker/worker.ts index 7e749c0..1a4cfd2 100644 --- a/src/lib/queryWorker/worker.ts +++ b/src/lib/queryWorker/worker.ts @@ -28,7 +28,7 @@ const init = workerData as QueryWorkerInit; // The database already exists — the main thread's initStack() created or // opened it, and seeded system types, before any worker is spawned (see -// pool.ts). open() (not openOrInitialize()) reflects that: there is no +// pool.ts). open()'s default `create: 'never'` reflects that: there is no // first-run path to handle here. const adapter = await LocalAdapter.open({ path: init.dbPath }); const stack = await Stack.open(adapter); diff --git a/src/lib/resumeBuffer.ts b/src/lib/resumeBuffer.ts index 45f7ae9..e837e4e 100644 --- a/src/lib/resumeBuffer.ts +++ b/src/lib/resumeBuffer.ts @@ -104,7 +104,6 @@ export type ResumeBufferKeyParts = { subjectId: string | null; filter: ChangeFilter; includeRecords: boolean; - includeUnlisted: boolean; }; /** @@ -145,7 +144,8 @@ export function resumeBufferKey(parts: ResumeBufferKeyParts): string { principalId: parts.principalId, subjectId: parts.subjectId, includeRecords: parts.includeRecords, - includeUnlisted: parts.includeUnlisted, + // Absent and `false` both exclude unlisted records, so they key alike. + includeUnlisted: filter.includeUnlisted === true, ...(typeId !== undefined && { typeId }), ...(baseId !== undefined && { baseId }), ...(filter.parentId !== undefined && { parentId: filter.parentId }), diff --git a/src/lib/selfService.ts b/src/lib/selfService.ts new file mode 100644 index 0000000..87610db --- /dev/null +++ b/src/lib/selfService.ts @@ -0,0 +1,5 @@ +// What any key that completes the DID handshake can leave behind on its own +// — a bearer token, a pending install request — lives at most this long +// unless it is renewed. Self-service state owes a bounded lifetime, and one +// bound for all of it keeps a stranger's footprint a single number. +export const SELF_SERVICE_TTL_MS = 7 * 24 * 60 * 60 * 1000; diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index f4a019e..d4eebed 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,7 +1,7 @@ import { timingSafeEqual } from 'node:crypto'; import type { MiddlewareHandler } from 'hono'; import { StackPermissionError } from '@haverstack/core'; -import type { TokenSession } from '@haverstack/core/wire'; +import { isOwnerActingAlone } from '@haverstack/core/wire'; import type { AppEnv } from '../types.js'; import type { StackContext } from '../stack.js'; import { wireError } from '../wireError.js'; @@ -53,18 +53,11 @@ export function requireAuth(): MiddlewareHandler { } /** - * Whether a session is the stack owner acting alone: undelegated, and - * authenticated as the owner rather than merely delegated for it. Being the - * owner is never on its own sufficient under delegation — a delegated - * session with the owner as principal still fails this, matching - * `ScopedStack`'s own owner-only gates (e.g. purge). See - * docs/spec/access-control.md § Delegation. + * Gates a route to the owner acting alone: undelegated, and authenticated + * as the owner rather than merely delegated for it — the same test + * `ScopedStack`'s own owner-only gates apply. See + * docs/spec/access-control.md § Delegation: principal and subject. */ -export function isOwnerActingAlone(auth: TokenSession | null, ownerEntityId: string): boolean { - return auth?.principalId === ownerEntityId && auth?.subjectId === ownerEntityId; -} - -/** Gates a route to the owner acting alone; see `isOwnerActingAlone()`. */ export function requireOwner(ownerEntityId: string): MiddlewareHandler { return async (c, next) => { const auth = c.get('auth'); @@ -75,3 +68,19 @@ export function requireOwner(ownerEntityId: string): MiddlewareHandler { await next(); }; } + +/** + * Gates a route to a session acting as itself — not a principal acting + * for someone else. Checked before the body is read, like requireOwner(), + * so a refused identity is refused whatever it sent. + */ +export function requireActingAlone(): MiddlewareHandler { + return async (c, next) => { + const auth = c.get('auth'); + if (!auth) return wireError(c, 401, 'unauthorized', 'Missing or invalid bearer token'); + if (auth.principalId !== auth.subjectId) { + throw new StackPermissionError('This request must come from the key acting as itself'); + } + await next(); + }; +} diff --git a/src/middleware/errors.ts b/src/middleware/errors.ts index 4b9a5ad..8f683d3 100644 --- a/src/middleware/errors.ts +++ b/src/middleware/errors.ts @@ -44,7 +44,7 @@ export function errorMiddleware(logger: Logger, stack: Stack): ErrorHandler { return async (c, next) => { @@ -22,11 +22,3 @@ export function knownParams(...names: string[]): MiddlewareHandler { await next(); }; } - -/** A boolean query param: absent is false, and anything but `true`/`false` is refused. */ -export function booleanParam(url: URL, name: string): boolean { - const value = url.searchParams.get(name); - if (value === null || value === 'false') return false; - if (value === 'true') return true; - throw new StackBadRequestError(`Invalid ${name}: expected true or false, got "${value}"`); -} diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts index c3736c7..be65907 100644 --- a/src/routes/attachments.ts +++ b/src/routes/attachments.ts @@ -11,6 +11,7 @@ import { parseUploadFilename, NOSNIFF_HEADER_NAME, NOSNIFF_HEADER_VALUE, + parseDownloadParams, } from '@haverstack/core/wire'; import { serializeRecord } from '@haverstack/wire-types'; import type { AppEnv } from '../types.js'; @@ -18,6 +19,7 @@ import { knownParams } from '../middleware/params.js'; import type { StackContext } from '../stack.js'; import { requireAuth, requireOwner } from '../middleware/auth.js'; import { readJson } from '../lib/json.js'; +import { wireError } from '../wireError.js'; export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): Hono { const app = new Hono(); @@ -53,9 +55,10 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): }); // GET /attachments/:fileId — download - app.get('/:fileId', knownParams('contentType', 'filename'), async (c) => { + app.get('/:fileId', async (c) => { const fileId = c.req.param('fileId'); const auth = c.get('auth'); + const { contentTypeParam, filenameParam } = parseDownloadParams(new URL(c.req.url)); let data: Uint8Array; try { @@ -67,7 +70,7 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): // for missing and forbidden, so this handler owes no anti-oracle // logic of its own — only to leave that answer intact. if (!auth && e instanceof StackPermissionError) { - return c.json({ error: 'Unauthorized' }, 401); + return wireError(c, 401, 'unauthorized', 'Missing or invalid bearer token'); } throw e; } @@ -84,9 +87,6 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): requesterEntityId: auth?.subjectId, }); - const contentTypeParam = c.req.query('contentType'); - const filenameParam = c.req.query('filename'); - const { contentType, forced } = resolveAttachmentDownloadContentType({ contentTypeParam, filenameParam, diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 5a1b6cc..46c68b0 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,13 +1,16 @@ import { Hono } from 'hono'; import type { ContentfulStatusCode } from 'hono/utils/http-status'; -import { verifyAuthChallenge, base64urlDecode } from '@haverstack/core/wire'; +import { + verifyAuthChallenge, + base64urlDecode, + parseAuthChallengeBody, + parseAuthTokenBody, +} from '@haverstack/core/wire'; import { isValidDidKey } from '@haverstack/core/did'; import { WIRE_AUTH_ERROR_STATUS } from '@haverstack/wire-types'; import type { - AuthChallengeRequest, - AuthChallengeResponse, - AuthTokenRequest, - AuthTokenResponse, + WireAuthChallengeResponse, + WireAuthTokenResponse, WireAuthErrorCode, } from '@haverstack/wire-types'; import type { Context } from 'hono'; @@ -17,12 +20,11 @@ import { knownParams } from '../middleware/params.js'; import type { StackContext } from '../stack.js'; import { readJson } from '../lib/json.js'; import { createExpiredTokenSweeper } from '../lib/tokenSweep.js'; +import { SELF_SERVICE_TTL_MS } from '../lib/selfService.js'; // The handshake never issues a delegated token: proving key possession -// proves the principal and nothing about whom it may act for. It gets a -// default TTL rather than the owner's choice of one, because a token -// anyone can mint for themselves owes a bounded lifetime. -const AUTH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; +// proves the principal and nothing about whom it may act for. It gets the +// self-service lifetime rather than the owner's choice of one. // Stamped on every handshake-issued token. GET /tokens is the owner's only // view of what exists, and without this a self-service token is @@ -47,32 +49,22 @@ export function authRoutes(ctx: StackContext, authOrigin: string, logger: Logger // POST /auth/challenge — issues a nonce bound to the requested DID. // Unauthenticated: this is how a token is earned in the first place. app.post('/challenge', knownParams(), async (c) => { - const body = await readJson>(c, ['did']); - if (typeof body.did !== 'string' || !isValidDidKey(body.did)) { + const body = parseAuthChallengeBody(await readJson(c)); + if (!isValidDidKey(body.did)) { return authError(c, 'invalid_did', 'Not a valid did:key'); } const { nonce, expiresAt } = ctx.nonces.issue(body.did); - const response: AuthChallengeResponse = { nonce, expiresAt: expiresAt.toISOString() }; + const response: WireAuthChallengeResponse = { nonce, expiresAt: expiresAt.toISOString() }; return c.json(response); }); // POST /auth/token — redeems a signed nonce for a bearer token. // Unauthenticated for the same reason. app.post('/token', knownParams(), async (c) => { - const body = await readJson>(c, ['did', 'nonce', 'signature']); - if (typeof body.did !== 'string' || !isValidDidKey(body.did)) { + const body = parseAuthTokenBody(await readJson(c)); + if (!isValidDidKey(body.did)) { return authError(c, 'invalid_did', 'Not a valid did:key'); } - // Checked ahead of the signature so a nonce issued to a different DID - // is refused as unknown_nonce even when the caller's own signature over - // it is perfectly valid — the nonce belongs to the DID it was issued - // for, not to whoever can produce a valid signature. - if (typeof body.nonce !== 'string') { - return authError(c, 'unknown_nonce', 'Unknown or already-used nonce'); - } - if (typeof body.signature !== 'string') { - return authError(c, 'invalid_signature', 'Signature does not verify'); - } // Spent on this call regardless of what happens next — single-use // means a redemption attempt consumes the nonce, not merely a @@ -105,7 +97,7 @@ export function authRoutes(ctx: StackContext, authOrigin: string, logger: Logger // Undelegated by construction: principalId and subjectId are both the // proven DID. Never let a client name its own subject. - const expiresAt = new Date(Date.now() + AUTH_TOKEN_TTL_MS); + const expiresAt = new Date(Date.now() + SELF_SERVICE_TTL_MS); const { token } = await ctx.tokens.createToken( { subjectId: body.did, principalId: body.did }, { @@ -119,7 +111,7 @@ export function authRoutes(ctx: StackContext, authOrigin: string, logger: Logger // unauthenticated route that makes each handshake cost more than the // last. The store persists the Date unchanged, so a read-back could // only return what is already here. - const response: AuthTokenResponse = { + const response: WireAuthTokenResponse = { token, expiresAt: expiresAt.toISOString(), principalId: body.did, diff --git a/src/routes/changes.ts b/src/routes/changes.ts index d54587c..2a463cc 100644 --- a/src/routes/changes.ts +++ b/src/routes/changes.ts @@ -7,11 +7,11 @@ import type { Config } from '../config.js'; import type { ScopedStack, RecordChange } from '@haverstack/core'; import type { TokenSession } from '@haverstack/core/wire'; import { StackBadRequestError, StackPermissionError } from '@haverstack/core'; -import { parseChangeParams } from '@haverstack/core/wire'; +import { parseChangeParams, isOwnerActingAlone } from '@haverstack/core/wire'; import { serializeChange, isValidCursor } from '@haverstack/wire-types'; import type { ChangeResetReason } from '@haverstack/wire-types'; import type { Logger } from 'pino'; -import { safeCompare, isOwnerActingAlone } from '../middleware/auth.js'; +import { safeCompare } from '../middleware/auth.js'; import { FrameGate } from '../lib/frameGate.js'; import { decodeCursor } from '../lib/resumeCursor.js'; import { ResumeBufferRegistry, resumeBufferKey, type ResumeEntry } from '../lib/resumeBuffer.js'; @@ -88,14 +88,14 @@ export function changeRoutes( app.get('/', async (c) => { const url = new URL(c.req.url); const auth = c.get('auth'); - const { filter, includeRecords, includeUnlisted } = parseChangeParams(url); + const { filter, includeRecords } = parseChangeParams(url); const presentedRaw = presentedCursorRaw(c, url); // Refused before the stream opens: a throw from subscribe() after // streaming starts is caught below and merely closes the connection, // silently, having already answered 200. includeUnlisted owes a real // 403, the same one GET /records answers with. - if (includeUnlisted && !isOwnerActingAlone(auth, ctx.stack.ownerEntityId)) { + if (filter.includeUnlisted && !(auth && isOwnerActingAlone(auth, ctx.stack.ownerEntityId))) { throw new StackPermissionError('includeUnlisted is owner-only'); } @@ -151,7 +151,7 @@ export function changeRoutes( (change: RecordChange) => { send('record', serializeChange(change)); }, - { filter, includeRecords, includeUnlisted }, + { filter, includeRecords }, ); } else { const key = resumeBufferKey({ @@ -159,11 +159,10 @@ export function changeRoutes( subjectId: auth?.subjectId ?? null, filter, includeRecords, - includeUnlisted, }); const scoped = scopeFor(auth); const buffer = await resumeBuffers.acquire(key, (onChange) => - scoped.subscribe(onChange, { filter, includeRecords, includeUnlisted }), + scoped.subscribe(onChange, { filter, includeRecords }), ); // Attached before anything below awaits, so there is no gap diff --git a/src/routes/entity.ts b/src/routes/entity.ts index 5dbbfb9..7c237a2 100644 --- a/src/routes/entity.ts +++ b/src/routes/entity.ts @@ -5,7 +5,9 @@ import type { StackContext } from '../stack.js'; import { requireAuth, requireOwner } from '../middleware/auth.js'; import { readJson } from '../lib/json.js'; import { serializeRecord } from '@haverstack/wire-types'; -import { StackBadRequestError, StackNotFoundError, StackValidationError } from '@haverstack/core'; +import { parseContentPatchBody } from '@haverstack/core/wire'; +import { ifMatchVersion } from '../lib/ifMatch.js'; +import { StackNotFoundError } from '@haverstack/core'; export function entityRoutes(ctx: StackContext): Hono { const app = new Hono(); @@ -19,18 +21,15 @@ export function entityRoutes(ctx: StackContext): Hono { return c.json(serializeRecord(record)); }); - app.patch('/', knownParams(), requireOwner(ownerEntityId), async (c) => { + // An alias for PATCH /records/:id/content on the owner's entity record, + // so it takes the same bare patch and the same If-Match. + // See docs/spec/wire-format.md § Entity. + app.patch('/content', knownParams(), requireOwner(ownerEntityId), async (c) => { const auth = c.get('auth')!; - const body = await readJson>(c, ['content']); - const content = body.content; - if (content === undefined) throw new StackBadRequestError('content is required'); - if (typeof content !== 'object' || content === null || Array.isArray(content)) - throw new StackValidationError([{ path: 'content', message: 'Must be an object' }]); + const patch = parseContentPatchBody(await readJson(c)); const record = await stack.asActor(auth).getOwnerEntity(); if (!record) throw new StackNotFoundError('Entity record not found'); - const updated = await stack - .asActor(auth) - .patchContent(record.id, content as Record); + const updated = await stack.asActor(auth).patchContent(record.id, patch, ifMatchVersion(c)); return c.json(serializeRecord(updated)); }); diff --git a/src/routes/installs.ts b/src/routes/installs.ts new file mode 100644 index 0000000..3155c65 --- /dev/null +++ b/src/routes/installs.ts @@ -0,0 +1,115 @@ +import { Hono } from 'hono'; +import { StackNotFoundError, isPlanEmpty } from '@haverstack/core'; +import type { InstallPlan } from '@haverstack/core'; +import { parseInstallBody } from '@haverstack/core/wire'; +import { serializeRecord } from '@haverstack/wire-types'; +import type { WireInstallResponse } from '@haverstack/wire-types'; +import type { AppEnv } from '../types.js'; +import { knownParams } from '../middleware/params.js'; +import type { StackContext } from '../stack.js'; +import { requireActingAlone, requireOwner } from '../middleware/auth.js'; +import { readJson } from '../lib/json.js'; +import type { InstallRequestEntry } from '../lib/installRequestStore.js'; + +/** + * The two halves of an app install. POST /installs is the app's, and the + * wire contract pins it: a key presents its manifest and learns whether + * it was approved. /installs/requests is the owner's, which the spec + * leaves to the server: review what a pending request would change, then + * approve or decline it. Underneath, both are Stack.planInstall(), and + * approval is Stack.installApp(). See docs/spec/apps.md § Over the wire. + */ +export function installRoutes(ctx: StackContext): Hono { + const app = new Hono(); + const { stack, installRequests } = ctx; + const ownerEntityId = stack.ownerEntityId; + + // The key being installed is the session's principal, never named in + // the body, so no app can ask on behalf of a key it does not hold. + // Planning writes nothing; only an empty plan is answered as installed. + // See docs/spec/wire-format.md § Installs. + app.post('/', knownParams(), requireActingAlone(), async (c) => { + const auth = c.get('auth')!; + const manifest = parseInstallBody(await readJson(c)); + const plan = await stack.planInstall(manifest, { did: auth.principalId }); + if (isPlanEmpty(plan)) { + const pending = installRequests.forKey(auth.principalId); + if (pending) installRequests.remove(pending.id); + const body: WireInstallResponse = { + status: 'installed', + install: serializeRecord(plan.existing!), + }; + return c.json(body, 200); + } + installRequests.put(auth.principalId, manifest); + const body: WireInstallResponse = { status: 'pending' }; + return c.json(body, 202); + }); + + app.get('/requests', knownParams(), requireOwner(ownerEntityId), (c) => { + return c.json({ requests: installRequests.list().map(serializeRequest) }); + }); + + // Planned on every read rather than stored, since what a manifest would + // change depends on the stack as it stands now. + app.get('/requests/:id', knownParams(), requireOwner(ownerEntityId), async (c) => { + const entry = requestOrNotFound(c.req.param('id')); + const plan = await stack.planInstall(entry.manifest, { did: entry.did }); + return c.json({ ...serializeRequest(entry), plan: serializePlan(plan) }); + }); + + // Applies the manifest the id names, planned afresh at approval. + app.post('/requests/:id/approve', knownParams(), requireOwner(ownerEntityId), async (c) => { + const entry = requestOrNotFound(c.req.param('id')); + const plan = await stack.planInstall(entry.manifest, { did: entry.did }); + const install = await stack.installApp(plan); + installRequests.remove(entry.id); + const body: WireInstallResponse = { status: 'installed', install: serializeRecord(install) }; + return c.json(body); + }); + + app.delete('/requests/:id', knownParams(), requireOwner(ownerEntityId), (c) => { + installRequests.remove(requestOrNotFound(c.req.param('id')).id); + return c.body(null, 204); + }); + + function requestOrNotFound(id: string): InstallRequestEntry { + const entry = installRequests.get(id); + if (!entry) throw new StackNotFoundError('Install request not found'); + return entry; + } + + return app; +} + +function serializeRequest(entry: InstallRequestEntry) { + return { + id: entry.id, + did: entry.did, + manifest: entry.manifest, + requestedAt: entry.requestedAt.toISOString(), + expiresAt: entry.expiresAt.toISOString(), + }; +} + +/** + * What a plan would change; its manifest and key are already on the request. + * Every field is named, so nothing reaches the wire unchosen, and the return + * type covers every InstallPlan field, so a field core adds fails to compile + * here until someone decides whether the owner's review shows it. + */ +function serializePlan( + plan: InstallPlan, +): Record, unknown> { + return { + existing: plan.existing ? serializeRecord(plan.existing) : null, + newFamilies: plan.newFamilies, + newVersions: plan.newVersions, + requestsAdded: plan.requestsAdded, + requestsRemoved: plan.requestsRemoved, + foreignRequests: plan.foreignRequests, + typeChanges: plan.typeChanges, + newKey: plan.newKey, + linkedKeys: plan.linkedKeys, + }; +} diff --git a/src/routes/records.ts b/src/routes/records.ts index 142bcd4..2381e01 100644 --- a/src/routes/records.ts +++ b/src/routes/records.ts @@ -1,25 +1,38 @@ import { Hono } from 'hono'; import type { AppEnv } from '../types.js'; -import { knownParams, booleanParam } from '../middleware/params.js'; +import { knownParams } from '../middleware/params.js'; import type { StackContext } from '../stack.js'; import type { ScopedStack } from '@haverstack/core'; import type { TokenSession } from '@haverstack/core/wire'; -import { requireAuth, requireOwner } from '../middleware/auth.js'; +import { requireAuth } from '../middleware/auth.js'; import { readJson } from '../lib/json.js'; import { parseQueryBody, parseQueryParams, + parseGetRecordParams, + parseDeleteParams, parseJournalParams, - parsePositiveInt, - parseIfMatch, + parseVersionsParams, + parseAssociationParams, + parseUnsignedInt, createOptionsFromWireRecord, - changesFromWireBody, + parseContentPatchBody, + parseParentBody, + parseUnlistedBody, + parseAssociationEditsBody, + parseAssociationsBody, + parsePermissionsBody, + parseMigrationBody, } from '@haverstack/core/wire'; -import { clampLimit, clampJournalLimit } from '../lib/queryLimit.js'; +import { readHistoryPage } from '../lib/historyPage.js'; +import { ifMatchVersion } from '../lib/ifMatch.js'; import { serializeRecord, serializeVersion, serializeJournalEntry } from '@haverstack/wire-types'; -import type { WireQueryResponse, WireJournalResponse } from '@haverstack/wire-types'; -import { StackValidationError, StackBadRequestError, StackNotFoundError } from '@haverstack/core'; -import type { AuthorityAssociation, DataAssociation, TypeId } from '@haverstack/core'; +import type { + WireQueryResponse, + WireJournalResponse, + WireVersionsResponse, +} from '@haverstack/wire-types'; +import { StackNotFoundError } from '@haverstack/core'; // --------------------------------------------------------------------------- // Route factory @@ -48,7 +61,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { const auth = c.get('auth'); - const query = clampLimit(parseQueryBody(await readJson(c))); + const query = parseQueryBody(await readJson(c)); const result = await queryWorker.query(auth, query, queryTimeoutMs); const body: WireQueryResponse = { records: result.records.map(serializeRecord), @@ -60,7 +73,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { const auth = c.get('auth'); - const query = clampLimit(parseQueryParams(new URL(c.req.url))); + const query = parseQueryParams(new URL(c.req.url)); const result = await queryWorker.query(auth, query, queryTimeoutMs); const body: WireQueryResponse = { records: result.records.map(serializeRecord), @@ -84,153 +97,122 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { - const id = c.req.param('id'); - const auth = c.get('auth'); - const record = await scopeFor(auth).get(id); + // GET /records/:id — a tombstone answers 404 unless ?includeDeleted=true, + // as on GET /records. See docs/spec/wire-format.md § Records. + app.get('/:id', async (c) => { + const opts = parseGetRecordParams(new URL(c.req.url)); + const record = await scopeFor(c.get('auth')).get(c.req.param('id'), opts); if (!record) throw new StackNotFoundError('Record not found'); return c.json(serializeRecord(record)); }); - // PATCH /records/:id — the body is a change set: an envelope naming any - // combination of `contentPatch`, `parentId`, `permissions`, `associations` - // and `unlisted`, applied as one atomic write that produces exactly one - // version. A conforming client sends { "contentPatch": { "title": "New" } }, - // never the bare patch — content is one aspect among five here, so the - // envelope is what tells a `parentId` naming a container from a content - // field of that name. One If-Match fences the whole multi-aspect edit. - // - // `changesFromWireBody()` owns the entire read of that envelope: which - // keys it may carry, the 400 an unrecognized or wholly absent key earns, - // and the 422 a malformed value earns. Content keys stay Stack's to judge, - // so nothing here inspects the patch. See docs/spec/wire-format.md § Records. - // - // No route-level guard belongs here: ScopedStack owns the _attachment@1 - // immutable-field and _grant@1 owner-only rules (docs/spec/attachments.md, - // docs/spec/access-control.md) and resolves each change-set key against its - // own gate, so a denial round-trips as the core error it is rather than a - // guess made before the permission check ran. - app.patch('/:id', knownParams(), requireAuth(), async (c) => { - const id = c.req.param('id'); - const auth = c.get('auth')!; - const changes = changesFromWireBody(await readJson(c)); + // Every aspect of a record has one write endpoint, and each endpoint + // reads If-Match and answers with the record it produced. Which gate a + // write passes — write, reshare, the _attachment@1 immutable fields, the + // _grant@1 owner-only rule — is ScopedStack's to resolve, so a denial + // round-trips as the core error it is rather than a guess made here. + // See docs/spec/wire-format.md § Records and § Optimistic concurrency. + app.patch('/:id/content', knownParams(), requireAuth(), async (c) => { + const patch = parseContentPatchBody(await readJson(c)); + const updated = await stack + .asActor(c.get('auth')!) + .patchContent(c.req.param('id'), patch, ifMatchVersion(c)); + return c.json(serializeRecord(updated)); + }); + app.put('/:id/parent', knownParams(), requireAuth(), async (c) => { + const { parentId } = parseParentBody(await readJson(c)); const updated = await stack - .asActor(auth) - .mutate(id, changes, { ifVersion: parseIfMatch(c.req.header('If-Match')) }); + .asActor(c.get('auth')!) + .setParent(c.req.param('id'), parentId, ifMatchVersion(c)); return c.json(serializeRecord(updated)); }); - // DELETE /records/:id (?purge=true for permanent). Both answer 200 with - // a record: a soft delete with the tombstone it produced, a purge - // with the record as it stood immediately before destruction. That body - // is the requester's only report of the files the purge stranded; every - // other row naming them is gone by the time it lands. deleteAndReturn() - // captures it inside the same write that destroys or tombstones the - // record, so there is no read-then-delete window for a concurrent write - // to fall into. See docs/spec/wire-format.md § Records and - // docs/spec/attachments.md § A purge strands the bytes it referenced. - app.delete('/:id', knownParams('purge'), requireAuth(), async (c) => { - const id = c.req.param('id'); - const auth = c.get('auth')!; - const purge = booleanParam(new URL(c.req.url), 'purge'); - const session = stack.asActor(auth); + app.put('/:id/unlisted', knownParams(), requireAuth(), async (c) => { + const { unlisted } = parseUnlistedBody(await readJson(c)); + const updated = await stack + .asActor(c.get('auth')!) + .setUnlisted(c.req.param('id'), unlisted, ifMatchVersion(c)); + return c.json(serializeRecord(updated)); + }); - const { record } = await session.deleteAndReturn(id, { - purge, - ifVersion: parseIfMatch(c.req.header('If-Match')), - }); + // DELETE /records/:id (?purge=true for permanent). Both answer 200 with + // a record: a soft delete with the tombstone it produced, a purge with + // the record as it stood immediately before destruction. That body is + // the requester's only report of the files the purge stranded, and + // delete() captures it inside the same write that destroys the record. + // See docs/spec/attachments.md § A purge strands the bytes it referenced. + app.delete('/:id', requireAuth(), async (c) => { + const { purge } = parseDeleteParams(new URL(c.req.url)); + const { record } = await stack + .asActor(c.get('auth')!) + .delete(c.req.param('id'), { purge, ...ifMatchVersion(c) }); if (!record) throw new StackNotFoundError('Record not found'); return c.json(serializeRecord(record)); }); // POST /records/:id/undelete — reverses a soft delete; idempotent app.post('/:id/undelete', knownParams(), requireAuth(), async (c) => { - const id = c.req.param('id'); - const auth = c.get('auth')!; const restored = await stack - .asActor(auth) - .undelete(id, { ifVersion: parseIfMatch(c.req.header('If-Match')) }); + .asActor(c.get('auth')!) + .undelete(c.req.param('id'), ifMatchVersion(c)); return c.json(serializeRecord(restored)); }); // ------------------------------------------------------------------ - // Permissions + // Permissions and associations // ------------------------------------------------------------------ + // Two surfaces, one shape: a POST edits the set with a list of adds and + // removes applied as one write, and a PUT replaces it. An element sent to + // the wrong surface is ScopedStack's 400, so nothing here inspects one. + // See docs/spec/wire-format.md § Permissions and § Associations. + app.get('/:id/permissions', knownParams(), async (c) => { - const id = c.req.param('id'); - const auth = c.get('auth'); - const record = await scopeFor(auth).get(id); + const record = await scopeFor(c.get('auth')).get(c.req.param('id')); if (!record) throw new StackNotFoundError('Record not found'); return c.json({ permissions: record.permissions ?? [] }); }); - // The amending spelling, where the change set's `permissions` key - // replaces the whole set — which is what survives two admins sharing one - // record at once. Both carry the reshare gate rather than the write bit, - // and both are ScopedStack's to apply: it refuses a data kind sent to - // this surface and holds the set to `write` implying `read`, so nothing - // here inspects the body. See docs/spec/access-control.md - // § Record-level permissions. app.post('/:id/permissions', knownParams(), requireAuth(), async (c) => { - const id = c.req.param('id'); - const auth = c.get('auth')!; - const body = await readJson(c); - const updated = await stack.asActor(auth).grantAccess(id, body); + const edits = parseAssociationEditsBody(await readJson(c)); + const updated = await stack + .asActor(c.get('auth')!) + .editAccess(c.req.param('id'), edits, ifMatchVersion(c)); return c.json(serializeRecord(updated)); }); - // POST to a /delete sub-path for the reason the association endpoints - // use one, below. - app.post('/:id/permissions/delete', knownParams(), requireAuth(), async (c) => { - const id = c.req.param('id'); - const auth = c.get('auth')!; - const body = await readJson(c); - const updated = await stack.asActor(auth).revokeAccess(id, body); + app.put('/:id/permissions', knownParams(), requireAuth(), async (c) => { + const permissions = parsePermissionsBody(await readJson(c)); + const updated = await stack + .asActor(c.get('auth')!) + .setAccess(c.req.param('id'), permissions, ifMatchVersion(c)); return c.json(serializeRecord(updated)); }); - // ------------------------------------------------------------------ - // Associations - // ------------------------------------------------------------------ - - app.get('/:id/associations', knownParams('kind', 'label'), async (c) => { - const id = c.req.param('id'); - const auth = c.get('auth'); - const record = await scopeFor(auth).get(id); + app.get('/:id/associations', async (c) => { + const { kind, label } = parseAssociationParams(new URL(c.req.url)); + const record = await scopeFor(c.get('auth')).get(c.req.param('id')); if (!record) throw new StackNotFoundError('Record not found'); - let assocs = record.associations ?? []; - const kind = c.req.query('kind'); - if (kind) assocs = assocs.filter((a) => a.kind === kind); - const label = c.req.query('label'); - if (label) assocs = assocs.filter((a) => a.label === label); - return c.json({ associations: assocs }); + const associations = (record.associations ?? []).filter( + (a) => (kind === undefined || a.kind === kind) && (label === undefined || a.label === label), + ); + return c.json({ associations }); }); - // Neither mutating endpoint reads If-Match, and one sent to either is - // ignored rather than refused: a set add/remove composes whatever the - // write order, so there is no race for a precondition to fence. Both - // answer with a record carrying whatever version it already had. See - // docs/spec/wire-format.md § Associations. app.post('/:id/associations', knownParams(), requireAuth(), async (c) => { - const id = c.req.param('id'); - const auth = c.get('auth')!; - const body = await readJson(c); - if (!body.kind || !body.label) throw new StackBadRequestError('kind and label are required'); - const updated = await stack.asActor(auth).associate(id, body); + const edits = parseAssociationEditsBody(await readJson(c)); + const updated = await stack + .asActor(c.get('auth')!) + .editAssociations(c.req.param('id'), edits, ifMatchVersion(c)); return c.json(serializeRecord(updated)); }); - // POST, not DELETE — a DELETE request body has no defined semantics - // (RFC 9110 §9.3.5) and is a portability landmine for proxies/gateways - // that drop or reject it. - app.post('/:id/associations/delete', knownParams(), requireAuth(), async (c) => { - const id = c.req.param('id'); - const auth = c.get('auth')!; - const body = await readJson(c); - const updated = await stack.asActor(auth).dissociate(id, body); + app.put('/:id/associations', knownParams(), requireAuth(), async (c) => { + const associations = parseAssociationsBody(await readJson(c)); + const updated = await stack + .asActor(c.get('auth')!) + .setAssociations(c.req.param('id'), associations, ifMatchVersion(c)); return c.json(serializeRecord(updated)); }); @@ -238,29 +220,22 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { const id = c.req.param('id'); - const auth = c.get('auth'); const query = parseJournalParams(new URL(c.req.url)); - const limit = clampJournalLimit(query.limit); - - const entries = await scopeFor(auth).getJournal(id, { ...query, limit }); - const body: WireJournalResponse = { - entries: entries.map(serializeJournalEntry), - cursor: - entries.length > 0 && entries.length === limit ? entries[entries.length - 1]!.seq : null, - }; + const scoped = scopeFor(c.get('auth')); + const { rows, cursor } = await readHistoryPage( + query.limit, + (limit) => scoped.getJournal(id, { ...query, limit }), + (entry) => entry.seq, + ); + const body: WireJournalResponse = { entries: rows.map(serializeJournalEntry), cursor }; return c.json(body); }); @@ -268,57 +243,51 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { + // Newest first, paged like the journal. See docs/spec/wire-format.md § Versions. + app.get('/:id/versions', async (c) => { const id = c.req.param('id'); - const auth = c.get('auth'); - const versions = await scopeFor(auth).getVersions(id); - return c.json(versions.map(serializeVersion)); + const query = parseVersionsParams(new URL(c.req.url)); + const scoped = scopeFor(c.get('auth')); + const { rows, cursor } = await readHistoryPage( + query.limit, + (limit) => scoped.getVersions(id, { ...query, limit }), + (version) => version.version, + ); + const body: WireVersionsResponse = { versions: rows.map(serializeVersion), cursor }; + return c.json(body); }); app.get('/:id/versions/:version', knownParams(), async (c) => { - const id = c.req.param('id'); - const vNum = parsePositiveInt(c.req.param('version'), 'version number'); - const auth = c.get('auth'); - const version = await scopeFor(auth).getVersion(id, vNum); + const version = await scopeFor(c.get('auth')).getVersion( + c.req.param('id'), + parseUnsignedInt(c.req.param('version'), 'version number'), + ); if (!version) throw new StackNotFoundError('Version not found'); return c.json(serializeVersion(version)); }); // POST /records/:id/restore/:version — creates new version, does not rewrite history app.post('/:id/restore/:version', knownParams(), requireAuth(), async (c) => { - const id = c.req.param('id'); - const vNum = parsePositiveInt(c.req.param('version'), 'version number'); - const auth = c.get('auth')!; const restored = await stack - .asActor(auth) - .restoreVersion(id, vNum, { ifVersion: parseIfMatch(c.req.header('If-Match')) }); + .asActor(c.get('auth')!) + .restoreVersion( + c.req.param('id'), + parseUnsignedInt(c.req.param('version'), 'version number'), + ifMatchVersion(c), + ); return c.json(serializeRecord(restored)); }); // POST /records/:id/migrate — the only way typeId changes after creation. - // The body carries the full post-migration content, computed client-side - // by the type's owning app and validated against toTypeId's schema by - // ScopedStack.commitMigration(). Owner acting alone only: replacing - // content and typeId wholesale would have to re-derive every gate - // create() and update() apply at both ends. - app.post('/:id/migrate', knownParams(), requireOwner(ownerEntityId), async (c) => { - const id = c.req.param('id'); - const auth = c.get('auth')!; - const body = await readJson>(c, ['toTypeId', 'content']); - if (!body.toTypeId || typeof body.toTypeId !== 'string') - throw new StackBadRequestError('toTypeId is required'); - if (!body.content || typeof body.content !== 'object') - throw new StackBadRequestError('content is required'); - if (!(await stack.getType(body.toTypeId as TypeId))) - throw new StackValidationError([ - { path: 'toTypeId', message: `Unknown type: "${body.toTypeId}"` }, - ]); - + // The body carries the full post-migration content, computed by the + // type's owning app. Who may commit one — the owner, or an installed app + // within the families its install claims — is ScopedStack's to decide. + // See docs/spec/wire-format.md § Migration commit. + app.post('/:id/migrate', knownParams(), requireAuth(), async (c) => { + const { toTypeId, content } = parseMigrationBody(await readJson(c)); const migrated = await stack - .asActor(auth) - .commitMigration(id, body.toTypeId as TypeId, body.content as Record, { - ifVersion: parseIfMatch(c.req.header('If-Match')), - }); + .asActor(c.get('auth')!) + .commitMigration(c.req.param('id'), toTypeId, content, ifMatchVersion(c)); return c.json(serializeRecord(migrated)); }); diff --git a/src/routes/types.ts b/src/routes/types.ts index c973d74..3c0e481 100644 --- a/src/routes/types.ts +++ b/src/routes/types.ts @@ -5,13 +5,8 @@ import type { StackContext } from '../stack.js'; import { requireOwner } from '../middleware/auth.js'; import { readJson } from '../lib/json.js'; import { serializeType } from '@haverstack/wire-types'; -import { - hashSchema, - StackBadRequestError, - StackNotFoundError, - StackValidationError, -} from '@haverstack/core'; -import type { TypeSchema } from '@haverstack/core'; +import { parseTypeBody } from '@haverstack/core/wire'; +import { StackNotFoundError } from '@haverstack/core'; export function typeRoutes(ctx: StackContext): Hono { const app = new Hono(); @@ -29,46 +24,12 @@ export function typeRoutes(ctx: StackContext): Hono { return c.json(serializeType(type)); }); + // Served through the unscoped Stack: ScopedStack has no defineType(), and + // no grant confers defining a stack-wide Type. stack.defineType() rather + // than adapter.saveType() is what runs the schema-drift check. + // See docs/spec/wire-format.md § Types. app.post('/', knownParams(), requireOwner(stack.ownerEntityId), async (c) => { - // The full StackType a client holds is accepted as-is: baseId, version - // and createdAt are derived or stamped here, like a record's stamped - // fields, so sending them back is not a mistake. - const body = await readJson>(c, [ - 'id', - 'baseId', - 'version', - 'name', - 'schema', - 'schemaHash', - 'migratesFrom', - 'createdAt', - ]); - if (!body.id || typeof body.id !== 'string') throw new StackBadRequestError('id is required'); - if (!body.name || typeof body.name !== 'string') - throw new StackBadRequestError('name is required'); - if (!body.schema || typeof body.schema !== 'object') - throw new StackBadRequestError('schema is required'); - if (!body.schemaHash || typeof body.schemaHash !== 'string') - throw new StackBadRequestError('schemaHash is required'); - if (body.migratesFrom !== undefined && typeof body.migratesFrom !== 'string') - throw new StackValidationError([{ path: 'migratesFrom', message: 'Must be a string' }]); - - const computedHash = await hashSchema(body.schema as TypeSchema); - if (body.schemaHash !== computedHash) - throw new StackValidationError([ - { path: 'schemaHash', message: 'schemaHash does not match schema' }, - ]); - - // stack.defineType() (not adapter.saveType() directly) is what runs the - // schema-drift check: redefining an existing typeId with anything - // beyond additive evolution throws StackSchemaDriftError, which - // adapter.saveType() alone has no way to enforce — it's a raw write. - const type = await stack.defineType({ - id: body.id, - name: body.name, - schema: body.schema as TypeSchema, - ...(body.migratesFrom !== undefined && { migratesFrom: body.migratesFrom }), - }); + const type = await stack.defineType(parseTypeBody(await readJson(c))); return c.json(serializeType(type), 201); }); diff --git a/src/routes/wellknown.ts b/src/routes/wellknown.ts index 9d3cf19..eec6b27 100644 --- a/src/routes/wellknown.ts +++ b/src/routes/wellknown.ts @@ -48,6 +48,8 @@ export function wellknownRoutes(ctx: StackContext, config: Config): Hono resume: true, records: true, }, + // POST /installs is always served, so, like `changes`, always advertised. + installs: { requests: true }, }; return c.json(body); }); diff --git a/src/shutdown.ts b/src/shutdown.ts index 27f11a8..a6a6697 100644 --- a/src/shutdown.ts +++ b/src/shutdown.ts @@ -51,6 +51,7 @@ export function createShutdownHandler( await ctx.stack.close(); await ctx.tokens.close(); ctx.nonces.close(); + ctx.installRequests.close(); logger.info('Clean shutdown complete'); }; } diff --git a/src/stack.ts b/src/stack.ts index ba16175..f8aa1cb 100644 --- a/src/stack.ts +++ b/src/stack.ts @@ -3,10 +3,12 @@ import { ARTICLE, BOOKMARK, CONTACT, + IMAGE, NOTE, PAGE, - PHOTO, PLACE, + POST, + SITE, TASK, defineCommonsTypes, } from '@haverstack/commons'; @@ -15,6 +17,7 @@ import type { StackTokenStore } from '@haverstack/core/wire'; import type { Logger } from 'pino'; import type { Config } from './config.js'; import { AuthNonceStore, defaultNonceStorePath } from './lib/nonceStore.js'; +import { InstallRequestStore, defaultInstallRequestStorePath } from './lib/installRequestStore.js'; import { QueryWorkerPool } from './lib/queryWorker/pool.js'; import { ChangeStreamRegistry } from './lib/changeStreams.js'; @@ -28,6 +31,9 @@ export type StackContext = { // DID challenge-response nonces — see AuthNonceStore. Also kept outside // the portable stack file, in its own sibling store beside the tokens. nonces: AuthNonceStore; + // POST /installs requests awaiting the owner — see InstallRequestStore. + // A request is not an approval, so it too stays out of the stack file. + installRequests: InstallRequestStore; // Runs ScopedStack.query() off the request thread with a per-request // deadline. See docs/spec/wire-format.md § Bounding query cost and // src/lib/queryWorker/pool.ts. @@ -38,13 +44,14 @@ export type StackContext = { }; export async function initStack(config: Config, logger: Logger): Promise { - // openOrInitialize() decides between open and create without a TOCTOU - // gap. ownerEntityId goes in as a lazy provider so openOrInitialize()'s own + // `create: 'ifMissing'` decides between open and create without a TOCTOU + // gap. ownerEntityId goes in as a lazy provider so open()'s own // owner-mismatch check, which throws, never runs on the open path — // ENTITY_ID divergence is a warning below, not a failure — and so a // missing ENTITY_ID is an error only when creating a database. - const adapter = await LocalAdapter.openOrInitialize({ + const adapter = await LocalAdapter.open({ path: config.dbPath, + create: 'ifMissing', ownerEntityId: () => { if (!config.entityId) { throw new Error( @@ -73,7 +80,18 @@ export async function initStack(config: Config, logger: Logger): Promise { - const adapter = await LocalAdapter.initialize({ + const adapter = await LocalAdapter.open({ path: dbPath, + create: 'exclusive', ownerEntityId: TEST_ENTITY_ID, ...(opts.timezone !== undefined && { timezone: opts.timezone }), }); const stack = await Stack.open(adapter); const tokens = await NativeTokenStore.open({ path: defaultTokenStorePath(dbPath) }); const nonces = AuthNonceStore.open(defaultNonceStorePath(dbPath)); + const installRequests = InstallRequestStore.open(defaultInstallRequestStorePath(dbPath)); const queryWorker = new QueryWorkerPool({ init: { dbPath }, poolSize: 1, queueLimit: 64, logger, }); - return { adapter, stack, tokens, nonces, queryWorker, changeStreams: new ChangeStreamRegistry() }; + return { + adapter, + stack, + tokens, + nonces, + installRequests, + queryWorker, + changeStreams: new ChangeStreamRegistry(), + }; } export function testConfig(dbPath: string, opts: TestContextOpts = { timezone: 'UTC' }): Config { diff --git a/tests/app.test.ts b/tests/app.test.ts index c3c2f0e..20c16bb 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -38,9 +38,9 @@ describe('request body size limit', () => { }); const id = (created.data as { id: string }).id; - const { status, data } = await req(smallApp, 'PATCH', `/records/${id}`, { + const { status, data } = await req(smallApp, 'PATCH', `/records/${id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { body: 'x'.repeat(6000) } }, + body: { body: 'x'.repeat(6000) }, }); expect(status).toBe(413); expect((data as { error: { code: string } }).error.code).toBe('payload_too_large'); diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index 8a0680d..661c429 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -37,15 +37,17 @@ import { deleteRecordFixtures, deleteRecordSequenceFixtures, undeleteRecordFixtures, - associateFixtures, - dissociateFixtures, - permissionsChangeFixtures, - grantAccessFixtures, - revokeAccessFixtures, + getRecordSequenceFixtures, + editAssociationsFixtures, + setAssociationsFixtures, + editAccessFixtures, + setAccessFixtures, + setParentFixtures, + setUnlistedFixtures, getJournalFixtures, getVersionsFixtures, getVersionFixtures, - getVersionsAfterMutateFixtures, + getVersionsSequenceFixtures, restoreVersionFixtures, commitMigrationFixtures, errorResponseFixtures, @@ -54,17 +56,17 @@ import { authSequenceFixtures, changeFeedFixtures, changeFeedSequenceFixtures, - unlistedChangeFixtures, - parentChangeFixtures, + installRequestFixtures, attachmentDownloadFixtures, attachmentUploadFixtures, AUTH_FIXTURE_DID, AUTH_FIXTURE_NONCE, } from '@haverstack/conformance-fixtures'; import * as conformanceFixtures from '@haverstack/conformance-fixtures'; +import type { ConformanceFixture } from '@haverstack/conformance-fixtures'; import type { WireRecord, WireJournalResponse } from '@haverstack/wire-types'; import { generateId, hashSchema } from '@haverstack/core'; -import type { DataAssociation } from '@haverstack/core'; +import type { AuthorityAssociation } from '@haverstack/core'; import { buildTestApp, req, @@ -73,6 +75,7 @@ import { TEST_ENTITY_ID, logger, type TestApp, + familyOf, } from './setup.js'; import { openChangeFeed, type DecodedFrame } from './changeFeedClient.js'; import { changeRoutes } from '../src/routes/changes.js'; @@ -251,6 +254,16 @@ describe('discovery fixtures', () => { expect(changes!.records).toBe(true); }); + test('discovery-advertises-install-requests', async () => { + const fixture = discoveryFixtures.find( + (f) => f.name === 'discovery-advertises-install-requests', + )!; + handled.add(fixture.name); + const { status, data } = await req(t.app, fixture.method, fixture.path); + expect(status).toBe(fixture.responseStatus); + expect((data as { installs?: unknown }).installs).toEqual(fixture.responseBody!.installs); + }); + test('coverage', () => { assertCoverage( discoveryFixtures.map((f) => f.name), @@ -291,7 +304,7 @@ describe('createRecord fixtures', () => { )!; handled.add(fixture.name); const body = withFreshId(fixture.requestBody as WireRecord & { appId?: string }); - await t.ctx.stack.grantType(body.typeId, { + await t.ctx.stack.grantType(familyOf(body.typeId), { actions: ['create'], grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }); @@ -312,11 +325,11 @@ describe('createRecord fixtures', () => { )!; handled.add(fixture.name); const body = withFreshId(fixture.requestBody as WireRecord & { appId?: string }); - await t.ctx.stack.grantType(body.typeId, { + await t.ctx.stack.grantType(familyOf(body.typeId), { actions: ['create'], grantee: { kind: 'entity', entityId: BLOG_SUBJECT_ID }, }); - await t.ctx.stack.grantType(body.typeId, { + await t.ctx.stack.grantType(familyOf(body.typeId), { actions: ['create'], grantee: { kind: 'entity', entityId: BLOG_APP_ID }, }); @@ -370,12 +383,14 @@ describe('createRecord fixtures', () => { }); const fileId = (uploaded.content as { fileId: string }).fileId; const note = await t.ctx.stack.create(NOTE_TYPE, { title: 'has a cover' }); - await t.ctx.stack.associate(note.id, { kind: 'attachment', label: 'cover', fileId }); - await t.ctx.stack.grantType(NOTE_TYPE, { + await t.ctx.stack.editAssociations(note.id, [ + { op: 'add', association: { kind: 'attachment', label: 'cover', fileId } }, + ]); + await t.ctx.stack.grantType(familyOf(NOTE_TYPE), { actions: ['read-any'], grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }); - await t.ctx.stack.grantType(ATTACHMENT_TYPE, { + await t.ctx.stack.grantType(familyOf(ATTACHMENT_TYPE), { actions: ['create'], grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }); @@ -624,6 +639,101 @@ describe('queryRecords fixtures', () => { ]); }); + /** Defines the fixtures' article and task Types, the shapes their response records carry. */ + async function seedQueryTypes() { + await t.ctx.stack.defineType({ + id: 'com.example/article@1', + name: 'Article', + schema: { title: { kind: 'string' } }, + }); + await t.ctx.stack.defineType({ + id: 'com.example/task@1', + name: 'Task', + schema: { by: { kind: 'string' } }, + }); + await t.ctx.stack.defineType({ + id: 'com.example/task@2', + name: 'Task', + schema: { owner: { kind: 'string' }, rank: { kind: 'number' } }, + }); + } + + /** + * Creates the fixture's response records from the last listed to the first — + * so a query whose order falls back to createdAt, newest first, reads + * them back as listed — and returns both lists. + */ + async function replay(fixture: ConformanceFixture) { + const expected = (fixture.responseBody as { records: WireRecord[] }).records; + for (const record of [...expected].reverse()) { + await t.ctx.stack.create(record.typeId, record.content); + } + const { status, data } = await req(t.app, fixture.method, fixture.path, { + token: TEST_TOKEN, + body: fixture.requestBody, + }); + expect(status).toBe(fixture.responseStatus); + return { expected, actual: (data as { records: WireRecord[] }).records }; + } + + test('query-without-sort-returns-newest-first', async () => { + const fixture = queryRecordsFixtures.find( + (f) => f.name === 'query-without-sort-returns-newest-first', + )!; + await seedQueryTypes(); + const { expected, actual } = await replay(fixture); + expect(actual.map((r) => r.content)).toEqual(expected.map((r) => r.content)); + }); + + test('query-content-sort-defaults-to-ascending', async () => { + const fixture = queryRecordsFixtures.find( + (f) => f.name === 'query-content-sort-defaults-to-ascending', + )!; + await seedQueryTypes(); + const { expected, actual } = await replay(fixture); + expect(actual.map((r) => r.content)).toEqual(expected.map((r) => r.content)); + }); + + test('query-filters-content-by-type', async () => { + const fixture = queryRecordsFixtures.find((f) => f.name === 'query-filters-content-by-type')!; + await seedQueryTypes(); + // Each arm names a field the other version lacks; a record satisfying + // the wrong arm's predicate must not match. + await t.ctx.stack.create('com.example/task@1', { by: 'Bea' }); + await t.ctx.stack.create('com.example/task@2', { owner: 'Bea' }); + const { expected, actual } = await replay(fixture); + expect(actual.map((r) => r.content)).toEqual(expected.map((r) => r.content)); + }); + + test('query-sorts-by-a-content-field-per-type', async () => { + const fixture = queryRecordsFixtures.find( + (f) => f.name === 'query-sorts-by-a-content-field-per-type', + )!; + await seedQueryTypes(); + const { expected, actual } = await replay(fixture); + expect(actual.map((r) => r.content)).toEqual(expected.map((r) => r.content)); + }); + + test('query-by-type-family', async () => { + const fixture = queryRecordsFixtures.find((f) => f.name === 'query-by-type-family')!; + const { status, data } = await req(t.app, fixture.method, fixture.path, { token: TEST_TOKEN }); + expect(status).toBe(fixture.responseStatus); + expect(data).toEqual(fixture.responseBody); + + // Every version of the family, and never one that only shares its prefix. + await t.ctx.stack.defineType({ + id: 'com.example/notebook@1', + name: 'Notebook', + schema: { title: { kind: 'string' } }, + }); + const v1 = await t.ctx.stack.create(NOTE_TYPE, { title: 'one' }); + const v2 = await t.ctx.stack.create(NOTE_TYPE_V2, { title: 'two' }); + await t.ctx.stack.create('com.example/notebook@1', { title: 'not a note' }); + const family = await req(t.app, 'GET', fixture.path, { token: TEST_TOKEN }); + const ids = (family.data as { records: WireRecord[] }).records.map((r) => r.id); + expect(ids.sort()).toEqual([v1.id, v2.id].sort()); + }); + test('coverage', () => { // query-empty-page-with-live-cursor and query-get-records-uses-the-same- // envelope pin narrower edge cases of the same two invariants (a @@ -645,6 +755,11 @@ describe('queryRecords fixtures', () => { 'query-get-sorts-by-a-content-field', 'query-attachment-label-and-file', 'query-references-file', + 'query-without-sort-returns-newest-first', + 'query-content-sort-defaults-to-ascending', + 'query-filters-content-by-type', + 'query-sorts-by-a-content-field-per-type', + 'query-by-type-family', ]), new Set(), ); @@ -652,9 +767,42 @@ describe('queryRecords fixtures', () => { }); // ------------------------------------------------------- -// Records: patchContent +// Records: per-aspect writes // ------------------------------------------------------- +/** + * A fixture's request, aimed at `id` in place of the record its path + * names, with the fixture's own headers and body unless `opts` says + * otherwise. The owner's token unless another is given. + */ +function dispatchAt( + fixture: ConformanceFixture, + id: string, + opts: { token?: string; body?: unknown } = {}, +) { + return req(t.app, fixture.method, fixture.path.replace(/^\/records\/[^/?]+/, `/records/${id}`), { + token: opts.token ?? TEST_TOKEN, + body: 'body' in opts ? opts.body : fixture.requestBody, + ...(fixture.requestHeaders && { headers: fixture.requestHeaders }), + }); +} + +/** The fields of a fixture's record response that a server does not mint. */ +function aspectsOf(body: unknown) { + const { content, version, permissions, associations, parentId, unlistedAt } = body as Record< + string, + unknown + >; + return { + content, + version, + permissions, + associations, + parentId, + unlisted: unlistedAt !== undefined, + }; +} + describe('patchContent fixtures', () => { const handled = new Set(); @@ -667,14 +815,9 @@ describe('patchContent fixtures', () => { title: 'original title', body: 'original body', }); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { - token: TEST_TOKEN, - body: fixture.requestBody, - }); + const { status, data } = await dispatchAt(fixture, record.id); expect(status).toBe(fixture.responseStatus); - const d = data as Record; - expect(d.content).toEqual(fixture.responseBody!.content); - expect(d.version).toBe(fixture.responseBody!.version); + expect(aspectsOf(data)).toEqual(aspectsOf(fixture.responseBody)); }); test('patch-content-null-deletes-a-field', async () => { @@ -682,18 +825,10 @@ describe('patchContent fixtures', () => { (f) => f.name === 'patch-content-null-deletes-a-field', )!; handled.add(fixture.name); - const record = await t.ctx.stack.create(NOTE_TYPE, { - title: 'to be removed', - body: 'kept', - }); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { - token: TEST_TOKEN, - body: fixture.requestBody, - }); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'to be removed', body: 'kept' }); + const { status, data } = await dispatchAt(fixture, record.id); expect(status).toBe(fixture.responseStatus); - const d = data as Record; - expect(d.content).toEqual(fixture.responseBody!.content); - expect(d.version).toBe(fixture.responseBody!.version); + expect(aspectsOf(data)).toEqual(aspectsOf(fixture.responseBody)); }); test('patch-record-restamps-the-actor', async () => { @@ -719,15 +854,12 @@ describe('patchContent fixtures', () => { }, ); const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { - token, - body: fixture.requestBody, - }); + const { status, data } = await dispatchAt(fixture, record.id, { token }); expect(status).toBe(fixture.responseStatus); const d = data as Record; expect(d.content).toEqual(fixture.responseBody!.content); - // Authorship (entityId) is untouched by a non-author write; updatedBy - // moves to the requester who made this edit. + // Authorship is untouched by a non-author write; updatedBy moves to the + // requester who made this edit. expect((d.createdBy as { subjectId: string }).subjectId).toBe(TEST_ENTITY_ID); expect((d.updatedBy as { subjectId: string }).subjectId).toBe(CONTRIBUTOR_ID); }); @@ -741,33 +873,146 @@ describe('patchContent fixtures', () => { }); }); +describe('setParent / setUnlisted fixtures', () => { + const handled = new Set(); + + test('set-parent-moves-a-record-into-a-container', async () => { + const fixture = setParentFixtures.find( + (f) => f.name === 'set-parent-moves-a-record-into-a-container', + )!; + handled.add(fixture.name); + const container = await t.ctx.stack.create(NOTE_TYPE, { title: 'folder' }); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello', body: 'World' }); + const { status, data } = await dispatchAt(fixture, record.id, { + body: { parentId: container.id }, + }); + expect(status).toBe(fixture.responseStatus); + expect(aspectsOf(data)).toEqual({ + ...aspectsOf(fixture.responseBody), + parentId: container.id, + }); + }); + + test('set-parent-null-moves-a-record-to-the-root', async () => { + const fixture = setParentFixtures.find( + (f) => f.name === 'set-parent-null-moves-a-record-to-the-root', + )!; + handled.add(fixture.name); + const container = await t.ctx.stack.create(NOTE_TYPE, { title: 'folder' }); + const record = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'Hello', body: 'World' }, + { parentId: container.id }, + ); + const { status, data } = await dispatchAt(fixture, record.id); + expect(status).toBe(fixture.responseStatus); + expect(aspectsOf(data)).toEqual(aspectsOf(fixture.responseBody)); + }); + + test('set-unlisted-true', async () => { + const fixture = setUnlistedFixtures.find((f) => f.name === 'set-unlisted-true')!; + handled.add(fixture.name); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello', body: 'World' }); + const { status, data } = await dispatchAt(fixture, record.id); + expect(status).toBe(fixture.responseStatus); + expect(aspectsOf(data)).toEqual(aspectsOf(fixture.responseBody)); + // Withheld from enumeration, never from a read by id. + const listed = await req(t.app, 'GET', '/records', { token: TEST_TOKEN }); + expect((listed.data as { records: { id: string }[] }).records.map((r) => r.id)).not.toContain( + record.id, + ); + }); + + test('set-unlisted-false-relists', async () => { + const fixture = setUnlistedFixtures.find((f) => f.name === 'set-unlisted-false-relists')!; + handled.add(fixture.name); + const record = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'Hello', body: 'World' }, + { unlisted: true }, + ); + const { status, data } = await dispatchAt(fixture, record.id); + expect(status).toBe(fixture.responseStatus); + expect(aspectsOf(data)).toEqual(aspectsOf(fixture.responseBody)); + }); + + test('coverage', () => { + assertCoverage( + [...setParentFixtures, ...setUnlistedFixtures].map((f) => f.name), + handled, + new Set(), + ); + }); +}); + // ------------------------------------------------------- -// Records: delete / undelete +// Records: get / delete / undelete // ------------------------------------------------------- +describe('getRecord sequence fixtures', () => { + const handled = new Set(); + + test('get-record-after-soft-delete', async () => { + const sequence = getRecordSequenceFixtures.find( + (f) => f.name === 'get-record-after-soft-delete', + )!; + handled.add(sequence.name); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); + for (const step of sequence.steps) { + const { status } = await dispatchAt(step, record.id); + expect(status).toBe(step.responseStatus); + } + }); + + test('get-record-after-soft-delete-unreadable', async () => { + const sequence = getRecordSequenceFixtures.find( + (f) => f.name === 'get-record-after-soft-delete-unreadable', + )!; + handled.add(sequence.name); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); + await t.ctx.stack.delete(record.id); + const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); + for (const step of sequence.steps) { + const { status } = await dispatchAt(step, record.id, { token }); + expect(status).toBe(step.responseStatus); + } + }); + + test('coverage', () => { + assertCoverage( + getRecordSequenceFixtures.map((f) => f.name), + handled, + new Set(), + ); + }); +}); + describe('deleteRecord fixtures', () => { const handled = new Set(); test('delete-record-soft', async () => { const fixture = deleteRecordFixtures.find((f) => f.name === 'delete-record-soft')!; handled.add(fixture.name); - const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); - const { status } = await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN }); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello', body: 'World' }); + const { status, data } = await dispatchAt(fixture, record.id); expect(status).toBe(fixture.responseStatus); - // Soft-deleted: still fetchable directly, history retained. - const after = await req(t.app, 'GET', `/records/${record.id}`, { token: TEST_TOKEN }); - expect(after.status).toBe(200); + const d = data as WireRecord; + expect(d.version).toBe(fixture.responseBody!.version); + expect(d.deletedAt).toBeDefined(); + // The tombstone projection: content is what a soft delete removes. See + // docs/spec/versioning.md § The tombstone is literal. + expect(d.content).toEqual({}); }); test('delete-record-purge', async () => { const fixture = deleteRecordFixtures.find((f) => f.name === 'delete-record-purge')!; handled.add(fixture.name); const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); - const { status } = await req(t.app, 'DELETE', `/records/${record.id}?purge=true`, { + const { status } = await dispatchAt(fixture, record.id); + expect(status).toBe(fixture.responseStatus); + const after = await req(t.app, 'GET', `/records/${record.id}?includeDeleted=true`, { token: TEST_TOKEN, }); - expect(status).toBe(fixture.responseStatus); - const after = await req(t.app, 'GET', `/records/${record.id}`, { token: TEST_TOKEN }); expect(after.status).toBe(404); }); @@ -805,15 +1050,19 @@ describe('deleteRecord fixtures', () => { ); const [addStep, purgeStep] = sequence.steps; - const added = await req(t.app, addStep!.method, `/records/${record.id}/associations`, { - token: TEST_TOKEN, - body: { kind: 'attachment', label: 'late-arrival', fileId: lateFileId }, + const added = await dispatchAt(addStep!, record.id, { + body: { + changes: [ + { + op: 'add', + association: { kind: 'attachment', label: 'late-arrival', fileId: lateFileId }, + }, + ], + }, }); expect(added.status).toBe(addStep!.responseStatus); - const purged = await req(t.app, purgeStep!.method, `/records/${record.id}?purge=true`, { - token: TEST_TOKEN, - }); + const purged = await dispatchAt(purgeStep!, record.id); expect(purged.status).toBe(purgeStep!.responseStatus); const associations = (purged.data as { associations?: Array<{ label: string }> }).associations; expect(associations?.map((a) => a.label).sort()).toEqual(['cover', 'late-arrival']); @@ -837,18 +1086,14 @@ describe('undeleteRecord fixtures', () => { const fixture = undeleteRecordFixtures.find((f) => f.name === 'undelete-record')!; handled.add(fixture.name); const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }); - await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN }); + await t.ctx.stack.delete(record.id); - const first = await req(t.app, 'POST', fixture.path.replace('1hk153x00001', record.id), { - token: TEST_TOKEN, - }); + const first = await dispatchAt(fixture, record.id); expect(first.status).toBe(fixture.responseStatus); const firstBody = first.data as Record; expect(firstBody.deletedAt).toBeUndefined(); - const second = await req(t.app, 'POST', fixture.path.replace('1hk153x00001', record.id), { - token: TEST_TOKEN, - }); + const second = await dispatchAt(fixture, record.id); expect(second.status).toBe(200); expect((second.data as Record).deletedAt).toBeUndefined(); expect((second.data as Record).content).toEqual(firstBody.content); @@ -867,196 +1112,155 @@ describe('undeleteRecord fixtures', () => { // Associations // ------------------------------------------------------- -describe('associate fixtures', () => { +describe('editAssociations / setAssociations fixtures', () => { const handled = new Set(); - test('associate-tag', async () => { - const fixture = associateFixtures.find((f) => f.name === 'associate-tag')!; - handled.add(fixture.name); - const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); - const { status } = await req(t.app, 'POST', `/records/${record.id}/associations`, { - token: TEST_TOKEN, - body: fixture.requestBody, + /** Uploads real bytes, so an attachment element names a stored file. */ + async function upload() { + const uploaded = await t.ctx.stack.putAttachment(new Uint8Array([1, 2, 3]), { + mimeType: 'image/png', + filename: 'embed.png', }); + return { fileId: (uploaded.content as { fileId: string }).fileId, recordId: uploaded.id }; + } + + test('edit-associations-add-tag', async () => { + const fixture = editAssociationsFixtures.find((f) => f.name === 'edit-associations-add-tag')!; + handled.add(fixture.name); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello', body: 'World' }); + const { status, data } = await dispatchAt(fixture, record.id); expect(status).toBe(fixture.responseStatus); - const { data } = await req(t.app, 'GET', `/records/${record.id}/associations`, { - token: TEST_TOKEN, - }); - expect((data as { associations: unknown[] }).associations).toEqual([fixture.requestBody]); + // Associations bump nothing. + expect(aspectsOf(data)).toEqual(aspectsOf(fixture.responseBody)); }); - test('associate-relationship-external-target', async () => { - const fixture = associateFixtures.find( - (f) => f.name === 'associate-relationship-external-target', + test('edit-associations-add-relationship-external-target', async () => { + const fixture = editAssociationsFixtures.find( + (f) => f.name === 'edit-associations-add-relationship-external-target', )!; handled.add(fixture.name); - const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); - const { status } = await req(t.app, 'POST', `/records/${record.id}/associations`, { - token: TEST_TOKEN, - body: fixture.requestBody, - }); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello', body: 'World' }); + const { status, data } = await dispatchAt(fixture, record.id); expect(status).toBe(fixture.responseStatus); - const { data } = await req(t.app, 'GET', `/records/${record.id}/associations`, { - token: TEST_TOKEN, - }); // The discriminated target travels verbatim — core validates the union, // this server neither flattens nor reshapes it. - expect((data as { associations: unknown[] }).associations).toEqual([fixture.requestBody]); + expect(aspectsOf(data)).toEqual(aspectsOf(fixture.responseBody)); }); - test('associate-attachment-record-id', async () => { - const fixture = associateFixtures.find((f) => f.name === 'associate-attachment-record-id')!; + test('edit-associations-add-attachment-record-id', async () => { + const fixture = editAssociationsFixtures.find( + (f) => f.name === 'edit-associations-add-attachment-record-id', + )!; handled.add(fixture.name); - const body = fixture.requestBody as DataAssociation & { - kind: 'attachment'; - fileId: string; - attachmentRecordId: string; - }; - const uploaded = await t.ctx.stack.putAttachment(new Uint8Array([1, 2, 3]), { - mimeType: 'image/png', - filename: 'embed.png', - }); - const fileId = (uploaded.content as { fileId: string }).fileId; - const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); - const { status } = await req(t.app, 'POST', `/records/${record.id}/associations`, { - token: TEST_TOKEN, - body: { ...body, fileId, attachmentRecordId: uploaded.id }, + const { fileId, recordId } = await upload(); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello', body: 'World' }); + const element = { kind: 'attachment', label: 'embed', fileId, attachmentRecordId: recordId }; + const { status, data } = await dispatchAt(fixture, record.id, { + body: { changes: [{ op: 'add', association: element }] }, }); expect(status).toBe(fixture.responseStatus); - const { data } = await req(t.app, 'GET', `/records/${record.id}/associations`, { - token: TEST_TOKEN, - }); - // attachmentRecordId travels verbatim, in both directions — the pointer - // annotates the reference rather than identifying it. - expect((data as { associations: unknown[] }).associations).toEqual([ - { ...body, fileId, attachmentRecordId: uploaded.id }, - ]); + // attachmentRecordId travels verbatim — the pointer annotates the + // reference rather than identifying it. + expect((data as { associations: unknown[] }).associations).toEqual([element]); }); - test('coverage', () => { - assertCoverage( - associateFixtures.map((f) => f.name), - handled, - new Set(), - ); - }); -}); - -describe('dissociate fixtures', () => { - const handled = new Set(); - - test('dissociate-tag', async () => { - const fixture = dissociateFixtures.find((f) => f.name === 'dissociate-tag')!; + test('edit-associations-remove-attachment-by-identity', async () => { + const fixture = editAssociationsFixtures.find( + (f) => f.name === 'edit-associations-remove-attachment-by-identity', + )!; handled.add(fixture.name); + const { fileId, recordId } = await upload(); + // The stored association carries an attachmentRecordId, but identity + // stays (kind, label, fileId) — removing without the pointer still + // matches it. const record = await t.ctx.stack.create( NOTE_TYPE, - { title: 'x' }, - { associations: [fixture.requestBody as DataAssociation] }, + { title: 'Hello', body: 'World' }, + { + associations: [ + { kind: 'attachment', label: 'embed', fileId, attachmentRecordId: recordId }, + ], + }, ); - const { status } = await req(t.app, 'POST', `/records/${record.id}/associations/delete`, { - token: TEST_TOKEN, - body: fixture.requestBody, + const { status, data } = await dispatchAt(fixture, record.id, { + body: { + changes: [{ op: 'remove', association: { kind: 'attachment', label: 'embed', fileId } }], + }, }); expect(status).toBe(fixture.responseStatus); - const { data } = await req(t.app, 'GET', `/records/${record.id}/associations`, { - token: TEST_TOKEN, - }); - expect((data as { associations: unknown[] }).associations).toEqual([]); + expect(aspectsOf(data)).toEqual(aspectsOf(fixture.responseBody)); }); - test('dissociate-attachment-by-identity', async () => { - const fixture = dissociateFixtures.find((f) => f.name === 'dissociate-attachment-by-identity')!; + test('edit-associations-remove-tag', async () => { + const fixture = editAssociationsFixtures.find( + (f) => f.name === 'edit-associations-remove-tag', + )!; handled.add(fixture.name); - const body = fixture.requestBody as DataAssociation & { kind: 'attachment'; fileId: string }; - const uploaded = await t.ctx.stack.putAttachment(new Uint8Array([1, 2, 3]), { - mimeType: 'image/png', - filename: 'embed.png', - }); - const fileId = (uploaded.content as { fileId: string }).fileId; - // The stored association carries an attachmentRecordId, but identity - // stays (kind, label, fileId) — dissociating without the pointer still - // matches and removes it. const record = await t.ctx.stack.create( NOTE_TYPE, - { title: 'x' }, - { associations: [{ ...body, fileId, attachmentRecordId: uploaded.id }] }, + { title: 'Hello', body: 'World' }, + { associations: [{ kind: 'tag', label: 'starred' }] }, ); - const { status } = await req(t.app, 'POST', `/records/${record.id}/associations/delete`, { - token: TEST_TOKEN, - body: { ...body, fileId }, - }); + const { status, data } = await dispatchAt(fixture, record.id); expect(status).toBe(fixture.responseStatus); - const { data } = await req(t.app, 'GET', `/records/${record.id}/associations`, { - token: TEST_TOKEN, - }); - expect((data as { associations: unknown[] }).associations).toEqual([]); + expect(aspectsOf(data)).toEqual(aspectsOf(fixture.responseBody)); }); - test('coverage', () => { - assertCoverage( - dissociateFixtures.map((f) => f.name), - handled, - new Set(), + test('edit-associations-swap-in-one-request', async () => { + const fixture = editAssociationsFixtures.find( + (f) => f.name === 'edit-associations-swap-in-one-request', + )!; + handled.add(fixture.name); + const record = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'Hello', body: 'World' }, + { associations: [{ kind: 'tag', label: 'draft' }] }, ); + const { status, data } = await dispatchAt(fixture, record.id); + expect(status).toBe(fixture.responseStatus); + expect(aspectsOf(data)).toEqual(aspectsOf(fixture.responseBody)); + // One write, so one journal entry for the swap. + const journal = await t.ctx.stack.getJournal(record.id); + expect(journal.at(-1)!.associations).toHaveLength(2); }); -}); - -// ------------------------------------------------------- -// Permissions -// ------------------------------------------------------- -describe('permissions change fixtures', () => { - const handled = new Set(); - - test('set-permissions-anyone', async () => { - const fixture = permissionsChangeFixtures.find((f) => f.name === 'set-permissions-anyone')!; + test('set-associations-replaces-the-data-set', async () => { + const fixture = setAssociationsFixtures.find( + (f) => f.name === 'set-associations-replaces-the-data-set', + )!; handled.add(fixture.name); const record = await t.ctx.stack.create( NOTE_TYPE, - { title: 'x' }, - { associations: [{ kind: 'tag', label: 'draft' }] }, + { title: 'Hello', body: 'World' }, + { + associations: [{ kind: 'tag', label: 'draft' }], + permissions: [{ kind: 'anyone', label: 'read' }], + }, ); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { - token: TEST_TOKEN, - body: fixture.requestBody, - }); + const { status, data } = await dispatchAt(fixture, record.id); expect(status).toBe(fixture.responseStatus); - const body = data as { permissions?: unknown[]; associations?: unknown[]; version: number }; - expect(body.permissions).toEqual(fixture.responseBody!.permissions); - // A separate domain: the key replaces the ACL and leaves the tags where - // they are, and the write bumps nothing. - expect(body.associations).toEqual(fixture.responseBody!.associations); - expect(body.version).toBe(record.version); - - const anon = await req(t.app, 'GET', `/records/${record.id}`); - expect(anon.status).toBe(200); + // The data set is replaced; the permission set is another aspect. + expect(aspectsOf(data)).toEqual(aspectsOf(fixture.responseBody)); }); - test('set-permissions-empty-is-private', async () => { - const fixture = permissionsChangeFixtures.find( - (f) => f.name === 'set-permissions-empty-is-private', + test('set-associations-if-match-current', async () => { + const fixture = setAssociationsFixtures.find( + (f) => f.name === 'set-associations-if-match-current', )!; handled.add(fixture.name); const record = await t.ctx.stack.create( NOTE_TYPE, - { title: 'x' }, - { permissions: [{ kind: 'anyone', label: 'read' }] }, + { title: 'Hello', body: 'World' }, + { associations: [{ kind: 'tag', label: 'draft' }] }, ); - const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { - token: TEST_TOKEN, - body: fixture.requestBody, - }); + const { status, data } = await dispatchAt(fixture, record.id); expect(status).toBe(fixture.responseStatus); - // Anonymous can't tell "made private" from "never existed" — the - // anti-oracle rule, so 404 + WWW-Authenticate, not 403. - const anon = await t.app.request(`/records/${record.id}`); - expect(anon.status).toBe(404); - expect(anon.headers.get('WWW-Authenticate')).toBe('Bearer'); + expect(aspectsOf(data)).toEqual(aspectsOf(fixture.responseBody)); }); test('coverage', () => { assertCoverage( - permissionsChangeFixtures.map((f) => f.name), + [...editAssociationsFixtures, ...setAssociationsFixtures].map((f) => f.name), handled, new Set(), ); @@ -1064,50 +1268,38 @@ describe('permissions change fixtures', () => { }); // ------------------------------------------------------- -// Record-level grant/revoke — the amending spelling of the permissions -// key, carrying the reshare gate rather than the write bit. +// Permissions — the same two spellings as associations, carrying the +// reshare gate rather than the write bit. // ------------------------------------------------------- -describe('grantAccess / revokeAccess fixtures', () => { +describe('editAccess / setAccess fixtures', () => { const handled = new Set(); + const MEMBER_ID = 'did:key:z6MkMember'; - test('grant-access-entity', async () => { - const fixture = grantAccessFixtures.find((f) => f.name === 'grant-access-entity')!; + test('edit-permissions-add-entity', async () => { + const fixture = editAccessFixtures.find((f) => f.name === 'edit-permissions-add-entity')!; handled.add(fixture.name); - const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }); - const element = { - ...fixture.requestBody, - grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, - }; - - const { status, data } = await req(t.app, 'POST', `/records/${record.id}/permissions`, { - token: TEST_TOKEN, - body: element, - }); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello', body: 'World' }); + const { status, data } = await dispatchAt(fixture, record.id); expect(status).toBe(fixture.responseStatus); - const body = data as { permissions?: unknown[]; version: number }; - expect(body.permissions).toEqual([element]); - // A permission element is an association: no bump, no snapshot. - expect(body.version).toBe(record.version); - - const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); + expect(aspectsOf(data)).toEqual(aspectsOf(fixture.responseBody)); + const { token } = await t.ctx.adapter.createToken({ subjectId: MEMBER_ID }); const read = await req(t.app, 'GET', `/records/${record.id}`, { token }); expect(read.status).toBe(200); }); - test('grant-access-group-role', async () => { - const fixture = grantAccessFixtures.find((f) => f.name === 'grant-access-group-role')!; + test('edit-permissions-add-group-role', async () => { + const fixture = editAccessFixtures.find((f) => f.name === 'edit-permissions-add-group-role')!; handled.add(fixture.name); const group = await t.ctx.stack.create(GROUP_TYPE, { name: 'Editors' }); - const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello', body: 'World' }); const element = { - ...fixture.requestBody, + kind: 'permission', + label: 'read', grantee: { kind: 'group', groupId: group.id, role: 'admin' }, }; - - const { status, data } = await req(t.app, 'POST', `/records/${record.id}/permissions`, { - token: TEST_TOKEN, - body: element, + const { status, data } = await dispatchAt(fixture, record.id, { + body: { changes: [{ op: 'add', association: element }] }, }); expect(status).toBe(fixture.responseStatus); // The role travels verbatim — there is no absent-role spelling for a @@ -1115,74 +1307,94 @@ describe('grantAccess / revokeAccess fixtures', () => { expect((data as { permissions?: unknown[] }).permissions).toEqual([element]); }); - test('the endpoints amend the set where the change-set key replaces it', async () => { - const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }); - await req(t.app, 'POST', `/records/${record.id}/permissions`, { - token: TEST_TOKEN, - body: { - kind: 'permission', - label: 'read', - grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, - }, - }); - const { data } = await req(t.app, 'POST', `/records/${record.id}/permissions`, { - token: TEST_TOKEN, - body: { kind: 'anyone', label: 'read' }, - }); - expect((data as { permissions?: unknown[] }).permissions).toHaveLength(2); + test('edit-permissions-grant-read-and-write-together', async () => { + const fixture = editAccessFixtures.find( + (f) => f.name === 'edit-permissions-grant-read-and-write-together', + )!; + handled.add(fixture.name); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello', body: 'World' }); + const { status, data } = await dispatchAt(fixture, record.id); + expect(status).toBe(fixture.responseStatus); + expect(aspectsOf(data)).toEqual(aspectsOf(fixture.responseBody)); }); - test('revoke-access-anyone', async () => { - const fixture = revokeAccessFixtures.find((f) => f.name === 'revoke-access-anyone')!; + test('edit-permissions-remove-anyone', async () => { + const fixture = editAccessFixtures.find((f) => f.name === 'edit-permissions-remove-anyone')!; handled.add(fixture.name); const record = await t.ctx.stack.create( NOTE_TYPE, - { title: 'Hello' }, + { title: 'Hello', body: 'World' }, { permissions: [{ kind: 'anyone', label: 'read' }] }, ); + const { status, data } = await dispatchAt(fixture, record.id); + expect(status).toBe(fixture.responseStatus); + expect(aspectsOf(data)).toEqual(aspectsOf(fixture.responseBody)); + }); - const { status, data } = await req(t.app, 'POST', `/records/${record.id}/permissions/delete`, { - token: TEST_TOKEN, - body: fixture.requestBody, - }); + test('set-permissions-anyone', async () => { + const fixture = setAccessFixtures.find((f) => f.name === 'set-permissions-anyone')!; + handled.add(fixture.name); + const record = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'Hello', body: 'World' }, + { associations: [{ kind: 'tag', label: 'draft' }] }, + ); + const { status, data } = await dispatchAt(fixture, record.id); + expect(status).toBe(fixture.responseStatus); + // A separate aspect: the set is replaced and the tags stay where they are. + expect(aspectsOf(data)).toEqual(aspectsOf(fixture.responseBody)); + const anon = await req(t.app, 'GET', `/records/${record.id}`); + expect(anon.status).toBe(200); + }); + + test('set-permissions-empty-is-private', async () => { + const fixture = setAccessFixtures.find((f) => f.name === 'set-permissions-empty-is-private')!; + handled.add(fixture.name); + const record = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'Hello', body: 'World' }, + { + associations: [{ kind: 'tag', label: 'draft' }], + permissions: [{ kind: 'anyone', label: 'read' }], + }, + ); + const { status, data } = await dispatchAt(fixture, record.id); expect(status).toBe(fixture.responseStatus); - const body = data as { permissions?: unknown[]; version: number }; - // Reach to the world is its own kind, so withdrawing it names that kind - // and leaves the set empty rather than clearing a field. - expect(body.permissions ?? []).toEqual([]); - expect(body.version).toBe(record.version); + expect(aspectsOf(data)).toEqual(aspectsOf(fixture.responseBody)); + // Anonymous can't tell "made private" from "never existed" — the + // anti-oracle rule, so 404 + WWW-Authenticate, not 403. + const anon = await t.app.request(`/records/${record.id}`); + expect(anon.status).toBe(404); + expect(anon.headers.get('WWW-Authenticate')).toBe('Bearer'); }); - test('carries the reshare gate, not the write bit', async () => { + test('both spellings carry the reshare gate, not the write bit', async () => { const record = await t.ctx.stack.create( NOTE_TYPE, { title: 'Hello' }, { permissions: [ - { - kind: 'permission', - label: 'read', - grantee: { kind: 'entity', entityId: WRITER_ID }, - }, - { - kind: 'permission', - label: 'write', - grantee: { kind: 'entity', entityId: WRITER_ID }, - }, + { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: WRITER_ID } }, + { kind: 'permission', label: 'write', grantee: { kind: 'entity', entityId: WRITER_ID } }, ], }, ); const { token } = await t.ctx.adapter.createToken({ subjectId: WRITER_ID }); - const { status } = await req(t.app, 'POST', `/records/${record.id}/permissions`, { + const edit = await req(t.app, 'POST', `/records/${record.id}/permissions`, { token, - body: { kind: 'anyone', label: 'read' }, + body: { changes: [{ op: 'add', association: { kind: 'anyone', label: 'read' } }] }, }); - expect(status).toBe(403); + expect(edit.status).toBe(403); + const set = await req(t.app, 'PUT', `/records/${record.id}/permissions`, { + token, + body: { permissions: [{ kind: 'anyone', label: 'read' }] }, + }); + expect(set.status).toBe(403); }); test('coverage', () => { assertCoverage( - [...grantAccessFixtures.map((f) => f.name), ...revokeAccessFixtures.map((f) => f.name)], + [...editAccessFixtures, ...setAccessFixtures].map((f) => f.name), handled, new Set(), ); @@ -1205,7 +1417,7 @@ describe('journal fixtures', () => { const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }); await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, - body: { kind: 'tag', label: 'starred' }, + body: { changes: [{ op: 'add', association: { kind: 'tag', label: 'starred' } }] }, }); const { status, data } = await req(t.app, 'GET', `/records/${record.id}/journal`, { @@ -1232,7 +1444,7 @@ describe('journal fixtures', () => { const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }); await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, - body: { kind: 'tag', label: 'starred' }, + body: { changes: [{ op: 'add', association: { kind: 'tag', label: 'starred' } }] }, }); const page = await req(t.app, 'GET', `/records/${record.id}/journal?afterSeq=0&limit=1`, { @@ -1277,11 +1489,15 @@ describe('journal fixtures', () => { const association = { kind: 'attachment', label: 'embed', fileId }; await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, - body: { ...association, attachmentRecordId: first.id }, + body: { + changes: [{ op: 'add', association: { ...association, attachmentRecordId: first.id } }], + }, }); await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, - body: { ...association, attachmentRecordId: second.id }, + body: { + changes: [{ op: 'add', association: { ...association, attachmentRecordId: second.id } }], + }, }); const { status, data } = await req(t.app, 'GET', `/records/${record.id}/journal?afterSeq=2`, { @@ -1318,11 +1534,13 @@ describe('journal fixtures', () => { const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }); await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, - body: { ...association, attachmentRecordId: uploaded.id }, + body: { + changes: [{ op: 'add', association: { ...association, attachmentRecordId: uploaded.id } }], + }, }); - await req(t.app, 'POST', `/records/${record.id}/associations/delete`, { + await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, - body: association, + body: { changes: [{ op: 'remove', association }] }, }); const { status, data } = await req(t.app, 'GET', `/records/${record.id}/journal?afterSeq=2`, { @@ -1331,10 +1549,10 @@ describe('journal fixtures', () => { expect(status).toBe(fixture.responseStatus); const entry = (data as WireJournalResponse).entries[0]!; expect(entry.ops).toEqual(['dissociate']); - // `previous` is the association in full, annotation included — where - // the change frame for the same write names identity only. + // The removed association in full, annotation included — where the + // change frame for the same write names identity only. expect(entry.associations).toEqual([ - { op: 'remove', previous: { ...association, attachmentRecordId: uploaded.id } }, + { op: 'remove', association: { ...association, attachmentRecordId: uploaded.id } }, ]); }); @@ -1345,7 +1563,7 @@ describe('journal fixtures', () => { handled.add(fixture.name); const container = await t.ctx.stack.create(NOTE_TYPE, { title: 'Container' }); const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }); - await req(t.app, 'PATCH', `/records/${record.id}`, { + await req(t.app, 'PUT', `/records/${record.id}/parent`, { token: TEST_TOKEN, body: { parentId: container.id }, }); @@ -1385,7 +1603,7 @@ describe('journal fixtures', () => { ); await req(t.app, 'POST', `/records/${record.id}/permissions`, { token: TEST_TOKEN, - body: { kind: 'anyone', label: 'read' }, + body: { changes: [{ op: 'add', association: { kind: 'anyone', label: 'read' } }] }, }); const { token } = await t.ctx.adapter.createToken({ subjectId: WRITER_ID }); @@ -1428,6 +1646,16 @@ describe('journal fixtures', () => { describe('version lifecycle fixtures', () => { const handled = new Set(); + type VersionsPage = { versions: Array>; cursor: number | null }; + + /** A record at version 3 holding snapshots of versions 1 and 2, as the fixtures assume. */ + async function recordAtVersion3(opts: { permissions?: AuthorityAssociation[] } = {}) { + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'original title' }, opts); + await t.ctx.stack.patchContent(record.id, { title: 'intermediate title' }); + await t.ctx.stack.patchContent(record.id, { title: 'title before restore' }); + return record; + } + test('every requester past the mutate gate sees the same snapshot rows', async () => { const ownerFixture = getVersionsFixtures.find((f) => f.name === 'get-versions-owner')!; const writerFixture = getVersionsFixtures.find( @@ -1453,119 +1681,85 @@ describe('version lifecycle fixtures', () => { // it once to give version 1 a snapshot to appear in. await t.ctx.stack.patchContent(record.id, { title: 'updated title' }); - const owner = await req(t.app, 'GET', `/records/${record.id}/versions`, { token: TEST_TOKEN }); + const owner = await dispatchAt(ownerFixture, record.id); expect(owner.status).toBe(ownerFixture.responseStatus); - expect((owner.data as unknown[]).length).toBe(1); + const ownerPage = owner.data as VersionsPage; + expect(ownerPage.versions.map((v) => v.version)).toEqual([1]); + expect(ownerPage.cursor).toBeNull(); const { token: writerToken } = await t.ctx.adapter.createToken({ subjectId: WRITER_ID }); - const writer = await req(t.app, 'GET', `/records/${record.id}/versions`, { - token: writerToken, - }); + const writer = await dispatchAt(writerFixture, record.id, { token: writerToken }); expect(writer.status).toBe(writerFixture.responseStatus); expect(writer.data).toEqual(owner.data); - const single = await req(t.app, 'GET', `/records/${record.id}/versions/1`, { - token: writerToken, - }); + const single = await dispatchAt(singleFixture, record.id, { token: writerToken }); expect(single.status).toBe(singleFixture.responseStatus); - expect(single.data).toEqual((owner.data as unknown[])[0]); + expect(single.data).toEqual(ownerPage.versions[0]); }); - test('restore auto-snapshots the pre-restore state; migrate auto-snapshots the pre-migration state', async () => { - const restoreFixture = restoreVersionFixtures.find((f) => f.name === 'restore-version')!; - const migrateFixture = commitMigrationFixtures.find((f) => f.name === 'commit-migration')!; - const afterRestoreFixture = getVersionsAfterMutateFixtures.find( - (f) => f.name === 'get-versions-after-restore-includes-pre-restore-snapshot', + test('a page carries the cursor to resume from, and the last page a null one', async () => { + const pageFixture = getVersionsFixtures.find( + (f) => f.name === 'get-versions-page-reports-a-cursor-to-resume-from', )!; - const afterMigrateFixture = getVersionsAfterMutateFixtures.find( - (f) => f.name === 'get-versions-after-migrate-includes-pre-migration-snapshot', + const lastFixture = getVersionsFixtures.find( + (f) => f.name === 'get-versions-before-version-ends-with-a-null-cursor', )!; - handled.add(restoreFixture.name); - handled.add(migrateFixture.name); - handled.add(afterRestoreFixture.name); - handled.add(afterMigrateFixture.name); - - // v1 - const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'original title' }); - // v2 — an intermediate edit, so v3 (below) isn't restore-version's own - // pre-restore auto-snapshot colliding with the create itself. - await req(t.app, 'PATCH', `/records/${record.id}`, { - token: TEST_TOKEN, - body: { contentPatch: { title: 'intermediate title' } }, - }); - // v3 — the state restore-version will move away from - await req(t.app, 'PATCH', `/records/${record.id}`, { - token: TEST_TOKEN, - body: { contentPatch: { title: 'title before restore' } }, - }); - - // v3 (auto pre-restore snapshot of v2) + v4 (restored content, from v1) - const restore = await req(t.app, 'POST', `/records/${record.id}/restore/1`, { - token: TEST_TOKEN, - }); - expect(restore.status).toBe(restoreFixture.responseStatus); - const restored = restore.data as Record; - expect(restored.content).toEqual(restoreFixture.responseBody!.content); - expect(restored.version).toBe(4); + handled.add(pageFixture.name); + handled.add(lastFixture.name); + const record = await recordAtVersion3(); - // v5 (auto pre-migration snapshot of v4 is v4 itself) + new typeId/content - const migrateBody = migrateFixture.requestBody!; - const migrate = await req(t.app, 'POST', `/records/${record.id}/migrate`, { + const first = await req(t.app, 'GET', `/records/${record.id}/versions?limit=1`, { token: TEST_TOKEN, - body: migrateBody, }); - expect(migrate.status).toBe(migrateFixture.responseStatus); - const migrated = migrate.data as Record; - expect(migrated.typeId).toBe(migrateBody.toTypeId); - expect(migrated.content).toEqual(migrateBody.content); - expect(migrated.version).toBe(5); + expect(first.status).toBe(pageFixture.responseStatus); + const firstPage = first.data as VersionsPage; + expect(firstPage.versions.map((v) => v.version)).toEqual([2]); + expect(firstPage.cursor).toBe(2); - const versions = await req(t.app, 'GET', `/records/${record.id}/versions`, { - token: TEST_TOKEN, - }); - expect(versions.status).toBe(afterMigrateFixture.responseStatus); - const versionList = versions.data as Array<{ version: number; typeId: string }>; - const numbers = versionList.map((v) => v.version); - // v4 (pre-migration snapshot), v3 (pre-restore snapshot), v1 (original) — - // matches afterMigrateFixture/afterRestoreFixture's own version lists. - expect(numbers).toEqual( - expect.arrayContaining(afterMigrateFixture.responseBody!.map((v) => v.version)), - ); - expect(numbers).toEqual( - expect.arrayContaining(afterRestoreFixture.responseBody!.map((v) => v.version)), + const last = await req( + t.app, + 'GET', + `/records/${record.id}/versions?beforeVersion=${firstPage.cursor}&limit=1`, + { token: TEST_TOKEN }, ); - const v4Snapshot = versionList.find((v) => v.version === 4)!; - expect(v4Snapshot.typeId).toBe(NOTE_TYPE); + expect(last.status).toBe(lastFixture.responseStatus); + const lastPage = last.data as VersionsPage; + expect(lastPage.versions.map((v) => v.version)).toEqual([1]); + // Exactly as many rows as the limit, yet nothing older: the cursor says so. + expect(lastPage.cursor).toBeNull(); }); - test('get-versions-after-associate-is-unchanged', async () => { - const fixture = getVersionsAfterMutateFixtures.find( - (f) => f.name === 'get-versions-after-associate-is-unchanged', + test('version-history-grows-on-restore-and-migrate-but-not-associate', async () => { + const sequence = getVersionsSequenceFixtures.find( + (f) => f.name === 'version-history-grows-on-restore-and-migrate-but-not-associate', )!; - handled.add(fixture.name); - const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'original title' }); - await req(t.app, 'PATCH', `/records/${record.id}`, { - token: TEST_TOKEN, - body: { contentPatch: { title: 'updated title' } }, - }); - const before = await req(t.app, 'GET', `/records/${record.id}/versions`, { - token: TEST_TOKEN, - }); - expect(before.status).toBe(fixture.responseStatus); - - await req(t.app, 'POST', `/records/${record.id}/associations`, { - token: TEST_TOKEN, - body: { kind: 'tag', label: 'starred' }, - }); + const restoreFixture = restoreVersionFixtures.find((f) => f.name === 'restore-version')!; + const migrateFixture = commitMigrationFixtures.find((f) => f.name === 'commit-migration')!; + handled.add(sequence.name); + handled.add(restoreFixture.name); + handled.add(migrateFixture.name); + const record = await recordAtVersion3(); - const after = await req(t.app, 'GET', `/records/${record.id}/versions`, { token: TEST_TOKEN }); - // Exactly the list it answered with before: no new entry, and no entry - // gains an associations key — a server that snapshots here hands every - // later restore a stale association set to put back. - expect(after.data).toEqual(before.data); - expect( - (after.data as Array>).every((v) => v.associations === undefined), - ).toBe(true); + for (const step of sequence.steps) { + const { status, data } = await dispatchAt(step, record.id); + expect(status).toBe(step.responseStatus); + if (step.method === 'GET') { + const expected = (step.responseBody as VersionsPage).versions.map((v) => v.version); + const page = data as VersionsPage; + expect(page.versions.map((v) => v.version)).toEqual(expected); + // No snapshot carries an aspect that bumps no version. + expect(page.versions.every((v) => v.associations === undefined)).toBe(true); + } + if (step.path.endsWith('/restore/1')) { + expect(aspectsOf(data)).toEqual(aspectsOf(restoreFixture.responseBody)); + } + if (step.path.endsWith('/migrate')) { + const migrated = data as Record; + expect(migrated.typeId).toBe(migrateFixture.responseBody!.typeId); + expect(migrated.content).toEqual(migrateFixture.responseBody!.content); + expect(migrated.version).toBe(migrateFixture.responseBody!.version); + } + } }); test('a snapshot names no container, and a restore leaves the record where it sits', async () => { @@ -1582,31 +1776,20 @@ describe('version lifecycle fixtures', () => { // v1, at the root const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'original title' }); // v2 — the state the restore below returns to, still at the root - await req(t.app, 'PATCH', `/records/${record.id}`, { - token: TEST_TOKEN, - body: { contentPatch: { title: 'title before restore' } }, - }); + await t.ctx.stack.patchContent(record.id, { title: 'title before restore' }); // Aspects that take no snapshot of their own, set before the bump that // snapshots v2 — so a snapshot capturing any of them would show them. - await req(t.app, 'POST', `/records/${record.id}/permissions`, { - token: TEST_TOKEN, - body: { kind: 'anyone', label: 'read' }, - }); + await t.ctx.stack.editAccess(record.id, [ + { op: 'add', association: { kind: 'anyone', label: 'read' } }, + ]); + await t.ctx.stack.setUnlisted(record.id, true); // v3 — the content bump that writes v2's snapshot - await req(t.app, 'PATCH', `/records/${record.id}`, { - token: TEST_TOKEN, - body: { contentPatch: { title: 'current title' }, unlisted: true }, - }); + await t.ctx.stack.patchContent(record.id, { title: 'current title' }); // A move bumps nothing, and is what leaves the record somewhere other // than where v2 was taken. - await req(t.app, 'PATCH', `/records/${record.id}`, { - token: TEST_TOKEN, - body: { parentId: container.id }, - }); + await t.ctx.stack.setParent(record.id, container.id); - const snapshot = await req(t.app, 'GET', `/records/${record.id}/versions/2`, { - token: TEST_TOKEN, - }); + const snapshot = await dispatchAt(snapshotFixture, record.id); expect(snapshot.status).toBe(snapshotFixture.responseStatus); const v2 = snapshot.data as Record; expect(v2.content).toEqual(snapshotFixture.responseBody!.content); @@ -1616,9 +1799,7 @@ describe('version lifecycle fixtures', () => { expect(v2.permissions).toBeUndefined(); expect(v2.associations).toBeUndefined(); - const restore = await req(t.app, 'POST', `/records/${record.id}/restore/2`, { - token: TEST_TOKEN, - }); + const restore = await dispatchAt(restoreFixture, record.id); expect(restore.status).toBe(restoreFixture.responseStatus); const restored = restore.data as { parentId?: string; content: unknown; version: number }; expect(restored.content).toEqual(restoreFixture.responseBody!.content); @@ -1632,7 +1813,7 @@ describe('version lifecycle fixtures', () => { const names = [ ...getVersionsFixtures.map((f) => f.name), ...getVersionFixtures.map((f) => f.name), - ...getVersionsAfterMutateFixtures.map((f) => f.name), + ...getVersionsSequenceFixtures.map((f) => f.name), ...restoreVersionFixtures.map((f) => f.name), ...commitMigrationFixtures.map((f) => f.name), ]; @@ -1640,6 +1821,93 @@ describe('version lifecycle fixtures', () => { }); }); +// ------------------------------------------------------- +// Installs +// ------------------------------------------------------- + +describe('installRequest fixtures', () => { + const handled = new Set(); + const APP_KEY = 'did:key:z6Mkfsz9oK6i2355mvEwtDYdAmqCN6kmQETThJtARfj9iGum'; + + function fixture(name: string) { + const found = installRequestFixtures.find((f) => f.name === name)!; + handled.add(found.name); + return found; + } + + /** An error's code and details are the contract; its message is for humans. */ + function expectRefusal(data: unknown, expected: unknown) { + const { code, details } = (expected as { error: { code: string; details?: unknown } }).error; + const actual = (data as { error: { code: string; details?: unknown } }).error; + expect(actual.code).toBe(code); + expect(actual.details).toEqual(details); + } + + test('install-request-pending', async () => { + const f = fixture('install-request-pending'); + const { token } = await t.ctx.adapter.createToken({ subjectId: APP_KEY }); + for (let i = 0; i < 2; i++) { + const { status, data } = await req(t.app, f.method, f.path, { token, body: f.requestBody }); + expect(status).toBe(f.responseStatus); + expect(data).toEqual(f.responseBody); + } + // A request is not an approval: nothing landed in the stack. + expect(await t.ctx.stack.getType('com.example.notes/note@1')).toBeNull(); + }); + + test('install-request-already-installed', async () => { + const f = fixture('install-request-already-installed'); + const manifest = f.requestBody!.manifest; + await t.ctx.stack.installApp(await t.ctx.stack.planInstall(manifest, { did: APP_KEY })); + const { token } = await t.ctx.adapter.createToken({ subjectId: APP_KEY }); + const { status, data } = await req(t.app, f.method, f.path, { token, body: f.requestBody }); + expect(status).toBe(f.responseStatus); + const body = data as { status: string; install: WireRecord }; + expect(body.status).toBe('installed'); + expect(body.install.typeId).toBe('_install@1'); + expect(body.install.content).toEqual((f.responseBody as typeof body).install.content); + // The key reads its own install. + const read = await req(t.app, 'GET', `/records/${body.install.id}`, { token }); + expect(read.status).toBe(200); + }); + + test('install-request-type-outside-namespace', async () => { + const f = fixture('install-request-type-outside-namespace'); + const { token } = await t.ctx.adapter.createToken({ subjectId: APP_KEY }); + const { status, data } = await req(t.app, f.method, f.path, { token, body: f.requestBody }); + expect(status).toBe(f.responseStatus); + expectRefusal(data, f.responseBody); + }); + + test('install-request-key-registered-to-another-app', async () => { + const f = fixture('install-request-key-registered-to-another-app'); + await t.ctx.stack.create(APP_TYPE, { appId: 'com.example.other', name: 'Other', did: APP_KEY }); + const { token } = await t.ctx.adapter.createToken({ subjectId: APP_KEY }); + const { status, data } = await req(t.app, f.method, f.path, { token, body: f.requestBody }); + expect(status).toBe(f.responseStatus); + expectRefusal(data, f.responseBody); + }); + + test('install-request-delegated-session', async () => { + const f = fixture('install-request-delegated-session'); + const { token } = await t.ctx.adapter.createToken({ + subjectId: TEST_ENTITY_ID, + principalId: APP_KEY, + }); + const { status, data } = await req(t.app, f.method, f.path, { token, body: f.requestBody }); + expect(status).toBe(f.responseStatus); + expectRefusal(data, f.responseBody); + }); + + test('coverage', () => { + assertCoverage( + installRequestFixtures.map((f) => f.name), + handled, + new Set(), + ); + }); +}); + // ------------------------------------------------------- // Error responses // ------------------------------------------------------- @@ -1655,20 +1923,43 @@ describe('error response fixtures', () => { return fixture; } + /** The fixture's request, aimed at `recordId` in place of the record its path names. */ async function dispatch( fixture: (typeof errorResponseFixtures)[number], token: string | undefined, - pathOverride?: string, + recordId?: string, bodyOverride?: unknown, ) { const body = bodyOverride !== undefined ? bodyOverride : fixture.requestBody; - return req(t.app, fixture.method, pathOverride ?? fixture.path, { + const path = + recordId === undefined + ? fixture.path + : fixture.path.replace(/^\/records\/[^/?]+/, `/records/${recordId}`); + return req(t.app, fixture.method, path, { token, ...(body !== undefined && { body }), ...(fixture.requestHeaders && { headers: fixture.requestHeaders }), }); } + /** + * Writes a _grant@1 Record straight to storage, past every write-time + * check — how a grant from an import or a foreign server arrives — so a + * test can pin what evaluation makes of one. + */ + async function storeGrant(content: Record) { + const now = new Date(); + await t.ctx.adapter.createRecord({ + id: generateId(), + typeId: GRANT_TYPE, + createdAt: now, + updatedAt: now, + content, + version: 1, + createdBy: { subjectId: TEST_ENTITY_ID }, + }); + } + function expectError( status: number, data: unknown, @@ -1702,7 +1993,7 @@ describe('error response fixtures', () => { }, ); const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); - const { status, data } = await dispatch(fixture, token, `/records/${record.id}`); + const { status, data } = await dispatch(fixture, token, record.id); expectError(status, data, fixture); }); @@ -1710,14 +2001,14 @@ describe('error response fixtures', () => { const fixture = find('error-not-found-record-the-requester-cannot-read'); const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); - const { status, data } = await dispatch(fixture, token, `/records/${record.id}`); + const { status, data } = await dispatch(fixture, token, record.id); expectError(status, data, fixture); }); test('error-validation-permission-write-without-read', async () => { const fixture = find('error-validation-permission-write-without-read'); const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); - const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}`); + const { status, data } = await dispatch(fixture, TEST_TOKEN, record.id); expectError(status, data, fixture); }); @@ -1727,7 +2018,7 @@ describe('error response fixtures', () => { // Format is checked before existence, so the empty string answers for // being malformed rather than for naming nothing. It is not a spelling // of the root here — `null` is. - const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}`); + const { status, data } = await dispatch(fixture, TEST_TOKEN, record.id); expectError(status, data, fixture); }); @@ -1738,7 +2029,7 @@ describe('error response fixtures', () => { // owner sees it: the reference gate exempts them, so the existence check // behind it is theirs to reach. Anyone else gets one indistinguishable // 403 for a destination they cannot read and one that is not there. - const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}`); + const { status, data } = await dispatch(fixture, TEST_TOKEN, record.id); expectError(status, data, fixture); }); @@ -1758,7 +2049,7 @@ describe('error response fixtures', () => { }, ); const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); - const { status, data } = await dispatch(fixture, token, `/records/${record.id}/versions`); + const { status, data } = await dispatch(fixture, token, record.id); expectError(status, data, fixture); }); @@ -1771,7 +2062,7 @@ describe('error response fixtures', () => { test('create-attachment-record-non-owner-without-carve-out-refused', async () => { const fixture = find('create-attachment-record-non-owner-without-carve-out-refused'); - await t.ctx.stack.grantType(ATTACHMENT_TYPE, { + await t.ctx.stack.grantType(familyOf(ATTACHMENT_TYPE), { actions: ['create'], grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }); @@ -1841,7 +2132,7 @@ describe('error response fixtures', () => { await t.ctx.stack.patchContent(record.id, { coverFileId: null }); const { token } = await t.ctx.adapter.createToken({ subjectId: WRITER_ID }); - const { status, data } = await dispatch(fixture, token, `/records/${record.id}/restore/1`); + const { status, data } = await dispatch(fixture, token, record.id); expectError(status, data, fixture); // The refusal is about the reference, not about write access: give the @@ -1884,7 +2175,7 @@ describe('error response fixtures', () => { content: { title: 42 }, updatedAt: new Date(), }); - const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}/restore/1`); + const { status, data } = await dispatch(fixture, TEST_TOKEN, record.id); expectError(status, data, fixture); // Nothing was applied: the record still holds its pre-restore content. const after = await req(t.app, 'GET', `/records/${record.id}`, { token: TEST_TOKEN }); @@ -1941,7 +2232,7 @@ describe('error response fixtures', () => { }, ); const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); - const { status, data } = await dispatch(fixture, token, `/records/${record.id}/journal`); + const { status, data } = await dispatch(fixture, token, record.id); expectError(status, data, fixture); }); @@ -1954,7 +2245,7 @@ describe('error response fixtures', () => { const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'about to be purged' }); await t.ctx.stack.delete(record.id, { purge: true }); - const purged = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}/journal`); + const purged = await dispatch(fixture, TEST_TOKEN, record.id); expectError(purged.status, purged.data, fixture); }); @@ -1994,11 +2285,7 @@ describe('error response fixtures', () => { test('error-validation-anyone-element-labelled-write', async () => { const fixture = find('error-validation-anyone-element-labelled-write'); const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); - const { status, data } = await dispatch( - fixture, - TEST_TOKEN, - `/records/${record.id}/permissions`, - ); + const { status, data } = await dispatch(fixture, TEST_TOKEN, record.id); expectError(status, data, fixture); }); @@ -2008,8 +2295,8 @@ describe('error response fixtures', () => { // which is the point: this pins the second half of the rule, where a // grant that reached storage some other way is read back as conferring // nothing rather than trusted. - await t.ctx.stack.create(GRANT_TYPE, { - typeId: APP_TYPE, + await storeGrant({ + baseId: familyOf(APP_TYPE), actions: ['create', 'read-any'], grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }); @@ -2023,7 +2310,7 @@ describe('error response fixtures', () => { // the create, so the refusal above is the family's and not the absence // of any machinery to read. await t.ctx.stack.create(GRANT_TYPE, { - typeId: COMMENT_TYPE, + baseId: familyOf(COMMENT_TYPE), actions: ['create', 'read-any'], grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }); @@ -2041,42 +2328,39 @@ describe('error response fixtures', () => { // than a 403. Written directly for the same reason as above — the // companion rule is enforced where the grant is read, not only where a // helper wrote it. - await t.ctx.stack.create(GRANT_TYPE, { - typeId: NOTE_TYPE, + await storeGrant({ + baseId: familyOf(NOTE_TYPE), actions: ['create', 'update-any'], grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }); const record = await t.ctx.stack.create(NOTE_TYPE, { title: "someone else's" }); const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); - const { status, data } = await dispatch(fixture, token, `/records/${record.id}`); + const { status, data } = await dispatch(fixture, token, record.id); expectError(status, data, fixture); // The control: add the read companion the grant was missing and the very // same PATCH succeeds. Without it this test would pass against a server // that had no grant for this requester at all, since both answer 404. await t.ctx.stack.create(GRANT_TYPE, { - typeId: NOTE_TYPE, + baseId: familyOf(NOTE_TYPE), actions: ['update-any', 'read-any'], grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }); - const control = await req(t.app, 'PATCH', `/records/${record.id}`, { - token, - body: fixture.requestBody, - }); + const control = await dispatch(fixture, token, record.id); expect(control.status).toBe(200); }); test('error-query-permission-kind-in-associations-key', async () => { const fixture = find('error-query-permission-kind-in-associations-key'); const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); - const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}`); + const { status, data } = await dispatch(fixture, TEST_TOKEN, record.id); expectError(status, data, fixture); }); test('error-query-association-kind-in-permissions-key', async () => { const fixture = find('error-query-association-kind-in-permissions-key'); const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); - const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}`); + const { status, data } = await dispatch(fixture, TEST_TOKEN, record.id); expectError(status, data, fixture); }); @@ -2091,7 +2375,7 @@ describe('error response fixtures', () => { test('error-validation-failed — PATCH content fails the type schema', async () => { const fixture = find('error-validation-failed'); const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); - const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}`); + const { status, data } = await dispatch(fixture, TEST_TOKEN, record.id); expectError(status, data, fixture); }); @@ -2204,6 +2488,96 @@ describe('error response fixtures', () => { expect(status).toBe(fixture.responseStatus); }); + test.each([ + 'error-bad-request-content-by-type-beside-content', + 'error-bad-request-empty-content-by-type', + 'error-bad-request-non-boolean-param', + 'error-bad-request-unknown-query-body-key', + 'error-bad-request-unknown-query-param', + 'error-bad-request-unknown-record-key', + 'error-validation-query-versioned-base-id', + ])('%s — refused before anything is read', async (name) => { + const fixture = find(name); + const { status, data } = await dispatch(fixture, TEST_TOKEN); + expectError(status, data, fixture); + }); + + test.each([ + 'error-bad-request-edit-associations-repoint', + 'error-bad-request-unknown-grantee-key', + 'error-validation-edit-permissions-write-without-read', + ])('%s — refused, and the record unchanged', async (name) => { + const fixture = find(name); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); + const { status, data } = await dispatch(fixture, TEST_TOKEN, record.id); + expectError(status, data, fixture); + const after = await t.ctx.stack.get(record.id); + expect(after?.associations).toEqual(record.associations); + expect(after?.permissions).toEqual(record.permissions); + }); + + test('error-bad-request-non-boolean-purge', async () => { + const fixture = find('error-bad-request-non-boolean-purge'); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); + const { status, data } = await dispatch(fixture, TEST_TOKEN, record.id); + expectError(status, data, fixture); + // Deletes nothing, soft or otherwise. + expect((await t.ctx.stack.get(record.id))?.deletedAt).toBeUndefined(); + }); + + test('error-bad-request-unknown-auth-token-key', async () => { + const fixture = find('error-bad-request-unknown-auth-token-key'); + t.ctx.nonces.seedForTesting( + AUTH_FIXTURE_NONCE, + AUTH_FIXTURE_DID, + new Date(Date.now() + 60_000), + ); + const { status, data } = await dispatch(fixture, undefined); + expectError(status, data, fixture); + }); + + test.each([ + 'error-validation-grant-protected-system-type', + 'error-validation-grant-versioned-base-id', + ])('%s — a grant target is checked on every _grant write', async (name) => { + const fixture = find(name); + const body = withFreshId(fixture.requestBody as WireRecord); + const { status, data } = await dispatch(fixture, TEST_TOKEN, undefined, body); + expectError(status, data, fixture); + }); + + test('error-schema-drift-enum-values-removed', async () => { + const fixture = find('error-schema-drift-enum-values-removed'); + await t.ctx.stack.defineType({ + id: 'com.example/book@1', + name: 'Book', + schema: { status: { kind: 'enum', values: ['want', 'reading', 'finished'] } }, + }); + const { status, data } = await dispatch(fixture, TEST_TOKEN); + expectError(status, data, fixture); + }); + + test('error-validation-enum-value-not-listed', async () => { + const fixture = find('error-validation-enum-value-not-listed'); + await t.ctx.stack.defineType({ + id: 'com.example/book@1', + name: 'Book', + schema: { status: { kind: 'enum', values: ['want', 'reading', 'finished'] } }, + }); + const record = await t.ctx.stack.create('com.example/book@1', { status: 'want' }); + const { status, data } = await dispatch(fixture, TEST_TOKEN, record.id); + expectError(status, data, fixture); + }); + + test('error-version-conflict-if-match-on-a-write-that-does-not-bump', async () => { + const fixture = find('error-version-conflict-if-match-on-a-write-that-does-not-bump'); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); + const { status, data } = await dispatch(fixture, TEST_TOKEN, record.id); + expectError(status, data, fixture); + // Changes nothing: the record is still private. + expect((await t.ctx.stack.get(record.id))?.permissions).toBeUndefined(); + }); + test('coverage', () => { assertCoverage( errorResponseFixtures.map((f) => f.name), @@ -2319,9 +2693,9 @@ describe('changeFeed fixtures', () => { const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN }); try { await conn.waitForFrames(1); // ready - await req(t.app, 'PATCH', `/records/${record.id}`, { - token, - body: { contentPatch: { title: 'edited by a contributor' } }, + await req(t.app, 'PATCH', `/records/${record.id}/content`, { + token: token, + body: { title: 'edited by a contributor' }, }); const [, frame] = await conn.waitForFrames(2); const data = frameData(frame); @@ -2345,7 +2719,7 @@ describe('changeFeed fixtures', () => { await conn.waitForFrames(1); // ready await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, - body: { kind: 'tag', label: 'starred' }, + body: { changes: [{ op: 'add', association: { kind: 'tag', label: 'starred' } }] }, }); const [, frame] = await conn.waitForFrames(2); const data = frameData(frame); @@ -2375,15 +2749,17 @@ describe('changeFeed fixtures', () => { const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }); await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, - body: { ...association, attachmentRecordId: uploaded.id }, + body: { + changes: [{ op: 'add', association: { ...association, attachmentRecordId: uploaded.id } }], + }, }); const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN }); try { await conn.waitForFrames(1); // ready - await req(t.app, 'POST', `/records/${record.id}/associations/delete`, { + await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, - body: association, + body: { changes: [{ op: 'remove', association: association }] }, }); const [, frame] = await conn.waitForFrames(2); const data = frameData(frame); @@ -2409,7 +2785,7 @@ describe('changeFeed fixtures', () => { await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN }); const [, frame] = await conn.waitForFrames(2); const data = frameData(frame); - expect(data.kind).toBe('deleted'); + expect(data.kind).toBe('removed'); expect(data.ops).toEqual(['delete']); expect(data.recordId).toBe(record.id); } finally { @@ -2458,9 +2834,9 @@ describe('changeFeed fixtures', () => { const conn = await openChangeFeed(t.app, '/changes?include=record', { token: TEST_TOKEN }); try { await conn.waitForFrames(1); // ready - await req(t.app, 'PATCH', `/records/${record.id}`, { + await req(t.app, 'PATCH', `/records/${record.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { title: 'Updated title' } }, + body: { title: 'Updated title' }, }); const [, frame] = await conn.waitForFrames(2); const data = frameData(frame); @@ -2495,13 +2871,13 @@ describe('changeFeed fixtures', () => { const conn = await openChangeFeed(t.app, '/changes', { token }); try { await conn.waitForFrames(1); // ready - await req(t.app, 'PATCH', `/records/${privateRecord.id}`, { + await req(t.app, 'PATCH', `/records/${privateRecord.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { title: 'still private' } }, + body: { title: 'still private' }, }); - await req(t.app, 'PATCH', `/records/${sharedRecord.id}`, { + await req(t.app, 'PATCH', `/records/${sharedRecord.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { title: 'shared, updated' } }, + body: { title: 'shared, updated' }, }); // Exactly one more frame — the readable record's — proves the // private edit produced none rather than merely arriving later. @@ -2529,13 +2905,13 @@ describe('changeFeed fixtures', () => { }); try { await conn.waitForFrames(1); // ready - await req(t.app, 'PATCH', `/records/${comment.id}`, { + await req(t.app, 'PATCH', `/records/${comment.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { body: 'still unrelated' } }, + body: { body: 'still unrelated' }, }); - await req(t.app, 'PATCH', `/records/${later.id}`, { + await req(t.app, 'PATCH', `/records/${later.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { title: 'Later' } }, + body: { title: 'Later' }, }); await req(t.app, 'POST', `/records/${note.id}/migrate`, { token: TEST_TOKEN, @@ -2567,13 +2943,13 @@ describe('changeFeed fixtures', () => { }); try { await conn.waitForFrames(1); // ready - await req(t.app, 'PATCH', `/records/${comment.id}`, { + await req(t.app, 'PATCH', `/records/${comment.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { body: 'still unrelated' } }, + body: { body: 'still unrelated' }, }); - await req(t.app, 'PATCH', `/records/${later.id}`, { + await req(t.app, 'PATCH', `/records/${later.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { title: 'Later' } }, + body: { title: 'Later' }, }); const [, frame] = await conn.waitForFrames(2); const data = frameData(frame); @@ -2614,22 +2990,22 @@ describe('changeFeed fixtures', () => { } }); - test('change-feed-unlist-frame-is-a-deleted-kind', async () => { + test('change-feed-unlist-frame-is-a-removed-kind', async () => { const fixture = changeFeedFixtures.find( - (f) => f.name === 'change-feed-unlist-frame-is-a-deleted-kind', + (f) => f.name === 'change-feed-unlist-frame-is-a-removed-kind', )!; handled.add(fixture.name); const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }); const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN }); try { await conn.waitForFrames(1); // ready - await req(t.app, 'PATCH', `/records/${record.id}`, { + await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { token: TEST_TOKEN, body: { unlisted: true }, }); const [, frame] = await conn.waitForFrames(2); const data = frameData(frame); - expect(data.kind).toBe('deleted'); + expect(data.kind).toBe('removed'); expect(data.ops).toEqual(['unlist']); expect(data.recordId).toBe(record.id); } finally { @@ -2643,11 +3019,11 @@ describe('changeFeed fixtures', () => { )!; handled.add(fixture.name); const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }); - await t.ctx.stack.mutate(record.id, { unlisted: true }); + await t.ctx.stack.setUnlisted(record.id, true); const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN }); try { await conn.waitForFrames(1); // ready - await req(t.app, 'PATCH', `/records/${record.id}`, { + await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { token: TEST_TOKEN, body: { unlisted: false }, }); @@ -2667,18 +3043,18 @@ describe('changeFeed fixtures', () => { )!; handled.add(fixture.name); const unlistedRecord = await t.ctx.stack.create(NOTE_TYPE, { title: 'Unlisted' }); - await t.ctx.stack.mutate(unlistedRecord.id, { unlisted: true }); + await t.ctx.stack.setUnlisted(unlistedRecord.id, true); const visibleRecord = await t.ctx.stack.create(NOTE_TYPE, { title: 'Visible' }); const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN }); try { await conn.waitForFrames(1); // ready - await req(t.app, 'PATCH', `/records/${unlistedRecord.id}`, { + await req(t.app, 'PATCH', `/records/${unlistedRecord.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { title: 'Edited while unlisted' } }, + body: { title: 'Edited while unlisted' }, }); - await req(t.app, 'PATCH', `/records/${visibleRecord.id}`, { + await req(t.app, 'PATCH', `/records/${visibleRecord.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { title: 'Edited, visible' } }, + body: { title: 'Edited, visible' }, }); // Exactly one more frame — the visible record's — proves the // unlisted edit produced none rather than merely arriving later. @@ -2709,7 +3085,7 @@ describe('changeFeed fixtures', () => { }); try { await conn.waitForFrames(1); // ready - await req(t.app, 'PATCH', `/records/${record.id}`, { + await req(t.app, 'PUT', `/records/${record.id}/parent`, { token: TEST_TOKEN, body: { parentId: destination.id }, }); @@ -2755,9 +3131,9 @@ describe('changeFeed sequence fixtures', () => { const [ready] = await first.waitForFrames(1); expect(ready.event).toBe('ready'); expect(frameData(ready).cursor).toMatch(CURSOR_PATTERN); - await req(t.app, 'PATCH', `/records/${record.id}`, { + await req(t.app, 'PATCH', `/records/${record.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { title: 'first' } }, + body: { title: 'first' }, }); const [, changeFrame] = await first.waitForFrames(2); expect(changeFrame.event).toBe('record'); @@ -2769,9 +3145,9 @@ describe('changeFeed sequence fixtures', () => { // A second edit lands with nobody listening — the buffer (retained // past disconnect) keeps collecting on its own. - await req(t.app, 'PATCH', `/records/${record.id}`, { + await req(t.app, 'PATCH', `/records/${record.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { title: 'second' } }, + body: { title: 'second' }, }); // Step 2: reconnect presenting the last id seen. ready still leads, @@ -2996,109 +3372,6 @@ describe('auth handshake fixtures', () => { }); }); -// ------------------------------------------------------- -// Records: the unlisted change-set key -// ------------------------------------------------------- - -describe('unlisted change fixtures', () => { - const handled = new Set(); - - // Run together against one seeded record, same as the versions block - // below — set-unlisted-false-relists assumes set-unlisted-true already - // ran (its own description says so). - test('set-unlisted-true then set-unlisted-false-relists', async () => { - const trueFixture = unlistedChangeFixtures.find((f) => f.name === 'set-unlisted-true')!; - const falseFixture = unlistedChangeFixtures.find( - (f) => f.name === 'set-unlisted-false-relists', - )!; - handled.add(trueFixture.name); - handled.add(falseFixture.name); - - const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello', body: 'World' }); - - const unlisted = await req(t.app, 'PATCH', `/records/${record.id}`, { - token: TEST_TOKEN, - body: trueFixture.requestBody, - }); - expect(unlisted.status).toBe(trueFixture.responseStatus); - expect((unlisted.data as WireRecord).unlistedAt).toBeDefined(); - - // Excluded from an unfiltered query, same posture as a soft-deleted record. - const queried = await req(t.app, 'GET', '/records', { token: TEST_TOKEN }); - expect( - (queried.data as { records: WireRecord[] }).records.some((r) => r.id === record.id), - ).toBe(false); - - const relisted = await req(t.app, 'PATCH', `/records/${record.id}`, { - token: TEST_TOKEN, - body: falseFixture.requestBody, - }); - expect(relisted.status).toBe(falseFixture.responseStatus); - expect((relisted.data as WireRecord).unlistedAt).toBeUndefined(); - }); - - test('coverage', () => { - assertCoverage( - unlistedChangeFixtures.map((f) => f.name), - handled, - new Set(), - ); - }); -}); - -// ------------------------------------------------------- -// Records: the parentId change-set key -// ------------------------------------------------------- - -describe('parent change fixtures', () => { - const handled = new Set(); - - // Run together against one seeded record: the null fixture moves a - // record back to the root, which only means anything once something - // has moved it off the root. - test('set-parent-moves-a-record-into-a-container then back to the root', async () => { - const intoFixture = parentChangeFixtures.find( - (f) => f.name === 'set-parent-moves-a-record-into-a-container', - )!; - const rootFixture = parentChangeFixtures.find( - (f) => f.name === 'set-parent-null-moves-a-record-to-the-root', - )!; - handled.add(intoFixture.name); - handled.add(rootFixture.name); - - // The destination has to exist: a caller-named parentId is checked for - // format and then for existence, so the fixture's own literal id would - // answer 409 here rather than moving anything. - const container = await t.ctx.stack.create(NOTE_TYPE, { title: 'Container' }); - const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello', body: 'World' }); - - const moved = await req(t.app, 'PATCH', `/records/${record.id}`, { - token: TEST_TOKEN, - body: { ...intoFixture.requestBody, parentId: container.id }, - }); - expect(moved.status).toBe(intoFixture.responseStatus); - expect((moved.data as WireRecord).parentId).toBe(container.id); - // A move touches containment and nothing else. - expect((moved.data as WireRecord).content).toEqual({ title: 'Hello', body: 'World' }); - - const toRoot = await req(t.app, 'PATCH', `/records/${record.id}`, { - token: TEST_TOKEN, - body: rootFixture.requestBody, - }); - expect(toRoot.status).toBe(rootFixture.responseStatus); - // State spells the root by omission, where the input spelled it null. - expect((toRoot.data as WireRecord).parentId).toBeUndefined(); - }); - - test('coverage', () => { - assertCoverage( - parentChangeFixtures.map((f) => f.name), - handled, - new Set(), - ); - }); -}); - // ------------------------------------------------------- // Attachments: download // ------------------------------------------------------- diff --git a/tests/lib/canonicalJson.test.ts b/tests/lib/canonicalJson.test.ts new file mode 100644 index 0000000..b05d54e --- /dev/null +++ b/tests/lib/canonicalJson.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; +import { canonicalJson } from '../../src/lib/canonicalJson.js'; + +describe('canonicalJson', () => { + it('serializes key order away, at every depth', () => { + expect(canonicalJson({ b: 1, a: { d: [{ y: 1, x: 2 }], c: null } })).toBe( + canonicalJson({ a: { c: null, d: [{ x: 2, y: 1 }] }, b: 1 }), + ); + }); + + it('keeps array order, which is part of the value', () => { + expect(canonicalJson([1, 2])).not.toBe(canonicalJson([2, 1])); + }); +}); diff --git a/tests/lib/historyPage.test.ts b/tests/lib/historyPage.test.ts new file mode 100644 index 0000000..e290599 --- /dev/null +++ b/tests/lib/historyPage.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { MAX_HISTORY_PAGE, readHistoryPage } from '../../src/lib/historyPage.js'; + +/** A history of `n` rows numbered from 1, read the way a Stack read takes a limit. */ +function history(n: number) { + const rows = Array.from({ length: n }, (_, i) => i + 1); + const limits: number[] = []; + const read = async (limit: number) => { + limits.push(limit); + return rows.slice(0, limit); + }; + return { read, limits }; +} + +const page = (n: number, limit: number | undefined) => { + const h = history(n); + return readHistoryPage(limit, h.read, (row) => row).then((r) => ({ ...r, limits: h.limits })); +}; + +describe('readHistoryPage', () => { + it('bounds a page no limit was given for, and says so in cursor', async () => { + const { rows, cursor } = await page(MAX_HISTORY_PAGE + 1, undefined); + expect(rows).toHaveLength(MAX_HISTORY_PAGE); + expect(cursor).toBe(MAX_HISTORY_PAGE); + }); + + it('caps a limit above the ceiling', async () => { + const { rows, limits } = await page(MAX_HISTORY_PAGE * 2, MAX_HISTORY_PAGE * 2); + expect(rows).toHaveLength(MAX_HISTORY_PAGE); + expect(limits).toEqual([MAX_HISTORY_PAGE + 1]); + }); + + it('answers a null cursor for a page exactly as long as what is left', async () => { + expect(await page(2, 2)).toMatchObject({ rows: [1, 2], cursor: null }); + }); + + it('answers the last row of a full page as the cursor when more follow', async () => { + expect(await page(3, 2)).toMatchObject({ rows: [1, 2], cursor: 2 }); + }); + + it('passes a limit below 1 through, for Stack to judge', async () => { + expect((await page(3, 0)).limits).toEqual([0]); + }); +}); diff --git a/tests/lib/installRequestStore.test.ts b/tests/lib/installRequestStore.test.ts new file mode 100644 index 0000000..f48f29a --- /dev/null +++ b/tests/lib/installRequestStore.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { dirname } from 'node:path'; +import { rm } from 'node:fs/promises'; +import type { AppManifest } from '@haverstack/core'; +import { InstallRequestStore } from '../../src/lib/installRequestStore.js'; +import { tempDbPath } from '../setup.js'; + +const MANIFEST: AppManifest = { + appId: 'com.example.notes', + name: 'Notes', + types: [], + requests: [], +}; + +describe('InstallRequestStore', () => { + let path: string; + let store: InstallRequestStore; + beforeEach(() => { + path = tempDbPath(); + store = InstallRequestStore.open(`${path}.installs`); + }); + afterEach(async () => { + vi.useRealTimers(); + store.close(); + await rm(dirname(path), { recursive: true, force: true }); + }); + + it('survives a reopen', () => { + const { id } = store.put('did:key:a', MANIFEST); + store.close(); + store = InstallRequestStore.open(`${path}.installs`); + expect(store.get(id)?.manifest).toEqual(MANIFEST); + }); + + it('removes by id, so a request its key replaced survives removing the old one', () => { + const old = store.put('did:key:a', MANIFEST); + const replacement = store.put('did:key:a', { ...MANIFEST, version: '2' }); + store.remove(old.id); + expect(store.forKey('did:key:a')?.id).toBe(replacement.id); + store.remove(replacement.id); + expect(store.forKey('did:key:a')).toBeNull(); + }); + + it('forgets a request no one re-sends, and keeps one that is', () => { + vi.useFakeTimers(); + const kept = store.put('did:key:a', MANIFEST); + const dropped = store.put('did:key:b', MANIFEST); + vi.advanceTimersByTime(6 * 24 * 60 * 60 * 1000); + store.put('did:key:a', MANIFEST); + vi.advanceTimersByTime(2 * 24 * 60 * 60 * 1000); + expect(store.get(kept.id)).not.toBeNull(); + expect(store.get(dropped.id)).toBeNull(); + expect(store.list().map((r) => r.did)).toEqual(['did:key:a']); + }); +}); diff --git a/tests/lib/queryLimit.test.ts b/tests/lib/queryLimit.test.ts deleted file mode 100644 index e3e61f8..0000000 --- a/tests/lib/queryLimit.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @haverstack/core/wire's parsers report `limit` as requested and never - * clamp it — a ceiling is deployment policy, not wire contract (core#240 / - * server#114). This server's ceiling is applied here, at the call site in - * records.ts, and pins the one behavioural detail worth being careful - * about in that migration: `limit=5000` in must still yield `1000` out. - */ -import { describe, it, expect } from 'vitest'; -import { clampLimit, MAX_QUERY_LIMIT } from '../../src/lib/queryLimit.js'; - -describe('clampLimit', () => { - it('clamps a limit above the ceiling down to it', () => { - expect(clampLimit({ limit: 5000 })).toEqual({ limit: MAX_QUERY_LIMIT }); - }); - - it('leaves a limit at or under the ceiling untouched', () => { - expect(clampLimit({ limit: MAX_QUERY_LIMIT })).toEqual({ limit: MAX_QUERY_LIMIT }); - expect(clampLimit({ limit: 10 })).toEqual({ limit: 10 }); - }); - - it('leaves an absent limit absent, not defaulted', () => { - expect(clampLimit({})).toEqual({}); - }); - - it('preserves the rest of the query', () => { - const query = { filter: { typeId: 'note@1' }, limit: 5000, cursor: 'abc' }; - expect(clampLimit(query)).toEqual({ - filter: { typeId: 'note@1' }, - limit: MAX_QUERY_LIMIT, - cursor: 'abc', - }); - }); -}); diff --git a/tests/lib/resumeBuffer.test.ts b/tests/lib/resumeBuffer.test.ts index 5b84978..762338c 100644 --- a/tests/lib/resumeBuffer.test.ts +++ b/tests/lib/resumeBuffer.test.ts @@ -87,22 +87,20 @@ describe('resumeBufferKey', () => { const a = resumeBufferKey({ principalId: 'p1', subjectId: 's1', - filter: { typeId: ['b', 'a'], kinds: ['deleted', 'created'] }, + filter: { typeId: ['b', 'a'], kinds: ['removed', 'created'] }, includeRecords: true, - includeUnlisted: false, }); const b = resumeBufferKey({ principalId: 'p1', subjectId: 's1', - filter: { typeId: ['a', 'b'], kinds: ['created', 'deleted'] }, + filter: { typeId: ['a', 'b'], kinds: ['created', 'removed'] }, includeRecords: true, - includeUnlisted: false, }); expect(a).toBe(b); }); - it('differs across sessions, filters, the includeRecords choice, or the includeUnlisted choice', () => { - const base = { filter: {}, includeRecords: false, includeUnlisted: false }; + it('differs across sessions, filters, the includeRecords choice, or includeUnlisted', () => { + const base = { filter: {}, includeRecords: false }; const anon = resumeBufferKey({ principalId: null, subjectId: null, ...base }); const authed = resumeBufferKey({ principalId: 'p1', subjectId: 's1', ...base }); const filtered = resumeBufferKey({ @@ -110,7 +108,6 @@ describe('resumeBufferKey', () => { subjectId: null, filter: { createdBy: { subjectId: 'e1' } }, includeRecords: false, - includeUnlisted: false, }); const withRecords = resumeBufferKey({ principalId: null, @@ -122,7 +119,7 @@ describe('resumeBufferKey', () => { principalId: null, subjectId: null, ...base, - includeUnlisted: true, + filter: { includeUnlisted: true }, }); const keys = new Set([anon, authed, filtered, withRecords, withUnlisted]); @@ -141,7 +138,6 @@ describe('resumeBufferKey', () => { principalId: null, subjectId: null, includeRecords: false, - includeUnlisted: false, }; const unfiltered = resumeBufferKey({ ...base, filter: {} }); const rootsOnly = resumeBufferKey({ ...base, filter: { parentId: null } }); @@ -155,7 +151,6 @@ describe('resumeBufferKey', () => { principalId: null, subjectId: null, includeRecords: false, - includeUnlisted: false, }; expect(resumeBufferKey({ ...base, filter: {} })).not.toBe( resumeBufferKey({ ...base, filter: { createdBy: { subjectId: 'e1' } } }), @@ -168,12 +163,18 @@ describe('resumeBufferKey', () => { ); }); + it('keys includeUnlisted: false the same as no includeUnlisted', () => { + const base = { principalId: null, subjectId: null, includeRecords: false }; + expect(resumeBufferKey({ ...base, filter: { includeUnlisted: false } })).toBe( + resumeBufferKey({ ...base, filter: {} }), + ); + }); + it('keys an empty createdBy the same as no createdBy', () => { const base = { principalId: null, subjectId: null, includeRecords: false, - includeUnlisted: false, }; expect(resumeBufferKey({ ...base, filter: { createdBy: {} } })).toBe( resumeBufferKey({ ...base, filter: {} }), @@ -185,7 +186,6 @@ describe('resumeBufferKey', () => { principalId: null, subjectId: null, includeRecords: false, - includeUnlisted: false, }; expect(resumeBufferKey({ ...base, filter: { parentId: 'x' } })).not.toBe( resumeBufferKey({ ...base, filter: { createdBy: { subjectId: 'x' } } }), diff --git a/tests/routes/associations.test.ts b/tests/routes/associations.test.ts index a928919..f91b800 100644 --- a/tests/routes/associations.test.ts +++ b/tests/routes/associations.test.ts @@ -27,7 +27,7 @@ describe('Associations', () => { const record = await seedRecord(); const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, - body: { kind: 'tag', label: 'starred' }, + body: { changes: [{ op: 'add', association: { kind: 'tag', label: 'starred' } }] }, }); expect(status).toBe(200); expect((data as Record).associations).toEqual([ @@ -41,7 +41,9 @@ describe('Associations', () => { const target = { kind: 'record' as const, recordId: other.id }; const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, - body: { kind: 'relationship', label: 'reply-to', target }, + body: { + changes: [{ op: 'add', association: { kind: 'relationship', label: 'reply-to', target } }], + }, }); expect(status).toBe(200); expect((data as Record).associations).toEqual([ @@ -58,7 +60,11 @@ describe('Associations', () => { const record = await seedRecord(); const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, - body: { kind: 'relationship', label: 'reply-to', target }, + body: { + changes: [ + { op: 'add', association: { kind: 'relationship', label: 'reply-to', target } }, + ], + }, }); expect(status).toBe(200); expect((data as Record).associations).toEqual([ @@ -69,8 +75,12 @@ describe('Associations', () => { it('GET returns all associations', async () => { const record = await seedRecord(); - await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' }); - await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'archived' }); + await t.ctx.adapter.editAssociations(record.id, [ + { op: 'add', association: { kind: 'tag', label: 'starred' } }, + ]); + await t.ctx.adapter.editAssociations(record.id, [ + { op: 'add', association: { kind: 'tag', label: 'archived' } }, + ]); const { status, data } = await req(t.app, 'GET', `/records/${record.id}/associations`, { token: TEST_TOKEN, }); @@ -80,7 +90,9 @@ describe('Associations', () => { it('GET ?kind=tag filters by kind', async () => { const record = await seedRecord(); - await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' }); + await t.ctx.adapter.editAssociations(record.id, [ + { op: 'add', association: { kind: 'tag', label: 'starred' } }, + ]); const { status, data } = await req( t.app, 'GET', @@ -92,12 +104,14 @@ describe('Associations', () => { expect(assocs.every((a) => a.kind === 'tag')).toBe(true); }); - it('POST .../associations/delete removes an association and answers with the updated record', async () => { + it('POST a remove edit removes an association and answers with the updated record', async () => { const record = await seedRecord(); - await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' }); - const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations/delete`, { + await t.ctx.adapter.editAssociations(record.id, [ + { op: 'add', association: { kind: 'tag', label: 'starred' } }, + ]); + const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, - body: { kind: 'tag', label: 'starred' }, + body: { changes: [{ op: 'remove', association: { kind: 'tag', label: 'starred' } }] }, }); expect(status).toBe(200); expect((data as Record).associations).toBeUndefined(); @@ -105,13 +119,17 @@ describe('Associations', () => { expect(after?.associations?.some((a) => a.label === 'starred')).toBeFalsy(); }); - it('POST .../associations/delete removes a relationship association regardless of target kind', async () => { + it('POST a remove edit removes a relationship association regardless of target kind', async () => { const record = await seedRecord(); const target = { kind: 'entity' as const, entityId: 'did:key:z6MkAlice' }; - await t.ctx.adapter.associate(record.id, { kind: 'relationship', label: 'author', target }); - const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations/delete`, { + await t.ctx.adapter.editAssociations(record.id, [ + { op: 'add', association: { kind: 'relationship', label: 'author', target } }, + ]); + const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, - body: { kind: 'relationship', label: 'author', target }, + body: { + changes: [{ op: 'remove', association: { kind: 'relationship', label: 'author', target } }], + }, }); expect(status).toBe(200); expect((data as Record).associations).toBeUndefined(); @@ -119,6 +137,33 @@ describe('Associations', () => { expect(after?.associations?.some((a) => a.label === 'author')).toBeFalsy(); }); + it('PUT replaces the data set and leaves permissions alone', async () => { + const record = await t.ctx.stack.create( + TYPE_ID, + { text: 'Hello' }, + { + associations: [{ kind: 'tag', label: 'draft' }], + permissions: [{ kind: 'anyone', label: 'read' }], + }, + ); + const { status, data } = await req(t.app, 'PUT', `/records/${record.id}/associations`, { + token: TEST_TOKEN, + body: { associations: [{ kind: 'tag', label: 'reviewed' }] }, + }); + expect(status).toBe(200); + const body = data as { associations?: unknown[]; permissions?: unknown[] }; + expect(body.associations).toEqual([{ kind: 'tag', label: 'reviewed' }]); + expect(body.permissions).toEqual([{ kind: 'anyone', label: 'read' }]); + }); + + it('GET refuses a kind no association has, rather than answering every kind', async () => { + const record = await seedRecord(); + const { status } = await req(t.app, 'GET', `/records/${record.id}/associations?kind=tags`, { + token: TEST_TOKEN, + }); + expect(status).toBe(400); + }); + describe('no-bump semantics', () => { it('leaves version and updatedAt where they stand on both endpoints', async () => { const record = await seedRecord(); @@ -126,14 +171,14 @@ describe('Associations', () => { const added = await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, - body: { kind: 'tag', label: 'starred' }, + body: { changes: [{ op: 'add', association: { kind: 'tag', label: 'starred' } }] }, }); expect(added.status).toBe(200); expect((added.data as { version: number }).version).toBe(before!.version); - const removed = await req(t.app, 'POST', `/records/${record.id}/associations/delete`, { + const removed = await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, - body: { kind: 'tag', label: 'starred' }, + body: { changes: [{ op: 'remove', association: { kind: 'tag', label: 'starred' } }] }, }); expect(removed.status).toBe(200); @@ -146,22 +191,28 @@ describe('Associations', () => { const record = await seedRecord(); await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, - body: { kind: 'tag', label: 'starred' }, + body: { changes: [{ op: 'add', association: { kind: 'tag', label: 'starred' } }] }, }); const { data } = await req(t.app, 'GET', `/records/${record.id}/versions`, { token: TEST_TOKEN, }); - expect(data).toEqual([]); + expect(data).toEqual({ versions: [], cursor: null }); }); - it('ignores an If-Match that names a stale version rather than refusing it', async () => { + it('checks If-Match like every write, though it bumps nothing', async () => { const record = await seedRecord(); - const { status } = await req(t.app, 'POST', `/records/${record.id}/associations`, { + const stale = await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, - body: { kind: 'tag', label: 'starred' }, + body: { changes: [{ op: 'add', association: { kind: 'tag', label: 'starred' } }] }, headers: { 'If-Match': `"${record.version + 99}"` }, }); - expect(status).toBe(200); + expect(stale.status).toBe(412); + const current = await req(t.app, 'PUT', `/records/${record.id}/associations`, { + token: TEST_TOKEN, + body: { associations: [{ kind: 'tag', label: 'starred' }] }, + headers: { 'If-Match': `"${record.version}"` }, + }); + expect(current.status).toBe(200); }); }); @@ -171,26 +222,28 @@ describe('Associations', () => { const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, body: { - kind: 'permission', - label: 'read', - grantee: { kind: 'entity', entityId: 'entity-other' }, + changes: [ + { + op: 'add', + association: { + kind: 'permission', + label: 'read', + grantee: { kind: 'entity', entityId: 'entity-other' }, + }, + }, + ], }, }); expect(status).toBe(400); expect((data as { error: { code: string } }).error.code).toBe('bad_request'); }); - it('refuses an anyone element sent to POST /associations/delete', async () => { + it('refuses an anyone element in a remove edit', async () => { const record = await seedRecord(); - const { status, data } = await req( - t.app, - 'POST', - `/records/${record.id}/associations/delete`, - { - token: TEST_TOKEN, - body: { kind: 'anyone', label: 'read' }, - }, - ); + const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations`, { + token: TEST_TOKEN, + body: { changes: [{ op: 'remove', association: { kind: 'anyone', label: 'read' } }] }, + }); expect(status).toBe(400); expect((data as { error: { code: string } }).error.code).toBe('bad_request'); }); diff --git a/tests/routes/attachments.test.ts b/tests/routes/attachments.test.ts index 241d990..d55dc4f 100644 --- a/tests/routes/attachments.test.ts +++ b/tests/routes/attachments.test.ts @@ -196,15 +196,14 @@ describe('GET /attachments/:fileId', () => { ); const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); + const missingId = '0000000000000000000000000000000000000000000000000000000000000000'; const inaccessible = await req(t.app, 'GET', `/attachments/${fileId}`, { token }); - const missing = await req( - t.app, - 'GET', - '/attachments/0000000000000000000000000000000000000000000000000000000000000000', - { token }, - ); + const missing = await req(t.app, 'GET', `/attachments/${missingId}`, { token }); expect(inaccessible.status).toBe(missing.status); - expect(inaccessible.data).toEqual(missing.data); + // A refusal names the id the caller passed, and nothing else differs. + expect(JSON.stringify(inaccessible.data).replaceAll(fileId, '')).toBe( + JSON.stringify(missing.data).replaceAll(missingId, ''), + ); }); it('uses ?contentType param as Content-Type without a metadata record', async () => { @@ -291,7 +290,7 @@ describe('GET /attachments/:fileId', () => { it("prefers the requester's own record for filename over the first-recorded record", async () => { const bytes = new TextEncoder().encode('shared content 2'); - await t.ctx.stack.grantType('_attachment@1', { + await t.ctx.stack.grantType('_attachment', { actions: ['create'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }); @@ -417,7 +416,7 @@ describe('POST /attachments', () => { }); it('allows an entity with a create grant on _attachment@1 to upload', async () => { - await t.ctx.stack.grantType('_attachment@1', { + await t.ctx.stack.grantType('_attachment', { actions: ['create'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }); diff --git a/tests/routes/auth.test.ts b/tests/routes/auth.test.ts index 128949a..3ec6f0d 100644 --- a/tests/routes/auth.test.ts +++ b/tests/routes/auth.test.ts @@ -68,10 +68,10 @@ describe('POST /auth/challenge', () => { expect((data as { error: { code: string } }).error.code).toBe('invalid_did'); }); - it('rejects a missing did with 400 invalid_did', async () => { + it('rejects a body with no did as malformed, with 400 bad_request', async () => { const { status, data } = await req(t.app, 'POST', '/auth/challenge', { body: {} }); expect(status).toBe(400); - expect((data as { error: { code: string } }).error.code).toBe('invalid_did'); + expect((data as { error: { code: string } }).error.code).toBe('bad_request'); }); }); diff --git a/tests/routes/changes.test.ts b/tests/routes/changes.test.ts index 9048bfe..47ff00b 100644 --- a/tests/routes/changes.test.ts +++ b/tests/routes/changes.test.ts @@ -9,6 +9,7 @@ import { TEST_TOKEN, logger, type TestApp, + familyOf, } from '../setup.js'; import { changeRoutes, type ChangeRouteOptions } from '../../src/routes/changes.js'; import { authMiddleware } from '../../src/middleware/auth.js'; @@ -79,33 +80,12 @@ describe('GET /changes', () => { }); it('never honors a bearer token passed as a query parameter', async () => { - // A private, owner-only record. A connection presenting the owner's - // token via ?token= rather than Authorization must still be treated as - // anonymous — it must not see this change. - const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'private' }); - const conn = await openChangeFeed(t.app, `/changes?token=${TEST_TOKEN}`); - try { - await conn.waitForFrames(1); // ready - await req(t.app, 'PATCH', `/records/${record.id}`, { - token: TEST_TOKEN, - body: { contentPatch: { title: 'still private' } }, - }); - // Prove the connection is otherwise live (so a missing frame above - // isn't just a dead connection) by making a public edit land. - const publicRecord = await t.ctx.stack.create( - NOTE_TYPE, - { title: 'public' }, - { permissions: [{ kind: 'anyone', label: 'read' }] }, - ); - await req(t.app, 'PATCH', `/records/${publicRecord.id}`, { - token: TEST_TOKEN, - body: { contentPatch: { title: 'still public' } }, - }); - const [, frame] = await conn.waitForFrames(2); - expect((frame.data as { recordId: string }).recordId).toBe(publicRecord.id); - } finally { - await conn.close(); - } + // A credential belongs in Authorization, never somewhere a browser might + // log or cache it, so ?token= is a param the feed does not define and is + // refused outright rather than read as anonymous or as the owner. + const { status, data } = await req(t.app, 'GET', `/changes?token=${TEST_TOKEN}`); + expect(status).toBe(400); + expect((data as { error: { code: string } }).error.code).toBe('bad_request'); }); it('sends periodic keepalive comments on an otherwise-idle connection', async () => { @@ -238,7 +218,7 @@ describe('GET /changes', () => { // so it's buffered), then revoke recordA's grant, then edit recordB // (still readable throughout). await t.ctx.stack.patchContent(recordA.id, { title: 'a, edited' }); - await t.ctx.stack.mutate(recordA.id, { permissions: [] }); + await t.ctx.stack.setAccess(recordA.id, []); await t.ctx.stack.patchContent(recordB.id, { title: 'b, edited' }); const resumed = await openChangeFeed(t.app, '/changes', { @@ -536,16 +516,16 @@ describe('GET /changes', () => { } }); - it('listed -> unlisted emits a deleted/unlist frame', async () => { + it('listed -> unlisted emits a removed/unlist frame', async () => { const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN }); try { await conn.waitForFrames(1); // ready - await t.ctx.stack.mutate(record.id, { unlisted: true }); + await t.ctx.stack.setUnlisted(record.id, true); const [, frame] = await conn.waitForFrames(2); const data = frame.data as { kind: string; ops: string[]; recordId: string }; expect(data.recordId).toBe(record.id); - expect(data.kind).toBe('deleted'); + expect(data.kind).toBe('removed'); expect(data.ops).toEqual(['unlist']); } finally { await conn.close(); @@ -571,7 +551,7 @@ describe('GET /changes', () => { const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN }); try { await conn.waitForFrames(1); // ready - await t.ctx.stack.mutate(record.id, { unlisted: false }); + await t.ctx.stack.setUnlisted(record.id, false); const [, frame] = await conn.waitForFrames(2); const data = frame.data as { kind: string; ops: string[]; recordId: string }; expect(data.recordId).toBe(record.id); @@ -610,7 +590,7 @@ describe('GET /changes', () => { describe('permission scoping on a live connection', () => { it('stops delivering once a type-level grant is withdrawn mid-stream', async () => { const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'covered by a grant' }); - const grantRecord = await t.ctx.stack.grantType(NOTE_TYPE, { + const grantRecord = await t.ctx.stack.grantType(familyOf(NOTE_TYPE), { actions: ['read-any'], grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }); @@ -661,11 +641,16 @@ describe('GET /changes', () => { await t.ctx.stack.patchContent(record.id, { title: 'still private' }); await expect(conn.waitForFrames(2, 300)).rejects.toThrow(); - await t.ctx.stack.grantAccess(record.id, { - kind: 'permission', - label: 'read', - grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, - }); + await t.ctx.stack.editAccess(record.id, [ + { + op: 'add', + association: { + kind: 'permission', + label: 'read', + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, + }, + }, + ]); await t.ctx.stack.patchContent(record.id, { title: 'now shared' }); const frames = await conn.waitForFrames(2); @@ -718,11 +703,16 @@ describe('GET /changes', () => { // Removal is a write to the _group record, which expires the // connection's resolved roles rather than its grant set. - await t.ctx.stack.dissociate(group.id, { - kind: 'relationship', - label: 'member', - target: { kind: 'entity', entityId: CONTRIBUTOR_ID }, - }); + await t.ctx.stack.editAssociations(group.id, [ + { + op: 'remove', + association: { + kind: 'relationship', + label: 'member', + target: { kind: 'entity', entityId: CONTRIBUTOR_ID }, + }, + }, + ]); await t.ctx.stack.patchContent(record.id, { title: 'off the roster' }); const proof = await t.ctx.stack.create( diff --git a/tests/routes/entity.test.ts b/tests/routes/entity.test.ts index 7f37bf1..4a42256 100644 --- a/tests/routes/entity.test.ts +++ b/tests/routes/entity.test.ts @@ -91,7 +91,7 @@ describe('GET /entity', () => { }); }); -describe('PATCH /entity', () => { +describe('PATCH /entity/content', () => { let t: TestApp; beforeEach(async () => { t = await buildTestApp(); @@ -102,9 +102,9 @@ describe('PATCH /entity', () => { it('merges content and returns the updated record', async () => { await seedEntityRecord(t.ctx); - const { status, data } = await req(t.app, 'PATCH', '/entity', { + const { status, data } = await req(t.app, 'PATCH', '/entity/content', { token: TEST_TOKEN, - body: { content: { name: 'Updated Entity', handle: 'updated' } }, + body: { name: 'Updated Entity', handle: 'updated' }, }); expect(status).toBe(200); const content = (data as Record).content as Record; @@ -113,18 +113,18 @@ describe('PATCH /entity', () => { }); it('returns 404 when the entity record does not exist', async () => { - const { status } = await req(t.app, 'PATCH', '/entity', { + const { status } = await req(t.app, 'PATCH', '/entity/content', { token: TEST_TOKEN, - body: { content: { name: 'X' } }, + body: { name: 'X' }, }); expect(status).toBe(404); }); it('updates the owner record found by content.did even when its id differs from ownerEntityId', async () => { await seedEntityRecordWithGeneratedId(t.ctx); - const { status, data } = await req(t.app, 'PATCH', '/entity', { + const { status, data } = await req(t.app, 'PATCH', '/entity/content', { token: TEST_TOKEN, - body: { content: { name: 'Renamed' } }, + body: { name: 'Renamed' }, }); expect(status).toBe(200); const content = (data as Record).content as Record; @@ -135,16 +135,16 @@ describe('PATCH /entity', () => { it('returns 403 for a non-owner authenticated entity', async () => { await seedEntityRecord(t.ctx); const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); - const { status } = await req(t.app, 'PATCH', '/entity', { + const { status } = await req(t.app, 'PATCH', '/entity/content', { token, - body: { content: { name: 'Hacked' } }, + body: { name: 'Hacked' }, }); expect(status).toBe(403); }); it('returns 401 for an unauthenticated request', async () => { - const { status } = await req(t.app, 'PATCH', '/entity', { - body: { content: { name: 'X' } }, + const { status } = await req(t.app, 'PATCH', '/entity/content', { + body: { name: 'X' }, }); expect(status).toBe(401); }); @@ -154,16 +154,16 @@ describe('PATCH /entity', () => { await req(t.app, 'GET', '/entity', { token: TEST_TOKEN }); await t.ctx.adapter.deleteRecord(record.id, { purge: true }); - const stale = await req(t.app, 'PATCH', '/entity', { + const stale = await req(t.app, 'PATCH', '/entity/content', { token: TEST_TOKEN, - body: { content: { name: 'X' } }, + body: { name: 'X' }, }); expect(stale.status).toBe(404); await seedEntityRecordWithGeneratedId(t.ctx); - const recovered = await req(t.app, 'PATCH', '/entity', { + const recovered = await req(t.app, 'PATCH', '/entity/content', { token: TEST_TOKEN, - body: { content: { name: 'Recovered' } }, + body: { name: 'Recovered' }, }); expect(recovered.status).toBe(200); }); diff --git a/tests/routes/groups.test.ts b/tests/routes/groups.test.ts index 6001e6d..0c396c2 100644 --- a/tests/routes/groups.test.ts +++ b/tests/routes/groups.test.ts @@ -26,11 +26,16 @@ describe('_group ACL', () => { it('an entity added to a real _group roster gains group-scoped read access', async () => { const group = await t.ctx.stack.create(GROUP_TYPE, { name: 'Test Group' }); - await t.ctx.stack.associate(group.id, { - kind: 'relationship', - label: 'member', - target: { kind: 'entity', entityId: OTHER_ENTITY_ID }, - }); + await t.ctx.stack.editAssociations(group.id, [ + { + op: 'add', + association: { + kind: 'relationship', + label: 'member', + target: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }, + }, + ]); const record = await t.ctx.stack.create( NOTE_TYPE, { title: 'group-visible' }, diff --git a/tests/routes/installs.test.ts b/tests/routes/installs.test.ts new file mode 100644 index 0000000..3588e81 --- /dev/null +++ b/tests/routes/installs.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { AppManifest } from '@haverstack/core'; +import { buildTestApp, req, TEST_TOKEN, OTHER_ENTITY_ID, type TestApp } from '../setup.js'; + +const APP_KEY = 'did:key:z6Mkfsz9oK6i2355mvEwtDYdAmqCN6kmQETThJtARfj9iGum'; + +const MANIFEST: AppManifest = { + appId: 'com.example.notes', + name: 'Notes', + version: '1.0.0', + types: [{ id: 'com.example.notes/note@1', name: 'Note', schema: { text: { kind: 'text' } } }], + requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], +}; + +type RequestSummary = { id: string; did: string; manifest: AppManifest }; + +describe('installs', () => { + let t: TestApp; + let appToken: string; + beforeEach(async () => { + t = await buildTestApp(); + ({ token: appToken } = await t.ctx.adapter.createToken({ subjectId: APP_KEY })); + }); + afterEach(async () => { + await t.cleanup(); + }); + + function ask(manifest: AppManifest = MANIFEST) { + return req(t.app, 'POST', '/installs', { token: appToken, body: { manifest } }); + } + + async function pending(): Promise { + const { data } = await req(t.app, 'GET', '/installs/requests', { token: TEST_TOKEN }); + return (data as { requests: RequestSummary[] }).requests; + } + + it('queues one request per key, keeping its id while the manifest is unchanged', async () => { + await ask(); + await ask(); + const [first, ...rest] = await pending(); + expect(rest).toEqual([]); + expect(first).toMatchObject({ did: APP_KEY, manifest: MANIFEST }); + + // An upgrade replaces the request, under a new id: the one the owner + // reviewed no longer names what the key is asking for. + await ask({ ...MANIFEST, version: '1.1.0' }); + const [upgraded, ...none] = await pending(); + expect(none).toEqual([]); + expect(upgraded!.id).not.toBe(first!.id); + expect(upgraded!.manifest.version).toBe('1.1.0'); + }); + + it('shows the owner what approving would change', async () => { + await ask(); + const [request] = await pending(); + const { status, data } = await req(t.app, 'GET', `/installs/requests/${request!.id}`, { + token: TEST_TOKEN, + }); + expect(status).toBe(200); + const { plan } = data as { plan: Record }; + expect(plan.existing).toBeNull(); + expect(plan.newFamilies).toEqual(['com.example.notes/note']); + expect(plan.newKey).toBe(true); + }); + + it('installs on approval, after which the key is answered as installed', async () => { + await ask(); + const [request] = await pending(); + const approved = await req(t.app, 'POST', `/installs/requests/${request!.id}/approve`, { + token: TEST_TOKEN, + }); + expect(approved.status).toBe(200); + // The same body POST /installs answers once the plan is empty. + const approvedBody = approved.data as { status: string; install: { typeId: string } }; + expect(approvedBody.status).toBe('installed'); + expect(approvedBody.install.typeId).toBe('_install@1'); + expect(await pending()).toEqual([]); + + const { status, data } = await ask(); + expect(status).toBe(200); + expect(data).toEqual(approved.data); + // The grant the manifest asked for now holds. + const created = await req(t.app, 'POST', '/records', { + token: appToken, + body: { typeId: 'com.example.notes/note@1', content: { text: 'hi' } }, + }); + expect(created.status).toBe(200); + }); + + it('drops a declined request without writing anything', async () => { + await ask(); + const [request] = await pending(); + const declined = await req(t.app, 'DELETE', `/installs/requests/${request!.id}`, { + token: TEST_TOKEN, + }); + expect(declined.status).toBe(204); + expect(await pending()).toEqual([]); + expect(await t.ctx.stack.getType('com.example.notes/note@1')).toBeNull(); + }); + + it('keeps a request id when the same manifest is re-sent with its keys reordered', async () => { + await ask(); + const [first] = await pending(); + const reordered = JSON.parse( + JSON.stringify({ + requests: MANIFEST.requests, + types: MANIFEST.types, + version: MANIFEST.version, + name: MANIFEST.name, + appId: MANIFEST.appId, + }), + ) as AppManifest; + await ask(reordered); + const [second] = await pending(); + expect(second!.id).toBe(first!.id); + }); + + it('acts only on the request an id names, never on the one that replaced it', async () => { + await ask(); + const [original] = await pending(); + await ask({ ...MANIFEST, version: '1.1.0' }); + for (const [method, path] of [ + ['POST', `/installs/requests/${original!.id}/approve`], + ['DELETE', `/installs/requests/${original!.id}`], + ] as const) { + expect((await req(t.app, method, path, { token: TEST_TOKEN })).status).toBe(404); + } + const [replacement] = await pending(); + expect(replacement!.manifest.version).toBe('1.1.0'); + }); + + it('refuses a delegated session before reading its body', async () => { + const { token } = await t.ctx.adapter.createToken({ + subjectId: OTHER_ENTITY_ID, + principalId: APP_KEY, + }); + const { status } = await req(t.app, 'POST', '/installs', { token, body: { manifest: 5 } }); + expect(status).toBe(403); + }); + + it('answers 404 for a request id it does not hold', async () => { + for (const [method, path] of [ + ['GET', '/installs/requests/nope'], + ['POST', '/installs/requests/nope/approve'], + ['DELETE', '/installs/requests/nope'], + ] as const) { + const { status } = await req(t.app, method, path, { token: TEST_TOKEN }); + expect(status).toBe(404); + } + }); + + it('serves the review surface to the owner acting alone', async () => { + await ask(); + const [request] = await pending(); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); + for (const [method, path] of [ + ['GET', '/installs/requests'], + ['GET', `/installs/requests/${request!.id}`], + ['POST', `/installs/requests/${request!.id}/approve`], + ['DELETE', `/installs/requests/${request!.id}`], + ] as const) { + expect((await req(t.app, method, path, { token })).status).toBe(403); + expect((await req(t.app, method, path)).status).toBe(401); + } + }); + + it('refuses an anonymous request and an unknown body key', async () => { + const anonymous = await req(t.app, 'POST', '/installs', { body: { manifest: MANIFEST } }); + expect(anonymous.status).toBe(401); + const extra = await req(t.app, 'POST', '/installs', { + token: appToken, + body: { manifest: MANIFEST, did: OTHER_ENTITY_ID }, + }); + expect(extra.status).toBe(400); + }); +}); diff --git a/tests/routes/queryScoping.test.ts b/tests/routes/queryScoping.test.ts index 98905fd..9502563 100644 --- a/tests/routes/queryScoping.test.ts +++ b/tests/routes/queryScoping.test.ts @@ -25,6 +25,7 @@ import { TEST_ENTITY_ID, OTHER_ENTITY_ID, type TestApp, + familyOf, } from '../setup.js'; import type { StackRecord } from '@haverstack/core'; @@ -101,7 +102,7 @@ async function seedNotes() { describe('query scoping across the worker boundary', () => { it('answers a read-own grantee with their own records and nobody else’s', async () => { const { owner, other, third } = await seedNotes(); - await t.ctx.stack.grantType(NOTE_TYPE, { + await t.ctx.stack.grantType(familyOf(NOTE_TYPE), { actions: ['read-own'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }); @@ -114,7 +115,7 @@ describe('query scoping across the worker boundary', () => { it('widens to every record of the type under read-any', async () => { const { owner, other, third } = await seedNotes(); - await t.ctx.stack.grantType(NOTE_TYPE, { + await t.ctx.stack.grantType(familyOf(NOTE_TYPE), { actions: ['read-any'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }); @@ -125,7 +126,7 @@ describe('query scoping across the worker boundary', () => { it('stops answering on the next query once the grant is withdrawn', async () => { const { owner } = await seedNotes(); - const grantRecord = await t.ctx.stack.grantType(NOTE_TYPE, { + const grantRecord = await t.ctx.stack.grantType(familyOf(NOTE_TYPE), { actions: ['read-any'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }); @@ -140,7 +141,7 @@ describe('query scoping across the worker boundary', () => { it('confers again once the withdrawn grant is undeleted', async () => { const { owner } = await seedNotes(); - const grantRecord = await t.ctx.stack.grantType(NOTE_TYPE, { + const grantRecord = await t.ctx.stack.grantType(familyOf(NOTE_TYPE), { actions: ['read-any'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }); @@ -172,7 +173,7 @@ describe('query scoping across the worker boundary', () => { ], }, ); - await t.ctx.stack.grantType(NOTE_TYPE, { + await t.ctx.stack.grantType(familyOf(NOTE_TYPE), { actions: ['read-any'], grantee: { kind: 'group', groupId: group.id, role: 'member' }, }); @@ -206,19 +207,26 @@ describe('query scoping across the worker boundary', () => { ], }, ); - await t.ctx.stack.grantType(NOTE_TYPE, { + await t.ctx.stack.grantType(familyOf(NOTE_TYPE), { actions: ['read-any'], grantee: { kind: 'group', groupId: group.id, role: 'member' }, }); const token = await tokenFor(OTHER_ENTITY_ID); expect(await queryIds(token)).toContain(owner.id); - await req(t.app, 'POST', `/records/${group.id}/associations/delete`, { + await req(t.app, 'POST', `/records/${group.id}/associations`, { token: TEST_TOKEN, body: { - kind: 'relationship', - label: 'member', - target: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + changes: [ + { + op: 'remove', + association: { + kind: 'relationship', + label: 'member', + target: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }, + }, + ], }, }); expect(await queryIds(token)).not.toContain(owner.id); @@ -226,7 +234,7 @@ describe('query scoping across the worker boundary', () => { it('never enumerates a _grant record for a grantee', async () => { await seedNotes(); - await t.ctx.stack.grantType(NOTE_TYPE, { + await t.ctx.stack.grantType(familyOf(NOTE_TYPE), { actions: ['read-any'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }); @@ -242,7 +250,7 @@ describe('query scoping across the worker boundary', () => { it('answers both query encodings with the same set', async () => { await seedNotes(); - await t.ctx.stack.grantType(NOTE_TYPE, { + await t.ctx.stack.grantType(familyOf(NOTE_TYPE), { actions: ['read-own'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }); @@ -255,7 +263,9 @@ describe('query scoping across the worker boundary', () => { it('answers an anonymous requester with world-readable records only', async () => { const { owner, other } = await seedNotes(); - await t.ctx.stack.grantAccess(owner.id, { kind: 'anyone', label: 'read' }); + await t.ctx.stack.editAccess(owner.id, [ + { op: 'add', association: { kind: 'anyone', label: 'read' } }, + ]); expect(await queryIds(undefined)).toEqual([owner.id]); expect(await queryIds(undefined)).not.toContain(other.id); @@ -265,7 +275,7 @@ describe('query scoping across the worker boundary', () => { await seedNotes(); // `{ kind: 'authenticated' }` is every entity that turned up with a DID, // which is never the anonymous view. - await t.ctx.stack.grantType(NOTE_TYPE, { + await t.ctx.stack.grantType(familyOf(NOTE_TYPE), { actions: ['read-any'], grantee: { kind: 'authenticated' }, }); diff --git a/tests/routes/records.test.ts b/tests/routes/records.test.ts index 55ff630..e942622 100644 --- a/tests/routes/records.test.ts +++ b/tests/routes/records.test.ts @@ -7,6 +7,7 @@ import { TEST_ENTITY_ID, OTHER_ENTITY_ID, type TestApp, + familyOf, } from '../setup.js'; const NOTE_TYPE_ID = 'com.example.test/note@1'; @@ -505,12 +506,12 @@ describe('Records', () => { }); }); - describe('PATCH /records/:id', () => { + describe('PATCH /records/:id/content', () => { it('merges content (does not overwrite unmentioned fields)', async () => { const record = await seedRecord(t.ctx, { body: 'original', title: 'My title' }); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { body: 'Updated body' } }, + body: { body: 'Updated body' }, }); expect(status).toBe(200); const d = data as Record; @@ -522,9 +523,9 @@ describe('Records', () => { it('snapshots the previous version on update', async () => { const record = await seedRecord(t.ctx); - await req(t.app, 'PATCH', `/records/${record.id}`, { + await req(t.app, 'PATCH', `/records/${record.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { body: 'v2' } }, + body: { body: 'v2' }, }); const versions = await t.ctx.adapter.getVersions(record.id); expect(versions).toHaveLength(1); @@ -546,9 +547,9 @@ describe('Records', () => { }, ); const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); - const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { - token, - body: { contentPatch: { body: 'hacked' } }, + const { status } = await req(t.app, 'PATCH', `/records/${record.id}/content`, { + token: token, + body: { body: 'hacked' }, }); expect(status).toBe(403); }); @@ -582,11 +583,16 @@ describe('Records', () => { filename: 'cover.png', }); const record = await t.ctx.stack.create(NOTE_TYPE_ID, { body: 'with a cover' }); - await t.ctx.stack.associate(record.id, { - kind: 'attachment', - label: 'cover', - fileId: uploaded.content.fileId, - }); + await t.ctx.stack.editAssociations(record.id, [ + { + op: 'add', + association: { + kind: 'attachment', + label: 'cover', + fileId: uploaded.content.fileId, + }, + }, + ]); // Every other row naming the file is gone once this returns, so the // body is the requester's one chance to hold the argument @@ -679,13 +685,13 @@ describe('Records', () => { }); describe('GET /records/:id/journal', () => { - it('answers an empty page rather than crashing on limit=0', async () => { + it.each(['journal', 'versions'])('refuses limit=0 on /%s with 400', async (history) => { const record = await seedRecord(t.ctx); - const { status, data } = await req(t.app, 'GET', `/records/${record.id}/journal?limit=0`, { + const { status, data } = await req(t.app, 'GET', `/records/${record.id}/${history}?limit=0`, { token: TEST_TOKEN, }); - expect(status).toBe(200); - expect(data).toEqual({ entries: [], cursor: null }); + expect(status).toBe(400); + expect((data as { error: { code: string } }).error.code).toBe('bad_request'); }); }); @@ -709,9 +715,9 @@ describe('Records', () => { it('returns 422 when PATCH attempts to change fileId', async () => { const { record } = await seedAttachment(t.ctx); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { fileId: 'tampered-id' } }, + body: { fileId: 'tampered-id' }, }); expect(status).toBe(422); expect(detailPaths(data)).toContain('fileId'); @@ -719,9 +725,9 @@ describe('Records', () => { it('returns 422 when PATCH attempts to change size', async () => { const { record } = await seedAttachment(t.ctx); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { size: 9999 } }, + body: { size: 9999 }, }); expect(status).toBe(422); expect(detailPaths(data)).toContain('size'); @@ -729,9 +735,9 @@ describe('Records', () => { it('returns 422 when PATCH attempts to change mimeType', async () => { const { record } = await seedAttachment(t.ctx); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { mimeType: 'text/html' } }, + body: { mimeType: 'text/html' }, }); expect(status).toBe(422); expect(detailPaths(data)).toContain('mimeType'); @@ -739,9 +745,9 @@ describe('Records', () => { it('reports all attempted immutable fields', async () => { const { record } = await seedAttachment(t.ctx); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { fileId: 'x', size: 0, mimeType: 'text/html' } }, + body: { fileId: 'x', size: 0, mimeType: 'text/html' }, }); expect(status).toBe(422); const paths = detailPaths(data); @@ -752,9 +758,9 @@ describe('Records', () => { it('allows PATCH to change filename', async () => { const { record } = await seedAttachment(t.ctx); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { filename: 'renamed.txt' } }, + body: { filename: 'renamed.txt' }, }); expect(status).toBe(200); const content = (data as { content: Record }).content; @@ -769,7 +775,7 @@ describe('Records', () => { // targeting _grant cannot be handed out at all, so a non-owner has // nothing to escalate with. await expect( - t.ctx.stack.grantType(GRANT_TYPE_ID, { + t.ctx.stack.grantType(familyOf(GRANT_TYPE_ID), { actions: ['update-own'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }), @@ -784,7 +790,7 @@ describe('Records', () => { const grantRecord = await t.ctx.stack.create( GRANT_TYPE_ID, { - typeId: NOTE_TYPE_ID, + baseId: familyOf(NOTE_TYPE_ID), actions: ['read-own'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, @@ -805,9 +811,9 @@ describe('Records', () => { ); const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); - const { status } = await req(t.app, 'PATCH', `/records/${grantRecord.id}`, { - token, - body: { contentPatch: { actions: ['read-any', 'update-any', 'delete-any'] } }, + const { status } = await req(t.app, 'PATCH', `/records/${grantRecord.id}/content`, { + token: token, + body: { actions: ['read-any', 'update-any', 'delete-any'] }, }); expect(status).toBe(403); }); @@ -824,7 +830,7 @@ describe('Records', () => { const grantRecord = await t.ctx.stack.create( GRANT_TYPE_ID, { - typeId: NOTE_TYPE_ID, + baseId: familyOf(NOTE_TYPE_ID), actions: ['read-own'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, @@ -851,14 +857,14 @@ describe('Records', () => { }); it('allows the owner to PATCH a grant record', async () => { - const grantRecord = await t.ctx.stack.grantType(NOTE_TYPE_ID, { + const grantRecord = await t.ctx.stack.grantType(familyOf(NOTE_TYPE_ID), { actions: ['read-own'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }); - const { status, data } = await req(t.app, 'PATCH', `/records/${grantRecord.id}`, { + const { status, data } = await req(t.app, 'PATCH', `/records/${grantRecord.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { actions: ['read-own', 'read-any'] } }, + body: { actions: ['read-own', 'read-any'] }, }); expect(status).toBe(200); const content = (data as { content: Record }).content; @@ -866,7 +872,7 @@ describe('Records', () => { }); it('allows the owner to soft-DELETE a grant record', async () => { - const grantRecord = await t.ctx.stack.grantType(NOTE_TYPE_ID, { + const grantRecord = await t.ctx.stack.grantType(familyOf(NOTE_TYPE_ID), { actions: ['read-own'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }); @@ -887,7 +893,7 @@ describe('Records', () => { const readGrant = await t.ctx.stack.create( GRANT_TYPE_ID, { - typeId: NOTE_TYPE_ID, + baseId: familyOf(NOTE_TYPE_ID), actions: ['read-own'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, @@ -908,11 +914,9 @@ describe('Records', () => { ); const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); - const { status } = await req(t.app, 'PATCH', `/records/${readGrant.id}`, { - token, - body: { - contentPatch: { typeId: NOTE_TYPE_ID, actions: ['read-any', 'update-any', 'delete-any'] }, - }, + const { status } = await req(t.app, 'PATCH', `/records/${readGrant.id}/content`, { + token: token, + body: { typeId: NOTE_TYPE_ID, actions: ['read-any', 'update-any', 'delete-any'] }, }); expect(status).toBe(403); @@ -937,9 +941,9 @@ describe('Records', () => { }); it('refuses to change _config.entityId', async () => { - const { status } = await req(t.app, 'PATCH', '/records/_config', { + const { status } = await req(t.app, 'PATCH', '/records/_config/content', { token: TEST_TOKEN, - body: { contentPatch: { entityId: 'someone-else' } }, + body: { entityId: 'someone-else' }, }); expect(status).toBe(409); }); @@ -979,7 +983,7 @@ describe('Records', () => { it('refuses to grant create on _app (privilege-escalation guard, same as _grant/_config)', async () => { await expect( - t.ctx.stack.grantType(APP_TYPE_ID, { + t.ctx.stack.grantType(familyOf(APP_TYPE_ID), { actions: ['create'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }), @@ -1014,22 +1018,24 @@ describe('Records', () => { }); const id = (data as { id: string }).id; - const { status: didChange } = await req(t.app, 'PATCH', `/records/${id}`, { + const { status: didChange } = await req(t.app, 'PATCH', `/records/${id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { did: 'did:key:zChanged' } }, + body: { did: 'did:key:zChanged' }, }); expect(didChange).toBe(422); - const { status: appIdChange } = await req(t.app, 'PATCH', `/records/${id}`, { + const { status: appIdChange } = await req(t.app, 'PATCH', `/records/${id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { appId: 'changed' } }, + body: { appId: 'changed' }, }); expect(appIdChange).toBe(422); - const { status: nameChange, data: renamed } = await req(t.app, 'PATCH', `/records/${id}`, { - token: TEST_TOKEN, - body: { contentPatch: { name: 'Renamed' } }, - }); + const { status: nameChange, data: renamed } = await req( + t.app, + 'PATCH', + `/records/${id}/content`, + { token: TEST_TOKEN, body: { name: 'Renamed' } }, + ); expect(nameChange).toBe(200); expect((renamed as { content: { name: string } }).content.name).toBe('Renamed'); }); @@ -1059,7 +1065,7 @@ describe('Records', () => { const bytes = new TextEncoder().encode('unreferenced'); const record = await t.ctx.stack.putAttachment(bytes, { mimeType: 'text/plain' }); const fileId = (record.content as { fileId: string }).fileId; - await t.ctx.stack.grantType(ATTACHMENT_TYPE_ID, { + await t.ctx.stack.grantType(familyOf(ATTACHMENT_TYPE_ID), { actions: ['create'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }); @@ -1093,7 +1099,7 @@ describe('Records', () => { associations: [{ kind: 'attachment', label: 'file', fileId }], }, ); - await t.ctx.stack.grantType(ATTACHMENT_TYPE_ID, { + await t.ctx.stack.grantType(familyOf(ATTACHMENT_TYPE_ID), { actions: ['create'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }); @@ -1116,11 +1122,11 @@ describe('Records', () => { it('a delegated session stamps createdBy with the subject and the principal', async () => { // Delegated authority is the intersection of both parties' grants. - await t.ctx.stack.grantType(NOTE_TYPE_ID, { + await t.ctx.stack.grantType(familyOf(NOTE_TYPE_ID), { actions: ['create'], grantee: { kind: 'entity', entityId: SUBJECT_ID }, }); - await t.ctx.stack.grantType(NOTE_TYPE_ID, { + await t.ctx.stack.grantType(familyOf(NOTE_TYPE_ID), { actions: ['create'], grantee: { kind: 'entity', entityId: APP_ID }, }); @@ -1139,7 +1145,7 @@ describe('Records', () => { }); it('a delegated create is refused when only one party holds the create grant', async () => { - await t.ctx.stack.grantType(NOTE_TYPE_ID, { + await t.ctx.stack.grantType(familyOf(NOTE_TYPE_ID), { actions: ['create'], grantee: { kind: 'entity', entityId: SUBJECT_ID }, }); @@ -1157,11 +1163,11 @@ describe('Records', () => { }); it('filters by ?createdByPrincipal=', async () => { - await t.ctx.stack.grantType(NOTE_TYPE_ID, { + await t.ctx.stack.grantType(familyOf(NOTE_TYPE_ID), { actions: ['create'], grantee: { kind: 'entity', entityId: SUBJECT_ID }, }); - await t.ctx.stack.grantType(NOTE_TYPE_ID, { + await t.ctx.stack.grantType(familyOf(NOTE_TYPE_ID), { actions: ['create'], grantee: { kind: 'entity', entityId: APP_ID }, }); @@ -1321,7 +1327,7 @@ describe('Records', () => { }); it('drops createdAt/updatedAt from a grantee create instead of forwarding them into a 403', async () => { - await t.ctx.stack.grantType(NOTE_TYPE_ID, { + await t.ctx.stack.grantType(familyOf(NOTE_TYPE_ID), { actions: ['create'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }); @@ -1344,11 +1350,11 @@ describe('Records', () => { subjectId: OTHER_ENTITY_ID, principalId: TEST_ENTITY_ID, }); - await t.ctx.stack.grantType(NOTE_TYPE_ID, { + await t.ctx.stack.grantType(familyOf(NOTE_TYPE_ID), { actions: ['create'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }); - await t.ctx.stack.grantType(NOTE_TYPE_ID, { + await t.ctx.stack.grantType(familyOf(NOTE_TYPE_ID), { actions: ['create'], grantee: { kind: 'entity', entityId: TEST_ENTITY_ID }, }); @@ -1403,7 +1409,7 @@ describe('Records', () => { }); it('a non-owner grantee with a plain create grant may create unlisted — not owner-only', async () => { - await t.ctx.stack.grantType(NOTE_TYPE_ID, { + await t.ctx.stack.grantType(familyOf(NOTE_TYPE_ID), { actions: ['create'], grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }); @@ -1427,11 +1433,11 @@ describe('Records', () => { // record is the subject's own to begin with. const appId = 'com.example.thirdparty'; const subjectId = 'entity-third-party-subject'; - await t.ctx.stack.grantType(NOTE_TYPE_ID, { + await t.ctx.stack.grantType(familyOf(NOTE_TYPE_ID), { actions: ['create'], grantee: { kind: 'entity', entityId: subjectId }, }); - await t.ctx.stack.grantType(NOTE_TYPE_ID, { + await t.ctx.stack.grantType(familyOf(NOTE_TYPE_ID), { actions: ['create'], grantee: { kind: 'entity', entityId: appId }, }); @@ -1453,12 +1459,12 @@ describe('Records', () => { }); }); - describe('PATCH /records/:id — If-Match / optimistic concurrency', () => { + describe('PATCH /records/:id/content — If-Match / optimistic concurrency', () => { it('succeeds when If-Match names the current version', async () => { const record = await seedRecord(t.ctx); - const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status } = await req(t.app, 'PATCH', `/records/${record.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { body: 'updated' } }, + body: { body: 'updated' }, headers: { 'If-Match': `"${record.version}"` }, }); expect(status).toBe(200); @@ -1466,9 +1472,9 @@ describe('Records', () => { it('returns 412 version_conflict with the versionConflict payload on a mismatch', async () => { const record = await seedRecord(t.ctx); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { body: 'updated' } }, + body: { body: 'updated' }, headers: { 'If-Match': `"${record.version + 1}"` }, }); expect(status).toBe(412); @@ -1544,16 +1550,15 @@ describe('Records', () => { }); }); - describe('the permissions change-set key, and GET /records/:id/permissions', () => { - it('the permissions key replaces permissions and returns the updated record', async () => { + describe('PUT and GET /records/:id/permissions', () => { + it('PUT replaces permissions and returns the updated record', async () => { const record = await seedRecord(t.ctx); - const res = await t.app.request(`/records/${record.id}`, { - method: 'PATCH', - headers: { Authorization: `Bearer ${TEST_TOKEN}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ permissions: [{ kind: 'anyone', label: 'read' }] }), + const res = await req(t.app, 'PUT', `/records/${record.id}/permissions`, { + token: TEST_TOKEN, + body: { permissions: [{ kind: 'anyone', label: 'read' }] }, }); expect(res.status).toBe(200); - const body = (await res.json()) as { permissions: unknown[] }; + const body = res.data as { permissions: unknown[] }; expect(body.permissions).toEqual([{ kind: 'anyone', label: 'read' }]); const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); @@ -1569,7 +1574,7 @@ describe('Records', () => { { body: 'was public' }, { permissions: [{ kind: 'anyone', label: 'read' }] }, ); - await req(t.app, 'PATCH', `/records/${record.id}`, { + await req(t.app, 'PUT', `/records/${record.id}/permissions`, { token: TEST_TOKEN, body: { permissions: [] }, }); @@ -1596,7 +1601,7 @@ describe('Records', () => { ]); }); - it('returns 403 when a non-owner without write access names the key', async () => { + it('returns 403 to a non-owner without write access', async () => { const record = await t.ctx.stack.create( NOTE_TYPE_ID, { body: 'x' }, @@ -1611,16 +1616,16 @@ describe('Records', () => { }, ); const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); - const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { - token, + const { status } = await req(t.app, 'PUT', `/records/${record.id}/permissions`, { + token: token, body: { permissions: [{ kind: 'anyone', label: 'read' }] }, }); expect(status).toBe(403); }); it("returns the record even when the change revokes the caller's own read access", async () => { - // The permissions key is gated on mayReshare(), owner-or-creator rather - // than plain write access — OTHER_ENTITY_ID must be the record's + // The permissions endpoints are gated on mayReshare(), owner-or-creator + // rather than plain write access — OTHER_ENTITY_ID must be the record's // author, not merely a grantee, to reach it as a non-owner. const record = await t.ctx.stack.create( NOTE_TYPE_ID, @@ -1628,8 +1633,8 @@ describe('Records', () => { { createdBy: { subjectId: OTHER_ENTITY_ID } }, ); const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { - token, + const { status, data } = await req(t.app, 'PUT', `/records/${record.id}/permissions`, { + token: token, body: { permissions: [] }, }); expect(status).toBe(200); @@ -1644,7 +1649,7 @@ describe('Records', () => { it('leaves version and updatedAt where they stand', async () => { const record = await seedRecord(t.ctx); const before = await t.ctx.adapter.getRecord(record.id); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status, data } = await req(t.app, 'PUT', `/records/${record.id}/permissions`, { token: TEST_TOKEN, body: { permissions: [{ kind: 'anyone', label: 'read' }] }, }); @@ -1654,25 +1659,25 @@ describe('Records', () => { expect(after!.updatedAt.toISOString()).toBe(before!.updatedAt.toISOString()); }); - it('does not check an If-Match sent beside a change set that only names it', async () => { + it('checks If-Match, though the write bumps nothing', async () => { const record = await seedRecord(t.ctx); - const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status } = await req(t.app, 'PUT', `/records/${record.id}/permissions`, { token: TEST_TOKEN, body: { permissions: [{ kind: 'anyone', label: 'read' }] }, headers: { 'If-Match': `"${record.version + 99}"` }, }); - expect(status).toBe(200); + expect(status).toBe(412); }); }); - describe('the unlisted change-set key', () => { + describe('PUT /records/:id/unlisted', () => { it('withholds a record from enumeration without changing who may read it', async () => { const record = await t.ctx.stack.create( NOTE_TYPE_ID, { body: 'x' }, { permissions: [{ kind: 'anyone', label: 'read' }] }, ); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status, data } = await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { token: TEST_TOKEN, body: { unlisted: true }, }); @@ -1695,11 +1700,11 @@ describe('Records', () => { it('{ unlisted: false } relists a record', async () => { const record = await seedRecord(t.ctx); - await req(t.app, 'PATCH', `/records/${record.id}`, { + await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { token: TEST_TOKEN, body: { unlisted: true }, }); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status, data } = await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { token: TEST_TOKEN, body: { unlisted: false }, }); @@ -1713,11 +1718,11 @@ describe('Records', () => { }); // A key that addresses nothing is 400; a key whose value is the wrong - // shape is 422. `unlisted` is a change-set key the envelope recognizes, - // so a non-boolean is the second of those. + // shape is 422. `unlisted` is the key this body defines, so a + // non-boolean is the second of those. it('rejects a non-boolean unlisted value with 422', async () => { const record = await seedRecord(t.ctx); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status, data } = await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { token: TEST_TOKEN, body: { unlisted: 'true' }, }); @@ -1726,17 +1731,15 @@ describe('Records', () => { expect(details.some((d) => d.path === 'unlisted')).toBe(true); }); - // The same 422 is owed by every key carrying a structured value, not just - // the boolean one. `permissions` and `associations` are each replaced - // wholesale by the key, so a non-array is the shape error rather than a - // content one — and without this, a core bump that quietly stopped - // rejecting either would pass the whole suite on the `unlisted` case alone. + // The same 422 is owed by every per-aspect body, not just the boolean + // one: each set endpoint replaces its set wholesale, so a non-array is + // the shape error rather than a content one. it.each([ ['permissions', { permissions: 'nope' }], ['associations', { associations: 'nope' }], - ])('rejects a non-array %s value with 422', async (key, body) => { + ])('PUT /%s rejects a non-array value with 422', async (key, body) => { const record = await seedRecord(t.ctx); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status, data } = await req(t.app, 'PUT', `/records/${record.id}/${key}`, { token: TEST_TOKEN, body, }); @@ -1745,25 +1748,25 @@ describe('Records', () => { expect(details.some((d) => d.path === key)).toBe(true); }); - it('rejects an unrecognized change-set key with 400', async () => { + it('rejects an unrecognized body key with 400', async () => { const record = await seedRecord(t.ctx); - const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status } = await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { token: TEST_TOKEN, body: { unlistd: true }, }); expect(status).toBe(400); }); - it('rejects an empty change set with 400', async () => { + it('rejects a body without unlisted with 400', async () => { const record = await seedRecord(t.ctx); - const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status } = await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { token: TEST_TOKEN, body: {}, }); expect(status).toBe(400); }); - it('returns 403 when a non-owner without write access names the key', async () => { + it('returns 403 to a non-owner without write access', async () => { const record = await t.ctx.stack.create( NOTE_TYPE_ID, { body: 'x' }, @@ -1778,8 +1781,8 @@ describe('Records', () => { }, ); const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); - const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { - token, + const { status } = await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { + token: token, body: { unlisted: true }, }); expect(status).toBe(403); @@ -1788,7 +1791,7 @@ describe('Records', () => { it('returns 409 on a soft-deleted record', async () => { const record = await seedRecord(t.ctx); await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN }); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status, data } = await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { token: TEST_TOKEN, body: { unlisted: true }, }); @@ -1796,23 +1799,13 @@ describe('Records', () => { expect((data as { error: { code: string } }).error.code).toBe('conflict'); }); - it('does not check an If-Match sent beside a change set that only names it', async () => { + it('checks If-Match, though the write bumps nothing', async () => { const record = await seedRecord(t.ctx); - const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status, data } = await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { token: TEST_TOKEN, body: { unlisted: true }, headers: { 'If-Match': `"${record.version + 99}"` }, }); - expect(status).toBe(200); - }); - - it('fences the whole call when a bumping key is named beside it', async () => { - const record = await seedRecord(t.ctx); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { - token: TEST_TOKEN, - body: { unlisted: true, contentPatch: { body: 'moved too' } }, - headers: { 'If-Match': `"${record.version + 99}"` }, - }); expect(status).toBe(412); expect((data as { error: { code: string } }).error.code).toBe('version_conflict'); }); @@ -1821,7 +1814,7 @@ describe('Records', () => { describe('includeUnlisted', () => { it('is excluded by default from GET /records and POST /records/query', async () => { const record = await seedRecord(t.ctx); - await t.ctx.stack.mutate(record.id, { unlisted: true }); + await t.ctx.stack.setUnlisted(record.id, true); const getRes = await req(t.app, 'GET', '/records', { token: TEST_TOKEN }); expect( @@ -1838,7 +1831,7 @@ describe('Records', () => { it('surfaces unlisted records for the owner acting alone', async () => { const record = await seedRecord(t.ctx); - await t.ctx.stack.mutate(record.id, { unlisted: true }); + await t.ctx.stack.setUnlisted(record.id, true); const { data } = await req(t.app, 'GET', '/records?includeUnlisted=true', { token: TEST_TOKEN, @@ -1875,9 +1868,9 @@ describe('Records', () => { it(`rejects "${key}" as a patch field with 422`, async () => { const record = await seedRecord(t.ctx); const body = JSON.parse(`{"${key}":{"evil":true}}`); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: body }, + body: body, }); expect(status).toBe(422); const details = (data as { error: { details: Array<{ path: string }> } }).error.details; @@ -2140,14 +2133,17 @@ describe('Records', () => { expect(status).toBe(401); }); - it('returns 422 for an unregistered toTypeId', async () => { + // No schema to fail against, so not a validation error: naming an + // undefined type is the 400 every write answers it with. See + // docs/spec/wire-format.md § Error responses. + it('returns 400 for an unregistered toTypeId', async () => { const record = await seedRecord(t.ctx); const { status, data } = await req(t.app, 'POST', `/records/${record.id}/migrate`, { token: TEST_TOKEN, body: { toTypeId: 'com.example.test/nonexistent@1', content: { body: 'x' } }, }); - expect(status).toBe(422); - expect((data as { error: { code: string } }).error.code).toBe('validation'); + expect(status).toBe(400); + expect((data as { error: { code: string } }).error.code).toBe('bad_request'); }); it('returns 422 when content fails the target schema', async () => { diff --git a/tests/routes/strictInput.test.ts b/tests/routes/strictInput.test.ts index 0fd09b1..de39667 100644 --- a/tests/routes/strictInput.test.ts +++ b/tests/routes/strictInput.test.ts @@ -37,13 +37,11 @@ describe('unrecognized request input', () => { it.each([ ['/tokens', { principalId: OTHER_ENTITY_ID, extra: true }], - ['/entity', { content: {}, extra: true }], ['/attachments/gc', { dryRun: true, extra: true }], ['/auth/challenge', { did: OTHER_ENTITY_ID, extra: true }], ['/records/1hk153x00001/migrate', { toTypeId: 'x', content: {}, extra: true }], - ] as const)('POST/PATCH %s refuses an unknown body key', async (path, body) => { - const method = path === '/entity' ? 'PATCH' : 'POST'; - const { status, data } = await req(t.app, method, path, { token: TEST_TOKEN, body }); + ] as const)('POST %s refuses an unknown body key', async (path, body) => { + const { status, data } = await req(t.app, 'POST', path, { token: TEST_TOKEN, body }); expect(status).toBe(400); expect(JSON.stringify(data)).toContain('extra'); }); @@ -66,16 +64,11 @@ describe('unrecognized request input', () => { expect(status).toBe(422); }); - it('PATCH /entity refuses non-object content with 422', async () => { - const { status } = await req(t.app, 'PATCH', '/entity', { - token: TEST_TOKEN, - body: { content: [] }, - }); - expect(status).toBe(422); - }); - - it('PATCH /entity requires content', async () => { - const { status } = await req(t.app, 'PATCH', '/entity', { token: TEST_TOKEN, body: {} }); - expect(status).toBe(400); - }); + it.each(['/entity/content', '/records/1hk153x00001/content'])( + 'PATCH %s refuses a body that is not an object with 400', + async (path) => { + const { status } = await req(t.app, 'PATCH', path, { token: TEST_TOKEN, body: [] }); + expect(status).toBe(400); + }, + ); }); diff --git a/tests/routes/tombstones.test.ts b/tests/routes/tombstones.test.ts index c8bcbde..937f32d 100644 --- a/tests/routes/tombstones.test.ts +++ b/tests/routes/tombstones.test.ts @@ -30,10 +30,21 @@ describe('tombstone projection and mutation refusal', () => { await t.cleanup(); }); - it('GET on a soft-deleted record projects a tombstone: content emptied, deletedAt set', async () => { + it('GET on a soft-deleted record answers 404 unless asked to include it', async () => { const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN }); - const { status, data } = await req(t.app, 'GET', `/records/${record.id}`, { + const hidden = await req(t.app, 'GET', `/records/${record.id}`, { token: TEST_TOKEN }); + expect(hidden.status).toBe(404); + const bad = await req(t.app, 'GET', `/records/${record.id}?includeDeleted=1`, { + token: TEST_TOKEN, + }); + expect(bad.status).toBe(400); + }); + + it('GET ?includeDeleted=true projects a tombstone: content emptied, deletedAt set', async () => { + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); + await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN }); + const { status, data } = await req(t.app, 'GET', `/records/${record.id}?includeDeleted=true`, { token: TEST_TOKEN, }); expect(status).toBe(200); @@ -73,12 +84,28 @@ describe('tombstone projection and mutation refusal', () => { } }); + it.each(['associations', 'permissions'])( + 'GET /records/:id/%s answers 404 for a soft-deleted record, as GET /records/:id does', + async (aspect) => { + const record = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'x' }, + { permissions: [{ kind: 'anyone', label: 'read' }] }, + ); + await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN }); + const { status } = await req(t.app, 'GET', `/records/${record.id}/${aspect}`, { + token: TEST_TOKEN, + }); + expect(status).toBe(404); + }, + ); + it('mutating a soft-deleted record is refused with 409, not applied to the tombstone', async () => { const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'z' }); await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN }); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { title: 'edited' } }, + body: { title: 'edited' }, }); expect(status).toBe(409); expect((data as { error: { code: string } }).error.code).toBe('conflict'); @@ -91,7 +118,7 @@ describe('tombstone projection and mutation refusal', () => { token: TEST_TOKEN, }); expect(status).toBe(200); - const versions = data as Array<{ content: Record }>; + const { versions } = data as { versions: Array<{ content: Record }> }; expect(versions[0]!.content).toEqual({ title: 'w' }); }); }); diff --git a/tests/routes/versions.test.ts b/tests/routes/versions.test.ts index ba23e94..e28bc6e 100644 --- a/tests/routes/versions.test.ts +++ b/tests/routes/versions.test.ts @@ -21,9 +21,9 @@ describe('Versions', () => { async function createAndPatch() { const record = await t.ctx.stack.create(TYPE_ID, { body: 'v1' }); - await req(t.app, 'PATCH', `/records/${record.id}`, { + await req(t.app, 'PATCH', `/records/${record.id}/content`, { token: TEST_TOKEN, - body: { contentPatch: { body: 'v2' } }, + body: { body: 'v2' }, }); return record; } @@ -34,7 +34,7 @@ describe('Versions', () => { token: TEST_TOKEN, }); expect(status).toBe(200); - expect((data as unknown[]).length).toBe(1); + expect((data as { versions: unknown[] }).versions).toHaveLength(1); }); it('GET /records/:id/versions/:version returns one version', async () => { @@ -155,7 +155,7 @@ describe('Versions', () => { token, }); expect(status).toBe(200); - expect((data as unknown[]).length).toBe(1); + expect((data as { versions: unknown[] }).versions).toHaveLength(1); }); }); @@ -181,7 +181,7 @@ describe('Versions', () => { const { data } = await req(t.app, 'GET', `/records/${record.id}/versions`, { token: TEST_TOKEN, }); - const snapshot = (data as Array>)[0]; + const snapshot = (data as { versions: Array> }).versions[0]!; expect(snapshot.content).toEqual({ body: 'v1' }); expect(snapshot.parentId).toBeUndefined(); expect(snapshot.unlistedAt).toBeUndefined(); @@ -195,13 +195,15 @@ describe('Versions', () => { // Associations, the ACL and listing state are all no-bump tiers: their // inverse is the same shape as the forward operation, so none of them // needs a snapshot to be recoverable from. - await t.ctx.stack.associate(record.id, { kind: 'tag', label: 'starred' }); + await t.ctx.stack.editAssociations(record.id, [ + { op: 'add', association: { kind: 'tag', label: 'starred' } }, + ]); expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(1); - await t.ctx.stack.mutate(record.id, { permissions: [{ kind: 'anyone', label: 'read' }] }); + await t.ctx.stack.setAccess(record.id, [{ kind: 'anyone', label: 'read' }]); expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(1); - await t.ctx.stack.mutate(record.id, { unlisted: true }); + await t.ctx.stack.setUnlisted(record.id, true); expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(1); await t.ctx.stack.patchContent(record.id, { body: 'v2' }); diff --git a/tests/setup.ts b/tests/setup.ts index 6087412..fc8589d 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -4,6 +4,7 @@ import { createApp } from '../src/app.js'; import type { StackContext } from '../src/stack.js'; import type { Hono } from 'hono'; import type { AppEnv } from '../src/app.js'; +import type { BaseId } from '@haverstack/core'; import { TEST_ENTITY_ID, TEST_TOKEN, @@ -46,6 +47,7 @@ export async function buildTestApp(opts: TestContextOpts = { timezone: 'UTC' }): await ctx.stack.close(); await ctx.tokens.close(); ctx.nonces.close(); + ctx.installRequests.close(); // Remove the whole temp directory (includes the .db file and attachments/). await rm(dirname(dbPath), { recursive: true, force: true }).catch(() => {}); }; @@ -91,3 +93,8 @@ export async function req( } return { status: res.status, data }; } + +/** The family a versioned TypeId belongs to — what a type-level grant names. */ +export function familyOf(typeId: string): BaseId { + return typeId.slice(0, typeId.indexOf('@')) as BaseId; +} diff --git a/tests/shutdown.test.ts b/tests/shutdown.test.ts index 0a25b26..983ec74 100644 --- a/tests/shutdown.test.ts +++ b/tests/shutdown.test.ts @@ -17,6 +17,7 @@ function fakeCtx(): StackContext { } as unknown as StackContext['stack'], tokens: { close: vi.fn().mockResolvedValue(undefined) } as unknown as StackContext['tokens'], nonces: { close: vi.fn() } as unknown as StackContext['nonces'], + installRequests: { close: vi.fn() } as unknown as StackContext['installRequests'], queryWorker: { close: vi.fn().mockResolvedValue(undefined), } as unknown as StackContext['queryWorker'], @@ -46,6 +47,7 @@ describe('createShutdownHandler', () => { expect(ctx.stack.close).toHaveBeenCalled(); expect(ctx.tokens.close).toHaveBeenCalled(); expect(ctx.nonces.close).toHaveBeenCalled(); + expect(ctx.installRequests.close).toHaveBeenCalled(); }); it('does not force connections closed if close() finishes just under the deadline', async () => { diff --git a/tests/stack.test.ts b/tests/stack.test.ts index 70545ac..8eb9bea 100644 --- a/tests/stack.test.ts +++ b/tests/stack.test.ts @@ -43,7 +43,7 @@ describe('initStack double-open protection', () => { // the scenario being tested. writeFileSync(`${dbPath}.lock`, JSON.stringify({ pid: process.ppid })); - // Second call finds the db already exists (openOrInitialize's open path) + // Second call finds the db already exists (open()'s ifMissing path) // and hits the same lock check LocalAdapter.open() always enforced. await expect(initStack(config, logger)).rejects.toThrow(/in use by another process/); }); @@ -121,7 +121,7 @@ describe('initStack bootstrap', () => { ctx.nonces.close(); }); - it('registers all eight commons types when SEED_COMMONS_TYPES is set', async () => { + it('registers every commons type when SEED_COMMONS_TYPES is set', async () => { dbPath = tempDbPath(); const config = { ...testConfig(dbPath), seedCommonsTypes: true }; @@ -133,10 +133,12 @@ describe('initStack bootstrap', () => { 'org.haverstack/article@1', 'org.haverstack/bookmark@1', 'org.haverstack/contact@1', + 'org.haverstack/image@1', 'org.haverstack/note@1', 'org.haverstack/page@1', - 'org.haverstack/photo@1', 'org.haverstack/place@1', + 'org.haverstack/post@1', + 'org.haverstack/site@1', 'org.haverstack/task@1', ].sort(), );