diff --git a/docs/api.md b/docs/api.md index 80c54d3..dc78be5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -84,9 +84,12 @@ The retryable column is why these carry a `code` at all rather than being bodyle | DELETE | `/records/:id` | Required | Soft-delete (or hard with `?hard=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 | @@ -100,7 +103,7 @@ The retryable column is why these carry a `code` at all rather than being bodyle { "contentPatch": { "title": "Q3 plan" }, "parentId": "1hk153x0000f", - "permissions": [{ "access": "public" }], + "permissions": [{ "kind": "anyone", "label": "read" }], "associations": [{ "kind": "tag", "label": "planning" }], "unlisted": false } @@ -110,6 +113,8 @@ Keys are read for **presence, not truthiness** — `unlisted: false` and `parent `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. +**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. + 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. 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). @@ -118,7 +123,9 @@ An unrecognized top-level key is `400`; a key whose value is the wrong shape is `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. -Version history requires the same access `PATCH`/`DELETE` require — a write-holder, or the owner — not plain read. A read-only requester gets `403`. +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`. @@ -132,7 +139,9 @@ A `relationship` association's `target` is a discriminated union naming which id { "kind": "relationship", "label": "syndicated-to", "target": { "scope": "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. +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). + +**`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. `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. @@ -140,7 +149,7 @@ A change set's `permissions` key replaces every permission on the record and ans `createdAt`/`updatedAt` are honored only from the stack owner acting alone (undelegated, authenticated as the owner) — the same tier that gates hard delete, `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`, `POST /records/:id/migrate`, `POST /records/:id/associations`, and `POST /records/:id/associations/delete`. One `If-Match` fences a whole multi-aspect change set. 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. +`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. ### Query parameters @@ -173,6 +182,64 @@ A relationship association's target names one of three scopes — a Record, an i 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 hard delete 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. + +## Journal + +Every write that emits a change event also appends one entry to the record's journal — a second durable tier beside version history, holding what the change moved, who moved it, and the association deltas nothing else retains. A snapshot answers _what could be put back_; an entry answers _what happened_. + +``` +GET /records/:id/journal — the log, oldest first +GET /records/:id/journal?sinceSeq=4 — entries after seq 4, exclusive +GET /records/:id/journal?limit=50 — at most 50 entries +``` + +```json +{ + "entries": [ + { + "seq": 2, + "at": "2026-08-13T12:00:00.000Z", + "kind": "changed", + "ops": ["associate"], + "version": 1, + "typeId": "com.example/note@1", + "actor": { "entityId": "did:key:z6Mk..." }, + "associations": [ + { + "op": "repoint", + "association": { + "kind": "attachment", + "label": "avatar", + "fileId": "abc123", + "attachmentRecordId": "1hk153x00002" + }, + "previous": { + "kind": "attachment", + "label": "avatar", + "fileId": "abc123", + "attachmentRecordId": "1hk153x00001" + } + } + ] + } + ], + "cursor": null +} +``` + +**`associations` is the field a client can get nowhere else.** Each element is a tagged edit — `add`, `repoint` or `remove` — and the two that displace something carry `previous` beside the thing that displaced it, so an inverse is read off one element rather than joined across two lists. It keeps what an add overwrote in place and what a removal took away, annotation included. The [change feed](#change-feed) reports only what is current, across two flat lists, and no snapshot carries associations at all. + +**`seq` is the entry's own** — a dense integer from 1 per record, never the feed's opaque whole-stack cursor. No value crosses between them. + +**`cursor` is the only end-of-log signal.** This server caps a page at 500 entries and reports the `seq` to send back as `sinceSeq`; it is `null` once nothing follows. A page shorter than the `limit` asked for must not be read as an exhausted log. Omitting `limit` reads the whole log by contract, so no default page size is supplied on the caller's behalf — the cap is announced through `cursor` instead. + +**`previousParentId` is the one field on any response where `null` is a value rather than an input spelling.** Absent means the entry is not a reparent; present and `null` means the record moved out of the root. Everywhere else the two collapse to absent, which here would lose which of them happened — and nothing else keeps a move's origin at all. `parentId`, which says where the record landed, follows the ordinary rule and is absent for the root. + +**Gated on the mutate surface, exactly as the version endpoints are.** A write-holder, the owner, or the creator passes; a plain reader gets `403`. **Authority elements are omitted for a requester who may not reshare the record**: an entry's `ops` still carries `permissions`, so the response names that the ACL moved, but the `permission` and `anyone` elements are dropped and the `associations` field goes with them when nothing else is in it. Entries are never dropped, so `seq` stays dense. + +A record the server does not have — never created, or hard-deleted — is `404`, never an empty log: a hard delete destroys the journal, and an empty log means "nothing changed" unconditionally. The 404-over-403 rule applies as everywhere, so a requester who cannot read the record gets `404` rather than the `403` above. + ## Unlisted `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. @@ -181,7 +248,7 @@ A soft-deleted record refuses further mutation: `PATCH`, `POST .../associations` PATCH /records/:id body: { "unlisted": boolean } ``` -Answers `200` with the updated record — it bumps `version` like any other mutation, and is snapshotted to version history the same way — carrying `unlistedAt` when `true`, absent when `false`. Accepts the same optional `If-Match` precondition as every other mutating endpoint, and 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 — 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. **`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. @@ -215,7 +282,9 @@ Query parameters, all optional and composable: `typeId` (repeatable, matched by 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. -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 `entityId` filters on). `kind` is the coarse branch a handler can be complete on; `ops` names every aspect the version actually moved (`create`, `patch`, `associate`, `dissociate`, `permissions`, `reparent`, `migrate`, `restore`, `delete`, `undelete`, `hard-delete`, `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. `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 `purged` frame — from a hard delete — carries none of `record`, `parentId`, or the record's own author, whatever the connection asked for: hard delete is the erasure primitive, and a frame naming what was destroyed is deliberately all that survives it. +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 `entityId` 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`, `permissions`, `reparent`, `migrate`, `restore`, `delete`, `undelete`, `hard-delete`, `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. + +A `purged` frame — from a hard delete — carries none of `record`, `parentId`, or the record's own author, whatever the connection asked for: hard delete is the erasure primitive, and a frame naming what was destroyed is deliberately all that survives it. A record this connection may not read produces no frame at all — not an empty or redacted one — the same reasoning that keeps a count of the whole match off the query envelope: the existence of a change is itself a disclosure. @@ -262,17 +331,29 @@ An `attachment` association (`{ kind: "attachment", label, fileId }`, set via `P ## Permissions -Records are private by default (readable only by the stack owner). The `permissions` field controls access: +Records are private by default (readable only by the stack owner). A record's `permissions` field is a list of **authority associations** — the bit is the element's label, so granting and revoking are a plain add and remove: ```json -{ "access": "public" } -{ "access": "entity", "entityId": "...", "read": true, "write": false } -{ "access": "group", "groupId": "...", "read": true, "write": true } +{ "kind": "permission", "label": "read", "grantee": { "scope": "entity", "entityId": "did:key:..." } } +{ "kind": "permission", "label": "write", "grantee": { "scope": "group", "groupId": "1hk153x0000g", "role": "member" } } +{ "kind": "anyone", "label": "read" } ``` +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) +``` + +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`. + +**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. + Non-owner entities authenticate either via the DID challenge-response handshake above or with tokens issued by the owner via `POST /tokens`, and are subject to both record-level permissions and create-grant checks on write. -A type-level grant names a specific entity (`grant(, ...)`) or no entity at all. **A grant with no grantee is a grant to the public.** It resolves for any authenticated entity, and since `POST /auth/challenge` lets anyone authenticate by generating a keypair, "any authenticated entity" means anyone who can reach the server — there is no vouching step in between. Grant a DID by name unless you genuinely mean everyone. (Note the asymmetry with record-level permissions above, which have a `group` tier between `entity` and `public`; type-level grants have no group tier, so a set of entities is expressed as one named grant each.) +A type-level grant names its grantee affirmatively in `content.grantee`, in one of three tiers: `{ "kind": "entity", "entityId": ... }`, `{ "kind": "group", "groupId": ..., "role": "member" | "admin" }`, or `{ "kind": "authenticated" }`. The field is required, so no tier is reachable by omission — a body that lost it is `422` rather than stored as a grant to everyone. **`{ "kind": "authenticated" }` is a grant to the public**: it resolves for any authenticated entity, and since `POST /auth/challenge` lets anyone authenticate by generating a keypair, that means anyone who can reach the server — there is no vouching step in between. Name a DID unless you genuinely mean everyone. It is distinct from a record permission's `anyone`, which reaches an unauthenticated requester too. ### Principal and subject diff --git a/package.json b/package.json index ffa633f..088a4d7 100644 --- a/package.json +++ b/package.json @@ -41,10 +41,10 @@ "format:check": "prettier --check ." }, "dependencies": { - "@haverstack/adapter-local": "^0.31.0", - "@haverstack/commons": "^0.26.0", - "@haverstack/core": "^0.32.0", - "@haverstack/wire-types": "^0.31.0", + "@haverstack/adapter-local": "^0.36.0", + "@haverstack/commons": "^0.31.0", + "@haverstack/core": "^0.37.0", + "@haverstack/wire-types": "^0.36.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.25.0", + "@haverstack/conformance-fixtures": "^0.30.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 3e62b61..a480f50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,17 +9,17 @@ importers: .: dependencies: '@haverstack/adapter-local': + specifier: ^0.36.0 + version: 0.36.0 + '@haverstack/commons': specifier: ^0.31.0 version: 0.31.0 - '@haverstack/commons': - specifier: ^0.26.0 - version: 0.26.0 '@haverstack/core': - specifier: ^0.32.0 - version: 0.32.0 + specifier: ^0.37.0 + version: 0.37.0 '@haverstack/wire-types': - specifier: ^0.31.0 - version: 0.31.0 + specifier: ^0.36.0 + version: 0.36.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.25.0 - version: 0.25.0 + specifier: ^0.30.0 + version: 0.30.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.31.0': - resolution: {integrity: sha512-RwUadZDm9jRc++DPni8TPsLnYGI/7gY1kdn9b2kXoStkFb8IPnlFp6ZBApnKGUCVkqsoXqvX6ET6dOX2Lnn6kA==} + '@haverstack/adapter-local@0.36.0': + resolution: {integrity: sha512-i5srwErYEYNzF/50teht6XaPSOj54Skn/9x5kfO+Q3kHb9IB3alJEangOmMSMu9vqU/i4ifnurYEgJwB6xsV5Q==} engines: {node: '>=22.5.0'} - '@haverstack/blob-adapter-disk@0.30.0': - resolution: {integrity: sha512-uvPKY5GImj7RNslw/HESWXd4KvhVIKuJ6ZIIi7kXKGemoWuFDxBo0cuvJxo3Qvwfr9pElgb1lQfyXfRp0I4syQ==} + '@haverstack/blob-adapter-disk@0.35.0': + resolution: {integrity: sha512-JWLGq0i8bBHjP3bF0qI6yVySZPuS6Vj8td9nPLi80z24s42FYFkYnylcXDHzbLFGYfD9tXyGeJjt5BIN1yBZbw==} - '@haverstack/commons@0.26.0': - resolution: {integrity: sha512-BFOmiMDzV9NhOuSK4fZv47/ooBw+kDR9ME9sZoA2sdXD4pbt3Uek3kJE0JVtDkmLm7ZyK2frNg9lJp/+kGrAcg==} + '@haverstack/commons@0.31.0': + resolution: {integrity: sha512-OhKy1IzXtZ9r6tFVwZ3fQb2Q+SbwBUu08FliU5BhwJXku/cFwVNBaOnZcrx1TQiOJ0i04ftz+WXq+BIDGblYxQ==} - '@haverstack/conformance-fixtures@0.25.0': - resolution: {integrity: sha512-IjLPUJh4Zr+JfpJ2gy0WZKmAS2tybK1/4rtytGB+LYm6hj2/K/ZXhDCjKmfFn0WYCQeVvbB+8j2RSnCFOW6GpA==} + '@haverstack/conformance-fixtures@0.30.0': + resolution: {integrity: sha512-20/m1byn9aUh96f2ny7fdAWwMgmrZdjCh4kSmv/VAVZHP9ihJOt0VLDN6DHrM6+QhxzhOA7GO/jJVp3ZAX3GWQ==} - '@haverstack/core@0.32.0': - resolution: {integrity: sha512-IPd3I9vJg3334MO+QLynGN+hULT0GG3Fv5aqvilFajJp3T4pyL1jfAQuYCD4I3ZuVZMMdsIjvQjFVB3F9v+xMg==} + '@haverstack/core@0.37.0': + resolution: {integrity: sha512-tEFL/mVpVeOsQt0BDdtEJu8PQgy5DVoScCqEKID+DrAkvcspjcXrhUsGsCe87tWHbrYUWAMH8zW0UCSNw/ET0Q==} - '@haverstack/record-adapter-sqlite@0.24.0': - resolution: {integrity: sha512-8to2k+i9ydCp/O0+SdEB5Pz6YbD6sG5NRo7pXySU4tIuG/+9iiWQ4fHL0Ig0ErYcpvIA/LjM9vMTthzJqFiPow==} + '@haverstack/record-adapter-sqlite@0.29.0': + resolution: {integrity: sha512-r62aMZRZpW+B3bkQuZ0neJtNezHYgoBMR6s/kQnpoulJUMKViG6Lvs8bfW/MoVacZI1tWSDwtc8UsqAV6sblCA==} engines: {node: '>=22.5.0'} - '@haverstack/wire-types@0.31.0': - resolution: {integrity: sha512-XoTsIfP3u72h7VMF9fq5KQi9qOvYKm3yIONdf6eNcFAnLjTk+fzylGFOISUXlUctLFWuay3xXMEHN1IWGavB5g==} + '@haverstack/wire-types@0.36.0': + resolution: {integrity: sha512-alZbtPvyCtIrdJztDHlwUqhZu3Lns38EB6x31hBo8Ns/q2aQXTMcewlgLbLT0KdqJDZkpBJDnfTKTTHJ/aoENQ==} '@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.31.0': + '@haverstack/adapter-local@0.36.0': dependencies: - '@haverstack/blob-adapter-disk': 0.30.0 - '@haverstack/core': 0.32.0 - '@haverstack/record-adapter-sqlite': 0.24.0 + '@haverstack/blob-adapter-disk': 0.35.0 + '@haverstack/core': 0.37.0 + '@haverstack/record-adapter-sqlite': 0.29.0 - '@haverstack/blob-adapter-disk@0.30.0': + '@haverstack/blob-adapter-disk@0.35.0': dependencies: - '@haverstack/core': 0.32.0 + '@haverstack/core': 0.37.0 - '@haverstack/commons@0.26.0': + '@haverstack/commons@0.31.0': dependencies: - '@haverstack/core': 0.32.0 + '@haverstack/core': 0.37.0 - '@haverstack/conformance-fixtures@0.25.0': + '@haverstack/conformance-fixtures@0.30.0': dependencies: - '@haverstack/wire-types': 0.31.0 + '@haverstack/wire-types': 0.36.0 - '@haverstack/core@0.32.0': {} + '@haverstack/core@0.37.0': {} - '@haverstack/record-adapter-sqlite@0.24.0': + '@haverstack/record-adapter-sqlite@0.29.0': dependencies: - '@haverstack/core': 0.32.0 + '@haverstack/core': 0.37.0 - '@haverstack/wire-types@0.31.0': + '@haverstack/wire-types@0.36.0': dependencies: - '@haverstack/core': 0.32.0 + '@haverstack/core': 0.37.0 '@hono/node-server@2.1.1(hono@4.13.7)': dependencies: diff --git a/src/lib/queryLimit.ts b/src/lib/queryLimit.ts index 019a93d..8b94f23 100644 --- a/src/lib/queryLimit.ts +++ b/src/lib/queryLimit.ts @@ -2,10 +2,22 @@ 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 ceiling, applied at the call site in records.ts. +// 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/routes/records.ts b/src/routes/records.ts index eccf303..4894921 100644 --- a/src/routes/records.ts +++ b/src/routes/records.ts @@ -7,16 +7,17 @@ import { readJson } from '../lib/json.js'; import { parseQueryBody, parseQueryParams, + parseJournalParams, parsePositiveInt, parseIfMatch, createOptionsFromWireRecord, changesFromWireBody, } from '@haverstack/core/wire'; -import { clampLimit } from '../lib/queryLimit.js'; -import { serializeRecord, serializeVersion } from '@haverstack/wire-types'; -import type { WireQueryResponse } from '@haverstack/wire-types'; +import { clampLimit, clampJournalLimit } from '../lib/queryLimit.js'; +import { serializeRecord, serializeVersion, serializeJournalEntry } from '@haverstack/wire-types'; +import type { WireQueryResponse, WireJournalResponse } from '@haverstack/wire-types'; import { StackValidationError, StackQueryError, StackNotFoundError } from '@haverstack/core'; -import type { Association, TypeId } from '@haverstack/core'; +import type { AuthorityAssociation, DataAssociation, TypeId } from '@haverstack/core'; // --------------------------------------------------------------------------- // Route factory @@ -119,19 +120,27 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { const id = c.req.param('id'); const auth = c.get('auth')!; const hard = new URL(c.req.url).searchParams.get('hard') === 'true'; const session = stack.forSession(auth); - await session.delete(id, { hard, ifVersion: parseIfMatch(c.req.header('If-Match')) }); - if (hard) return c.body(null, 204); - return c.json(serializeRecord((await session.get(id))!)); + const { record } = await session.deleteAndReturn(id, { + hard, + ifVersion: parseIfMatch(c.req.header('If-Match')), + }); + if (!record) throw new StackNotFoundError('Record not found'); + return c.json(serializeRecord(record)); }); // POST /records/:id/undelete — reverses a soft delete; idempotent @@ -156,6 +165,31 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { + const id = c.req.param('id'); + const auth = c.get('auth')!; + const body = await readJson(c); + const updated = await stack.forSession(auth).grantAccess(id, body); + 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', requireAuth(), async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth')!; + const body = await readJson(c); + const updated = await stack.forSession(auth).revokeAccess(id, body); + return c.json(serializeRecord(updated)); + }); + // ------------------------------------------------------------------ // Associations // ------------------------------------------------------------------ @@ -173,14 +207,17 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { const id = c.req.param('id'); const auth = c.get('auth')!; - const body = await readJson(c); + const body = await readJson(c); if (!body.kind || !body.label) throw new StackQueryError('kind and label are required'); - const updated = await stack - .forSession(auth) - .associate(id, body, { ifVersion: parseIfMatch(c.req.header('If-Match')) }); + const updated = await stack.forSession(auth).associate(id, body); return c.json(serializeRecord(updated)); }); @@ -190,13 +227,41 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { const id = c.req.param('id'); const auth = c.get('auth')!; - const body = await readJson(c); - const updated = await stack - .forSession(auth) - .dissociate(id, body, { ifVersion: parseIfMatch(c.req.header('If-Match')) }); + const body = await readJson(c); + const updated = await stack.forSession(auth).dissociate(id, body); return c.json(serializeRecord(updated)); }); + // ------------------------------------------------------------------ + // Journal + // ------------------------------------------------------------------ + + // GET /records/:id/journal — the change journal, oldest first. Not + // optional and never answered empty for a record this server doesn't + // hold: an empty log means "nothing changed" unconditionally, so a + // missing or purged record is the 404 ScopedStack raises. + // + // The mutate-surface gate and the authority-element projection a + // non-resharer gets are both ScopedStack.getJournal()'s, so this route + // decodes the window, bounds the page and serializes. `cursor` is the + // only end-of-log signal — a page filled to the ceiling carries the seq + // to resume from, whether or not the log ends there. See + // docs/spec/journal.md and docs/spec/wire-format.md § Journal. + app.get('/:id/journal', async (c) => { + 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, + }; + return c.json(body); + }); + // ------------------------------------------------------------------ // Versions // ------------------------------------------------------------------ diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index 1bd30f8..c69da17 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -39,6 +39,9 @@ import { associateFixtures, dissociateFixtures, permissionsChangeFixtures, + grantAccessFixtures, + revokeAccessFixtures, + getJournalFixtures, getVersionsFixtures, getVersionFixtures, getVersionsAfterMutateFixtures, @@ -57,9 +60,9 @@ import { AUTH_FIXTURE_DID, AUTH_FIXTURE_NONCE, } from '@haverstack/conformance-fixtures'; -import type { WireRecord } from '@haverstack/wire-types'; +import type { WireRecord, WireJournalResponse } from '@haverstack/wire-types'; import { generateId, hashSchema } from '@haverstack/core'; -import type { Association } from '@haverstack/core'; +import type { DataAssociation } from '@haverstack/core'; import { buildTestApp, req, @@ -103,6 +106,7 @@ const NOTE_TYPE = 'com.example/note@1'; const NOTE_TYPE_V2 = 'com.example/note@2'; const COMMENT_TYPE = 'com.example/comment@1'; const ATTACHMENT_TYPE = '_attachment@1'; +const GROUP_TYPE = '_group@1'; // Reusing the fixtures' own placeholder identities as real test principals // where possible — nothing requires DID-shaped entityIds for a record-level @@ -259,7 +263,9 @@ describe('createRecord fixtures', () => { )!; handled.add(fixture.name); const body = withFreshId(fixture.requestBody as WireRecord & { appId?: string }); - await t.ctx.stack.grant(CONTRIBUTOR_ID, [{ actions: ['create'], typeId: body.typeId }]); + await t.ctx.stack.grant({ kind: 'entity', entityId: CONTRIBUTOR_ID }, [ + { actions: ['create'], typeId: body.typeId }, + ]); const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); const { status, data } = await req(t.app, 'POST', fixture.path, { token, body }); expect(status).toBe(fixture.responseStatus); @@ -278,8 +284,12 @@ describe('createRecord fixtures', () => { )!; handled.add(fixture.name); const body = withFreshId(fixture.requestBody as WireRecord & { appId?: string }); - await t.ctx.stack.grant(BLOG_SUBJECT_ID, [{ actions: ['create'], typeId: body.typeId }]); - await t.ctx.stack.grant(BLOG_APP_ID, [{ actions: ['create'], typeId: body.typeId }]); + await t.ctx.stack.grant({ kind: 'entity', entityId: BLOG_SUBJECT_ID }, [ + { actions: ['create'], typeId: body.typeId }, + ]); + await t.ctx.stack.grant({ kind: 'entity', entityId: BLOG_APP_ID }, [ + { actions: ['create'], typeId: body.typeId }, + ]); const { token } = await t.ctx.adapter.createToken(BLOG_APP_ID, { onBehalfOf: BLOG_SUBJECT_ID }); const { status, data } = await req(t.app, 'POST', fixture.path, { token, body }); expect(status).toBe(fixture.responseStatus); @@ -330,7 +340,7 @@ 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.grant(CONTRIBUTOR_ID, [ + await t.ctx.stack.grant({ kind: 'entity', entityId: CONTRIBUTOR_ID }, [ { actions: ['read-any'], typeId: NOTE_TYPE }, { actions: ['create'], typeId: ATTACHMENT_TYPE }, ]); @@ -364,6 +374,30 @@ describe('createRecord fixtures', () => { expect(typeof d.unlistedAt).toBe('string'); }); + test('create-grant-record-group-grantee — a grant names its grantee affirmatively', async () => { + const fixture = createRecordFixtures.find( + (f) => f.name === 'create-grant-record-group-grantee', + )!; + handled.add(fixture.name); + const group = await t.ctx.stack.create(GROUP_TYPE, { name: 'Reviewers' }); + const body = withFreshId(fixture.requestBody as WireRecord); + const content = body.content as { grantee: { groupId: string } }; + const { status, data } = await req(t.app, 'POST', fixture.path, { + token: TEST_TOKEN, + body: { + ...body, + content: { ...content, grantee: { ...content.grantee, groupId: group.id } }, + }, + }); + expect(status).toBe(fixture.responseStatus); + // No tier is reachable by omission, so the grantee travels whole — + // role included, since admin is the narrower set. + expect((data as { content: unknown }).content).toEqual({ + ...content, + grantee: { ...content.grantee, groupId: group.id }, + }); + }); + test('coverage', () => { assertCoverage( createRecordFixtures.map((f) => f.name), @@ -580,7 +614,18 @@ describe('patchContent fixtures', () => { { title: 'original' }, { entityId: TEST_ENTITY_ID, - permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: true }], + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + }, + { + kind: 'permission', + label: 'write', + grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + }, + ], }, ); const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); @@ -722,7 +767,7 @@ describe('associate fixtures', () => { test('associate-attachment-record-id', async () => { const fixture = associateFixtures.find((f) => f.name === 'associate-attachment-record-id')!; handled.add(fixture.name); - const body = fixture.requestBody as Association & { + const body = fixture.requestBody as DataAssociation & { kind: 'attachment'; fileId: string; attachmentRecordId: string; @@ -767,7 +812,7 @@ describe('dissociate fixtures', () => { const record = await t.ctx.stack.create( NOTE_TYPE, { title: 'x' }, - { associations: [fixture.requestBody as Association] }, + { associations: [fixture.requestBody as DataAssociation] }, ); const { status } = await req(t.app, 'POST', `/records/${record.id}/associations/delete`, { token: TEST_TOKEN, @@ -783,7 +828,7 @@ describe('dissociate fixtures', () => { test('dissociate-attachment-by-identity', async () => { const fixture = dissociateFixtures.find((f) => f.name === 'dissociate-attachment-by-identity')!; handled.add(fixture.name); - const body = fixture.requestBody as Association & { kind: 'attachment'; fileId: string }; + const body = fixture.requestBody as DataAssociation & { kind: 'attachment'; fileId: string }; const uploaded = await t.ctx.stack.putAttachment( new Uint8Array([1, 2, 3]), 'image/png', @@ -825,15 +870,26 @@ describe('dissociate fixtures', () => { describe('permissions change fixtures', () => { const handled = new Set(); - test('set-permissions-public', async () => { - const fixture = permissionsChangeFixtures.find((f) => f.name === 'set-permissions-public')!; + test('set-permissions-anyone', async () => { + const fixture = permissionsChangeFixtures.find((f) => f.name === 'set-permissions-anyone')!; handled.add(fixture.name); - const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); - const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const record = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'x' }, + { associations: [{ kind: 'tag', label: 'draft' }] }, + ); + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: fixture.requestBody, }); 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); }); @@ -846,7 +902,7 @@ describe('permissions change fixtures', () => { const record = await t.ctx.stack.create( NOTE_TYPE, { title: 'x' }, - { permissions: [{ access: 'public' }] }, + { permissions: [{ kind: 'anyone', label: 'read' }] }, ); const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, @@ -869,6 +925,362 @@ describe('permissions change fixtures', () => { }); }); +// ------------------------------------------------------- +// Record-level grant/revoke — the amending spelling of the permissions +// key, carrying the reshare gate rather than the write bit. +// ------------------------------------------------------- + +describe('grantAccess / revokeAccess fixtures', () => { + const handled = new Set(); + + test('grant-access-entity', async () => { + const fixture = grantAccessFixtures.find((f) => f.name === 'grant-access-entity')!; + handled.add(fixture.name); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }); + const element = { + ...fixture.requestBody, + grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + }; + + const { status, data } = await req(t.app, 'POST', `/records/${record.id}/permissions`, { + token: TEST_TOKEN, + body: element, + }); + 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(CONTRIBUTOR_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')!; + 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 element = { + ...fixture.requestBody, + grantee: { scope: 'group', groupId: group.id, role: 'admin' }, + }; + + const { status, data } = await req(t.app, 'POST', `/records/${record.id}/permissions`, { + token: TEST_TOKEN, + body: element, + }); + expect(status).toBe(fixture.responseStatus); + // The role travels verbatim — there is no absent-role spelling for a + // group grantee to fall back on. + 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: { scope: '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('revoke-access-anyone', async () => { + const fixture = revokeAccessFixtures.find((f) => f.name === 'revoke-access-anyone')!; + handled.add(fixture.name); + const record = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'Hello' }, + { permissions: [{ kind: 'anyone', label: 'read' }] }, + ); + + const { status, data } = await req(t.app, 'POST', `/records/${record.id}/permissions/delete`, { + token: TEST_TOKEN, + body: fixture.requestBody, + }); + 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); + }); + + test('carries 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: { scope: 'entity', entityId: WRITER_ID }, + }, + { + kind: 'permission', + label: 'write', + grantee: { scope: 'entity', entityId: WRITER_ID }, + }, + ], + }, + ); + const { token } = await t.ctx.adapter.createToken(WRITER_ID); + const { status } = await req(t.app, 'POST', `/records/${record.id}/permissions`, { + token, + body: { kind: 'anyone', label: 'read' }, + }); + expect(status).toBe(403); + }); + + test('coverage', () => { + assertCoverage( + [...grantAccessFixtures.map((f) => f.name), ...revokeAccessFixtures.map((f) => f.name)], + handled, + new Set(), + ); + }); +}); + +// ------------------------------------------------------- +// Journal — the second durable tier, and the only place an association's +// prior state survives. +// ------------------------------------------------------- + +describe('journal fixtures', () => { + const handled = new Set(); + + test('get-journal-reads-the-whole-log-oldest-first', async () => { + const fixture = getJournalFixtures.find( + (f) => f.name === 'get-journal-reads-the-whole-log-oldest-first', + )!; + handled.add(fixture.name); + 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' }, + }); + + const { status, data } = await req(t.app, 'GET', `/records/${record.id}/journal`, { + token: TEST_TOKEN, + }); + expect(status).toBe(fixture.responseStatus); + const body = data as WireJournalResponse; + expect(body.entries.map((e) => e.seq)).toEqual([1, 2]); + expect(body.entries.map((e) => e.ops)).toEqual([['create'], ['associate']]); + // version stands still across the associate, which is why seq is the + // only ordering the log carries. + expect(body.entries.every((e) => e.version === 1)).toBe(true); + expect(body.entries[1]!.associations).toEqual([ + { op: 'add', association: { kind: 'tag', label: 'starred' } }, + ]); + expect(body.cursor).toBeNull(); + }); + + test('get-journal-page-reports-a-cursor-to-resume-from', async () => { + const fixture = getJournalFixtures.find( + (f) => f.name === 'get-journal-page-reports-a-cursor-to-resume-from', + )!; + handled.add(fixture.name); + 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' }, + }); + + const page = await req(t.app, 'GET', `/records/${record.id}/journal?sinceSeq=0&limit=1`, { + token: TEST_TOKEN, + }); + expect(page.status).toBe(fixture.responseStatus); + const first = page.data as WireJournalResponse; + expect(first.entries.map((e) => e.seq)).toEqual([1]); + // cursor is the only end-of-log signal, and carries the seq to send + // back as sinceSeq. + expect(first.cursor).toBe(1); + + const resumed = await req( + t.app, + 'GET', + `/records/${record.id}/journal?sinceSeq=${first.cursor}`, + { token: TEST_TOKEN }, + ); + expect((resumed.data as WireJournalResponse).entries.map((e) => e.seq)).toEqual([2]); + }); + + test('get-journal-entry-keeps-what-an-associate-overwrote', async () => { + const fixture = getJournalFixtures.find( + (f) => f.name === 'get-journal-entry-keeps-what-an-associate-overwrote', + )!; + handled.add(fixture.name); + const first = await t.ctx.stack.putAttachment( + new Uint8Array([1, 2, 3]), + 'image/png', + 'embed.png', + ); + const fileId = first.content.fileId; + // A second _attachment record naming the same bytes, which is what a + // re-point moves the reference to. + const second = await t.ctx.stack.create(ATTACHMENT_TYPE, { + fileId, + mimeType: 'image/png', + size: 3, + filename: 'embed.png', + }); + + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }); + const association = { kind: 'attachment', label: 'embed', fileId }; + await req(t.app, 'POST', `/records/${record.id}/associations`, { + token: TEST_TOKEN, + body: { ...association, attachmentRecordId: first.id }, + }); + await req(t.app, 'POST', `/records/${record.id}/associations`, { + token: TEST_TOKEN, + body: { ...association, attachmentRecordId: second.id }, + }); + + const { status, data } = await req(t.app, 'GET', `/records/${record.id}/journal?sinceSeq=2`, { + token: TEST_TOKEN, + }); + expect(status).toBe(fixture.responseStatus); + const entry = (data as WireJournalResponse).entries[0]!; + expect(entry.ops).toEqual(['associate']); + // Both halves ride one element: what stands now, and what it displaced. + // Nothing else retains the overwritten attachmentRecordId. + expect(entry.associations).toEqual([ + { + op: 'repoint', + association: { ...association, attachmentRecordId: second.id }, + previous: { ...association, attachmentRecordId: first.id }, + }, + ]); + }); + + test('get-journal-entry-keeps-what-a-dissociate-removed', async () => { + const fixture = getJournalFixtures.find( + (f) => f.name === 'get-journal-entry-keeps-what-a-dissociate-removed', + )!; + handled.add(fixture.name); + const uploaded = await t.ctx.stack.putAttachment( + new Uint8Array([1, 2, 3]), + 'image/png', + 'embed.png', + ); + const association = { + kind: 'attachment', + label: 'embed', + fileId: uploaded.content.fileId, + }; + 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 }, + }); + await req(t.app, 'POST', `/records/${record.id}/associations/delete`, { + token: TEST_TOKEN, + body: association, + }); + + const { status, data } = await req(t.app, 'GET', `/records/${record.id}/journal?sinceSeq=2`, { + token: TEST_TOKEN, + }); + 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. + expect(entry.associations).toEqual([ + { op: 'remove', previous: { ...association, attachmentRecordId: uploaded.id } }, + ]); + }); + + test('get-journal-reparent-entry-spells-the-root-as-null', async () => { + const fixture = getJournalFixtures.find( + (f) => f.name === 'get-journal-reparent-entry-spells-the-root-as-null', + )!; + 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}`, { + token: TEST_TOKEN, + body: { parentId: container.id }, + }); + + const { status, data } = await req(t.app, 'GET', `/records/${record.id}/journal?sinceSeq=1`, { + token: TEST_TOKEN, + }); + expect(status).toBe(fixture.responseStatus); + const entry = (data as WireJournalResponse).entries[0]!; + expect(entry.ops).toEqual(['reparent']); + expect(entry.parentId).toBe(container.id); + // Present and null: the record moved out of the root. Absent would say + // the entry is not a reparent at all, so the two cannot collapse. + expect(entry.previousParentId).toBeNull(); + expect('previousParentId' in entry).toBe(true); + expect(entry.version).toBe(1); + }); + + test('omits authority elements for a requester who may not reshare', async () => { + const record = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'Hello' }, + { + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: WRITER_ID }, + }, + { + kind: 'permission', + label: 'write', + grantee: { scope: 'entity', entityId: WRITER_ID }, + }, + ], + }, + ); + await req(t.app, 'POST', `/records/${record.id}/permissions`, { + token: TEST_TOKEN, + body: { kind: 'anyone', label: 'read' }, + }); + + const { token } = await t.ctx.adapter.createToken(WRITER_ID); + const { data } = await req(t.app, 'GET', `/records/${record.id}/journal`, { token }); + const entries = (data as WireJournalResponse).entries; + const moved = entries.find((e) => e.ops.includes('permissions'))!; + // The entry still names that the ACL moved; the elements beneath it are + // the resharer's, and go with the field when nothing else is in it. + expect(moved).toBeDefined(); + expect(moved.associations).toBeUndefined(); + // Entries are never dropped, so seq stays dense. + expect(entries.map((e) => e.seq)).toEqual(entries.map((_, i) => i + 1)); + + const owner = await req(t.app, 'GET', `/records/${record.id}/journal`, { token: TEST_TOKEN }); + const ownerEntry = (owner.data as WireJournalResponse).entries.find((e) => + e.ops.includes('permissions'), + )!; + expect(ownerEntry.associations).toEqual([ + { op: 'add', association: { kind: 'anyone', label: 'read' } }, + ]); + }); + + test('coverage', () => { + assertCoverage( + getJournalFixtures.map((f) => f.name), + handled, + new Set(), + ); + }); +}); + // ------------------------------------------------------- // Versions: a coordinated lifecycle matching the fixtures' own designed // story — get-versions-after-restore/migrate explicitly assume the paired @@ -880,29 +1292,23 @@ describe('permissions change fixtures', () => { describe('version lifecycle fixtures', () => { const handled = new Set(); - test('owner sees permissions; non-owner write-holder has them stripped', async () => { - const ownerFixture = getVersionsFixtures.find( - (f) => f.name === 'get-versions-owner-includes-permissions', - )!; + 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( - (f) => f.name === 'get-versions-non-owner-write-holder-strips-permissions', - )!; - const singleFixture = getVersionFixtures.find( - (f) => f.name === 'get-version-single-strips-permissions-for-non-owner', + (f) => f.name === 'get-versions-non-owner-write-holder-sees-the-same-rows', )!; + const singleFixture = getVersionFixtures.find((f) => f.name === 'get-version-single')!; handled.add(ownerFixture.name); handled.add(writerFixture.name); handled.add(singleFixture.name); - const memberPermission = (ownerFixture.responseBody![0] as { permissions: unknown[] }) - .permissions[0]; const record = await t.ctx.stack.create( NOTE_TYPE, { title: 'original title' }, { permissions: [ - memberPermission as never, - { access: 'entity', entityId: WRITER_ID, read: true, write: true }, + { kind: 'permission', label: 'read', grantee: { scope: 'entity', entityId: WRITER_ID } }, + { kind: 'permission', label: 'write', grantee: { scope: 'entity', entityId: WRITER_ID } }, ], }, ); @@ -913,25 +1319,20 @@ describe('version lifecycle fixtures', () => { const owner = await req(t.app, 'GET', `/records/${record.id}/versions`, { token: TEST_TOKEN }); expect(owner.status).toBe(ownerFixture.responseStatus); - const ownerVersions = owner.data as Array<{ permissions?: unknown[] }>; - expect(ownerVersions[0]!.permissions).toEqual([ - memberPermission, - { access: 'entity', entityId: WRITER_ID, read: true, write: true }, - ]); + expect((owner.data as unknown[]).length).toBe(1); const { token: writerToken } = await t.ctx.adapter.createToken(WRITER_ID); const writer = await req(t.app, 'GET', `/records/${record.id}/versions`, { token: writerToken, }); expect(writer.status).toBe(writerFixture.responseStatus); - const writerVersions = writer.data as Array<{ permissions?: unknown[] }>; - expect(writerVersions[0]!.permissions).toBeUndefined(); + expect(writer.data).toEqual(owner.data); const single = await req(t.app, 'GET', `/records/${record.id}/versions/1`, { token: writerToken, }); expect(single.status).toBe(singleFixture.responseStatus); - expect((single.data as { permissions?: unknown[] }).permissions).toBeUndefined(); + expect(single.data).toEqual((owner.data as unknown[])[0]); }); test('restore auto-snapshots the pre-restore state; migrate auto-snapshots the pre-migration state', async () => { @@ -1001,51 +1402,93 @@ describe('version lifecycle fixtures', () => { expect(v4Snapshot.typeId).toBe(NOTE_TYPE); }); - test('a snapshot carries its container, and a restore puts the record back in it', async () => { + test('get-versions-after-associate-is-unchanged', async () => { + const fixture = getVersionsAfterMutateFixtures.find( + (f) => f.name === 'get-versions-after-associate-is-unchanged', + )!; + 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 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); + }); + + test('a snapshot names no container, and a restore leaves the record where it sits', async () => { const snapshotFixture = getVersionFixtures.find( - (f) => f.name === 'get-version-carries-the-container-the-snapshot-was-taken-in', + (f) => f.name === 'get-version-carries-no-containment-listing-or-authority-state', )!; const restoreFixture = restoreVersionFixtures.find( - (f) => f.name === 'restore-version-puts-the-record-back-in-its-old-container', + (f) => f.name === 'restore-version-leaves-the-record-where-it-sits', )!; handled.add(snapshotFixture.name); handled.add(restoreFixture.name); const container = await t.ctx.stack.create(NOTE_TYPE, { title: 'Container' }); - // v1, in the container - const record = await t.ctx.stack.create( - NOTE_TYPE, - { title: 'original title' }, - { parentId: container.id }, - ); - // v2 — the state the restore below will return to, still in the container + // 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' } }, }); - // v3 — moved to the root, which is what gives v2 a container to have been in + // 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' }, + }); + // v3 — the content bump that writes v2's snapshot await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { parentId: null }, + body: { contentPatch: { title: 'current title' }, unlisted: true }, + }); + // 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 }, }); const snapshot = await req(t.app, 'GET', `/records/${record.id}/versions/2`, { token: TEST_TOKEN, }); expect(snapshot.status).toBe(snapshotFixture.responseStatus); - const v2 = snapshot.data as { parentId?: string; content: unknown }; - expect(v2.parentId).toBe(container.id); + const v2 = snapshot.data as Record; expect(v2.content).toEqual(snapshotFixture.responseBody!.content); + // None of the four aspects that bump no version is ever snapshotted. + expect(v2.parentId).toBeUndefined(); + expect(v2.unlistedAt).toBeUndefined(); + expect(v2.permissions).toBeUndefined(); + expect(v2.associations).toBeUndefined(); const restore = await req(t.app, 'POST', `/records/${record.id}/restore/2`, { token: TEST_TOKEN, }); expect(restore.status).toBe(restoreFixture.responseStatus); const restored = restore.data as { parentId?: string; content: unknown; version: number }; - // A restore settles containment rather than leaving the record where it - // sits: the snapshot names the container, so the record goes back to it. - expect(restored.parentId).toBe(container.id); expect(restored.content).toEqual(restoreFixture.responseBody!.content); + // A restore settles content and typeId alone: the record comes back in + // whatever container it is in now, not the one the snapshot was taken in. + expect(restored.parentId).toBe(container.id); expect(restored.version).toBe(4); }); @@ -1112,7 +1555,15 @@ describe('error response fixtures', () => { const record = await t.ctx.stack.create( NOTE_TYPE, { title: 'x' }, - { permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: false }] }, + { + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + }, + ], + }, ); const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); const { status, data } = await dispatch(fixture, token, `/records/${record.id}`); @@ -1160,7 +1611,15 @@ describe('error response fixtures', () => { const record = await t.ctx.stack.create( NOTE_TYPE, { title: 'x' }, - { permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: false }] }, + { + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + }, + ], + }, ); const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); const { status, data } = await dispatch(fixture, token, `/records/${record.id}/versions`); @@ -1176,7 +1635,9 @@ 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.grant(CONTRIBUTOR_ID, [{ actions: ['create'], typeId: ATTACHMENT_TYPE }]); + await t.ctx.stack.grant({ kind: 'entity', entityId: CONTRIBUTOR_ID }, [ + { actions: ['create'], typeId: ATTACHMENT_TYPE }, + ]); const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); // The contributor uploads the bytes themselves, so they end up holding // an _attachment@1 record for this fileId — the "uploaded it themselves" @@ -1200,31 +1661,49 @@ describe('error response fixtures', () => { test('error-permission-denied-restore-reference-reconveyance', async () => { const fixture = find('error-permission-denied-restore-reference-reconveyance'); - // v1 carries an attachment association; v2 drops it. Nothing else - // references the file afterward — an _attachment@1 record holds its - // fileId in a plain string field, not a file-ref one, so the owner's - // own upload record is not a reference the attachmentFileId filter - // sees. The writer therefore cannot attach the file fresh today, and - // restoring v1 would hand it back to them. + // A file-ref content field is the only reference a snapshot can put + // back: no snapshot has ever carried an association, so a restore can + // never introduce one to gate. v1 names the file, v2 drops it, and + // nothing readable by the writer names it afterwards — an _attachment@1 + // record holds its fileId in a plain string field, not a file-ref one, + // so the owner's own upload record is not a reference the check sees. + const PHOTO_NOTE = 'com.example/photo-note@1'; + await t.ctx.stack.defineType(PHOTO_NOTE, 'Photo note', { + title: { kind: 'string' }, + coverFileId: { kind: 'file-ref' }, + }); const uploaded = await t.ctx.stack.putAttachment( new Uint8Array([4, 5, 6]), 'image/png', 'cover.png', ); - const fileId = (uploaded.content as { fileId: string }).fileId; + const fileId = uploaded.content.fileId; const record = await t.ctx.stack.create( - NOTE_TYPE, - { title: 'has a cover' }, + PHOTO_NOTE, + { title: 'has a cover', coverFileId: fileId }, { - permissions: [{ access: 'entity', entityId: WRITER_ID, read: true, write: true }], - associations: [{ kind: 'attachment', label: 'cover', fileId }], + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: WRITER_ID }, + }, + { + kind: 'permission', + label: 'write', + grantee: { scope: 'entity', entityId: WRITER_ID }, + }, + ], }, ); - await t.ctx.stack.dissociate(record.id, { kind: 'attachment', label: 'cover', fileId }); + // v2 drops the reference, which is what leaves it reachable only from + // the v1 snapshot a restore would put back. + await t.ctx.stack.patchContent(record.id, { coverFileId: null }); const { token } = await t.ctx.adapter.createToken(WRITER_ID); const { status, data } = await dispatch(fixture, token, `/records/${record.id}/restore/1`); expectError(status, data, fixture); + // The refusal is about the reference, not about write access: give the // writer a readable record that carries the same file today and the // identical restore goes through. @@ -1232,7 +1711,13 @@ describe('error response fixtures', () => { NOTE_TYPE, { title: 'still has the cover' }, { - permissions: [{ access: 'entity', entityId: WRITER_ID, read: true, write: true }], + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: WRITER_ID }, + }, + ], associations: [{ kind: 'attachment', label: 'cover', fileId }], }, ); @@ -1300,6 +1785,67 @@ describe('error response fixtures', () => { expectError(status, data, fixture); }); + test('error-permission-denied-journal-read-only', async () => { + const fixture = find('error-permission-denied-journal-read-only'); + const record = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'readable, not writable' }, + { + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + }, + ], + }, + ); + const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + const { status, data } = await dispatch(fixture, token, `/records/${record.id}/journal`); + expectError(status, data, fixture); + }); + + test('error-not-found-journal-of-a-record-that-is-gone', async () => { + const fixture = find('error-not-found-journal-of-a-record-that-is-gone'); + // Never created and hard-deleted are the same answer — never an empty + // log, which would read as "nothing changed". + const missing = await dispatch(fixture, TEST_TOKEN); + expectError(missing.status, missing.data, fixture); + + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'about to be purged' }); + await t.ctx.stack.delete(record.id, { hard: true }); + const purged = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}/journal`); + expectError(purged.status, purged.data, fixture); + }); + + test('error-validation-grant-without-grantee', async () => { + const fixture = find('error-validation-grant-without-grantee'); + const body = withFreshId(fixture.requestBody as WireRecord); + const { status, data } = await dispatch(fixture, TEST_TOKEN, undefined, body); + expectError(status, data, fixture); + }); + + test('error-validation-grant-group-grantee-without-role', async () => { + const fixture = find('error-validation-grant-group-grantee-without-role'); + const body = withFreshId(fixture.requestBody as WireRecord); + const { status, data } = await dispatch(fixture, TEST_TOKEN, undefined, body); + expectError(status, data, fixture); + }); + + 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}`); + 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}`); + expectError(status, data, fixture); + }); + test('error-conflict-duplicate-id', async () => { const fixture = find('error-conflict-duplicate-id'); const body = withFreshId(fixture.requestBody as WireRecord); @@ -1520,7 +2066,20 @@ describe('changeFeed fixtures', () => { const record = await t.ctx.stack.create( NOTE_TYPE, { title: 'original' }, - { permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: true }] }, + { + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + }, + { + kind: 'permission', + label: 'write', + grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + }, + ], + }, ); const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN }); @@ -1541,6 +2100,70 @@ describe('changeFeed fixtures', () => { } }); + test('change-feed-associate-frame-carries-associationsAdded', async () => { + const fixture = changeFeedFixtures.find( + (f) => f.name === 'change-feed-associate-frame-carries-associationsAdded', + )!; + 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, 'POST', `/records/${record.id}/associations`, { + token: TEST_TOKEN, + body: { kind: 'tag', label: 'starred' }, + }); + const [, frame] = await conn.waitForFrames(2); + const data = frameData(frame); + expect(data.ops).toEqual(['associate']); + // The frame reports the verb without a bump: version and updatedAt + // are exactly what the record already held. + expect(data.version).toBe(record.version); + expect(data.updatedAt).toBe(record.updatedAt.toISOString()); + // The only record of what the call moved — an association is never + // snapshotted, so a subscriber has no other way to learn it. + expect(data.associationsAdded).toEqual([{ kind: 'tag', label: 'starred' }]); + } finally { + await conn.close(); + } + }); + + test('change-feed-dissociate-frame-carries-associationsRemoved', async () => { + const fixture = changeFeedFixtures.find( + (f) => f.name === 'change-feed-dissociate-frame-carries-associationsRemoved', + )!; + handled.add(fixture.name); + const uploaded = await t.ctx.stack.putAttachment( + new Uint8Array([1, 2, 3]), + 'image/png', + 'embed.png', + ); + const association = { kind: 'attachment', label: 'embed', fileId: uploaded.content.fileId }; + 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 }, + }); + + 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`, { + token: TEST_TOKEN, + body: association, + }); + const [, frame] = await conn.waitForFrames(2); + const data = frameData(frame); + expect(data.ops).toEqual(['dissociate']); + expect(data.version).toBe(record.version); + // Identity only: the annotation is no longer current, and a frame + // reports what is true now. The journal is where it survives. + expect(data.associationsRemoved).toEqual([association]); + } finally { + await conn.close(); + } + }); + test('change-feed-deleted-frame-is-not-terminal', async () => { const fixture = changeFeedFixtures.find( (f) => f.name === 'change-feed-deleted-frame-is-not-terminal', @@ -1625,7 +2248,15 @@ describe('changeFeed fixtures', () => { const sharedRecord = await t.ctx.stack.create( NOTE_TYPE, { title: 'shared' }, - { permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: false }] }, + { + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + }, + ], + }, ); const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); const conn = await openChangeFeed(t.app, '/changes', { token }); diff --git a/tests/routes/associations.test.ts b/tests/routes/associations.test.ts index 9480b94..776e7ef 100644 --- a/tests/routes/associations.test.ts +++ b/tests/routes/associations.test.ts @@ -115,56 +115,80 @@ describe('Associations', () => { expect(after?.associations?.some((a) => a.label === 'author')).toBeFalsy(); }); - describe('If-Match / optimistic concurrency', () => { - it('POST /associations succeeds when If-Match names the current version', async () => { + describe('no-bump semantics', () => { + it('leaves version and updatedAt where they stand on both endpoints', async () => { const record = await seedRecord(); - const { status } = await req(t.app, 'POST', `/records/${record.id}/associations`, { + const before = await t.ctx.adapter.getRecord(record.id); + + const added = await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, body: { kind: 'tag', label: 'starred' }, - headers: { 'If-Match': `"${record.version}"` }, }); - expect(status).toBe(200); + 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`, { + token: TEST_TOKEN, + body: { kind: 'tag', label: 'starred' }, + }); + expect(removed.status).toBe(200); + + const after = await t.ctx.adapter.getRecord(record.id); + expect(after!.version).toBe(before!.version); + expect(after!.updatedAt.toISOString()).toBe(before!.updatedAt.toISOString()); }); - it('POST /associations returns 412 version_conflict on an If-Match mismatch', async () => { + it('writes no version snapshot', async () => { const record = await seedRecord(); - const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations`, { + await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, body: { kind: 'tag', label: 'starred' }, - headers: { 'If-Match': `"${record.version + 1}"` }, }); - expect(status).toBe(412); - expect((data as { error: { code: string } }).error.code).toBe('version_conflict'); + const { data } = await req(t.app, 'GET', `/records/${record.id}/versions`, { + token: TEST_TOKEN, + }); + expect(data).toEqual([]); }); - it('POST /associations/delete succeeds when If-Match names the current version', async () => { + it('ignores an If-Match that names a stale version rather than refusing it', async () => { const record = await seedRecord(); - await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' }); - const current = await t.ctx.adapter.getRecord(record.id); - const { status } = await req(t.app, 'POST', `/records/${record.id}/associations/delete`, { + const { status } = await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, body: { kind: 'tag', label: 'starred' }, - headers: { 'If-Match': `"${current!.version}"` }, + headers: { 'If-Match': `"${record.version + 99}"` }, }); expect(status).toBe(200); }); + }); + + describe('authority kinds are refused', () => { + it('refuses a permission element sent to POST /associations', async () => { + const record = await seedRecord(); + const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations`, { + token: TEST_TOKEN, + body: { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: 'entity-other' }, + }, + }); + expect(status).toBe(400); + expect((data as { error: { code: string } }).error.code).toBe('bad_request'); + }); - it('POST /associations/delete returns 412 version_conflict on an If-Match mismatch', async () => { + it('refuses an anyone element sent to POST /associations/delete', async () => { const record = await seedRecord(); - await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' }); - const current = await t.ctx.adapter.getRecord(record.id); const { status, data } = await req( t.app, 'POST', `/records/${record.id}/associations/delete`, { token: TEST_TOKEN, - body: { kind: 'tag', label: 'starred' }, - headers: { 'If-Match': `"${current!.version + 1}"` }, + body: { kind: 'anyone', label: 'read' }, }, ); - expect(status).toBe(412); - expect((data as { error: { code: string } }).error.code).toBe('version_conflict'); + 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 46bb8a2..1722d30 100644 --- a/tests/routes/attachments.test.ts +++ b/tests/routes/attachments.test.ts @@ -68,7 +68,7 @@ describe('GET /attachments/:fileId', () => { NOTE_TYPE_ID, { body: 'public note' }, { - permissions: [{ access: 'public' }], + permissions: [{ kind: 'anyone', label: 'read' }], associations: [{ kind: 'attachment', label: 'file', fileId }], }, ); @@ -106,7 +106,7 @@ describe('GET /attachments/:fileId', () => { NOTE_TYPE_ID, { body: 'public note' }, { - permissions: [{ access: 'public' }], + permissions: [{ kind: 'anyone', label: 'read' }], associations: [{ kind: 'attachment', label: 'file', fileId }], }, ); @@ -121,7 +121,13 @@ describe('GET /attachments/:fileId', () => { NOTE_TYPE_ID, { body: 'shared note' }, { - permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }], + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], associations: [{ kind: 'attachment', label: 'file', fileId }], }, ); @@ -142,7 +148,13 @@ describe('GET /attachments/:fileId', () => { { body: 'unlisted but shared' }, { unlisted: true, - permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }], + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], associations: [{ kind: 'attachment', label: 'file', fileId }], }, ); @@ -270,7 +282,9 @@ 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.grant(OTHER_ENTITY_ID, [{ actions: ['create'], typeId: '_attachment@1' }]); + await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ + { actions: ['create'], typeId: '_attachment@1' }, + ]); const first = await t.ctx.stack .forSession({ principalId: OTHER_ENTITY_ID, subjectId: OTHER_ENTITY_ID }) .putAttachment(bytes, 'text/plain', 'first.txt'); @@ -394,7 +408,9 @@ describe('POST /attachments', () => { }); it('allows an entity with a create grant on _attachment@1 to upload', async () => { - await t.ctx.stack.grant(OTHER_ENTITY_ID, [{ actions: ['create'], typeId: '_attachment@1' }]); + await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ + { actions: ['create'], typeId: '_attachment@1' }, + ]); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); const res = await t.app.request('/attachments', { method: 'POST', @@ -527,7 +543,18 @@ describe('DELETE /attachments/:fileId', () => { NOTE_TYPE_ID, { body: 'shared note' }, { - permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }], + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + { + kind: 'permission', + label: 'write', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], associations: [{ kind: 'attachment', label: 'file', fileId }], }, ); diff --git a/tests/routes/auth.test.ts b/tests/routes/auth.test.ts index 38d2163..1eb7c3e 100644 --- a/tests/routes/auth.test.ts +++ b/tests/routes/auth.test.ts @@ -398,7 +398,7 @@ describe('invalid bearer credentials', () => { await t.ctx.stack.create( NOTE_TYPE_ID, { body: 'public content' }, - { permissions: [{ access: 'public' }] }, + { permissions: [{ kind: 'anyone', label: 'read' }] }, ); }); afterEach(async () => { diff --git a/tests/routes/changes.test.ts b/tests/routes/changes.test.ts index 261a40c..dd007ec 100644 --- a/tests/routes/changes.test.ts +++ b/tests/routes/changes.test.ts @@ -91,7 +91,7 @@ describe('GET /changes', () => { const publicRecord = await t.ctx.stack.create( NOTE_TYPE, { title: 'public' }, - { permissions: [{ access: 'public' }] }, + { permissions: [{ kind: 'anyone', label: 'read' }] }, ); await req(t.app, 'PATCH', `/records/${publicRecord.id}`, { token: TEST_TOKEN, @@ -188,12 +188,28 @@ describe('GET /changes', () => { const recordA = await t.ctx.stack.create( NOTE_TYPE, { title: 'a' }, - { permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: false }] }, + { + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + }, + ], + }, ); const recordB = await t.ctx.stack.create( NOTE_TYPE, { title: 'b' }, - { permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: false }] }, + { + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + }, + ], + }, ); const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); diff --git a/tests/routes/groups.test.ts b/tests/routes/groups.test.ts index 8190658..342ad64 100644 --- a/tests/routes/groups.test.ts +++ b/tests/routes/groups.test.ts @@ -30,7 +30,15 @@ describe('_group ACL', () => { const record = await t.ctx.stack.create( NOTE_TYPE, { title: 'group-visible' }, - { permissions: [{ access: 'group', groupId: group.id, read: true, write: false }] }, + { + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'group', groupId: group.id, role: 'member' }, + }, + ], + }, ); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); const { status } = await req(t.app, 'GET', `/records/${record.id}`, { token }); @@ -58,7 +66,15 @@ describe('_group ACL', () => { const record = await t.ctx.stack.create( NOTE_TYPE, { title: 'should stay private' }, - { permissions: [{ access: 'group', groupId: notAGroup.id, read: true, write: false }] }, + { + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'group', groupId: notAGroup.id, role: 'member' }, + }, + ], + }, ); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); const { status } = await req(t.app, 'GET', `/records/${record.id}`, { token }); diff --git a/tests/routes/records.test.ts b/tests/routes/records.test.ts index 9f901a0..9fda138 100644 --- a/tests/routes/records.test.ts +++ b/tests/routes/records.test.ts @@ -119,7 +119,7 @@ describe('Records', () => { NOTE_TYPE_ID, { body: 'public content' }, { - permissions: [{ access: 'public' }], + permissions: [{ kind: 'anyone', label: 'read' }], }, ); const { status, data } = await req(t.app, 'GET', `/records/${record.id}`); @@ -132,7 +132,13 @@ describe('Records', () => { NOTE_TYPE_ID, { body: 'shared content' }, { - permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }], + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], }, ); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); @@ -454,7 +460,7 @@ describe('Records', () => { await t.ctx.stack.create( NOTE_TYPE_ID, { body: 'public' }, - { permissions: [{ access: 'public' }] }, + { permissions: [{ kind: 'anyone', label: 'read' }] }, ); const { data } = await req(t.app, 'GET', '/records'); const d = data as { records: Array<{ content: { body: string } }> }; @@ -484,7 +490,7 @@ describe('Records', () => { await t.ctx.stack.create( NOTE_TYPE_ID, { body: 'public' }, - { permissions: [{ access: 'public' }] }, + { permissions: [{ kind: 'anyone', label: 'read' }] }, ); const { status, data } = await req(t.app, 'POST', '/records/query', { body: { filter: { typeId: NOTE_TYPE_ID } }, @@ -527,7 +533,13 @@ describe('Records', () => { NOTE_TYPE_ID, { body: 'hi' }, { - permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }], + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], }, ); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); @@ -551,21 +563,64 @@ describe('Records', () => { expect(after?.deletedAt).toBeDefined(); }); - it('hard-deletes with ?hard=true (owner)', async () => { + it('hard-deletes with ?hard=true (owner), answering with the record it destroyed', async () => { const record = await seedRecord(t.ctx); - const { status } = await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, { + const { status, data } = await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, { token: TEST_TOKEN, }); - expect(status).toBe(204); + expect(status).toBe(200); + expect((data as { id: string }).id).toBe(record.id); expect(await t.ctx.adapter.getRecord(record.id)).toBeNull(); }); + it('names the files a purge stranded in the body it answers with', async () => { + const uploaded = await t.ctx.stack.putAttachment( + new Uint8Array([1, 2, 3]), + 'image/png', + '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, + }); + + // Every other row naming the file is gone once this returns, so the + // body is the requester's one chance to hold the argument + // deleteAttachment() takes. + const { status, data } = await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, { + token: TEST_TOKEN, + }); + expect(status).toBe(200); + const associations = (data as { associations?: Array<{ fileId?: string }> }).associations; + expect(associations?.map((a) => a.fileId)).toContain(uploaded.content.fileId); + }); + + it('answers 404 for a hard delete of a record that is not there', async () => { + const { status } = await req(t.app, 'DELETE', '/records/1hk153x00099?hard=true', { + token: TEST_TOKEN, + }); + expect(status).toBe(404); + }); + it('non-owner with write access can soft-delete', async () => { const record = await t.ctx.stack.create( NOTE_TYPE_ID, { body: 'shared' }, { - permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }], + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + { + kind: 'permission', + label: 'write', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], }, ); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); @@ -580,7 +635,18 @@ describe('Records', () => { NOTE_TYPE_ID, { body: 'shared' }, { - permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }], + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + { + kind: 'permission', + label: 'write', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], }, ); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); @@ -610,6 +676,17 @@ describe('Records', () => { }); }); + describe('GET /records/:id/journal', () => { + it('answers an empty page rather than crashing on limit=0', async () => { + const record = await seedRecord(t.ctx); + const { status, data } = await req(t.app, 'GET', `/records/${record.id}/journal?limit=0`, { + token: TEST_TOKEN, + }); + expect(status).toBe(200); + expect(data).toEqual({ entries: [], cursor: null }); + }); + }); + describe('_attachment@1 immutable field protection', () => { // Enforced by ScopedStack now (docs/spec/attachments.md § The _attachment // record type) — putAttachment() itself returns the created record, so @@ -691,7 +768,9 @@ describe('Records', () => { // targeting _grant cannot be handed out at all, so a non-owner has // nothing to escalate with. await expect( - t.ctx.stack.grant(OTHER_ENTITY_ID, [{ actions: ['update-own'], typeId: GRANT_TYPE_ID }]), + t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ + { actions: ['update-own'], typeId: GRANT_TYPE_ID }, + ]), ).rejects.toThrow(/privilege escalation/); }); @@ -702,9 +781,24 @@ describe('Records', () => { // it directly) to prove the fence holds regardless. const grantRecord = await t.ctx.stack.create( GRANT_TYPE_ID, - { typeId: NOTE_TYPE_ID, actions: ['read-own'] }, { - permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }], + typeId: NOTE_TYPE_ID, + actions: ['read-own'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }, + { + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + { + kind: 'permission', + label: 'write', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], }, ); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); @@ -726,9 +820,24 @@ describe('Records', () => { // "_grant records refuse deletion" rather than "can't read it". const grantRecord = await t.ctx.stack.create( GRANT_TYPE_ID, - { typeId: NOTE_TYPE_ID, actions: ['read-own'] }, { - permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }], + typeId: NOTE_TYPE_ID, + actions: ['read-own'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }, + { + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + { + kind: 'permission', + label: 'write', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], }, ); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); @@ -739,7 +848,7 @@ describe('Records', () => { }); it('allows the owner to PATCH a grant record', async () => { - const [grantRecord] = await t.ctx.stack.grant(OTHER_ENTITY_ID, [ + const [grantRecord] = await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ { actions: ['read-own'], typeId: NOTE_TYPE_ID }, ]); @@ -753,7 +862,7 @@ describe('Records', () => { }); it('allows the owner to soft-DELETE a grant record', async () => { - const [grantRecord] = await t.ctx.stack.grant(OTHER_ENTITY_ID, [ + const [grantRecord] = await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ { actions: ['read-own'], typeId: NOTE_TYPE_ID }, ]); @@ -772,9 +881,24 @@ describe('Records', () => { // record with an explicit record-level write permission on itself. const readGrant = await t.ctx.stack.create( GRANT_TYPE_ID, - { typeId: NOTE_TYPE_ID, actions: ['read-own'] }, { - permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }], + typeId: NOTE_TYPE_ID, + actions: ['read-own'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }, + { + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + { + kind: 'permission', + label: 'write', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], }, ); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); @@ -850,7 +974,9 @@ describe('Records', () => { it('refuses to grant create on _app (privilege-escalation guard, same as _grant/_config)', async () => { await expect( - t.ctx.stack.grant(OTHER_ENTITY_ID, [{ actions: ['create'], typeId: APP_TYPE_ID }]), + t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ + { actions: ['create'], typeId: APP_TYPE_ID }, + ]), ).rejects.toThrow(/privilege escalation/); }); @@ -927,7 +1053,7 @@ describe('Records', () => { const bytes = new TextEncoder().encode('unreferenced'); const record = await t.ctx.stack.putAttachment(bytes, 'text/plain'); const fileId = (record.content as { fileId: string }).fileId; - await t.ctx.stack.grant(OTHER_ENTITY_ID, [ + await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ { actions: ['create'], typeId: ATTACHMENT_TYPE_ID }, ]); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); @@ -950,11 +1076,17 @@ describe('Records', () => { NOTE_TYPE_ID, { body: 'ref' }, { - permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }], + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], associations: [{ kind: 'attachment', label: 'file', fileId }], }, ); - await t.ctx.stack.grant(OTHER_ENTITY_ID, [ + await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ { actions: ['create'], typeId: ATTACHMENT_TYPE_ID }, ]); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); @@ -976,8 +1108,12 @@ describe('Records', () => { it('a delegated session stamps principalId and attributes entityId to the subject', async () => { // Delegated authority is the intersection of both parties' grants. - await t.ctx.stack.grant(SUBJECT_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]); - await t.ctx.stack.grant(APP_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]); + await t.ctx.stack.grant({ kind: 'entity', entityId: SUBJECT_ID }, [ + { actions: ['create'], typeId: NOTE_TYPE_ID }, + ]); + await t.ctx.stack.grant({ kind: 'entity', entityId: APP_ID }, [ + { actions: ['create'], typeId: NOTE_TYPE_ID }, + ]); const { token } = await t.ctx.adapter.createToken(APP_ID, { onBehalfOf: SUBJECT_ID }); const { status, data } = await req(t.app, 'POST', '/records', { @@ -991,7 +1127,9 @@ describe('Records', () => { }); it('a delegated create is refused when only one party holds the create grant', async () => { - await t.ctx.stack.grant(SUBJECT_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]); + await t.ctx.stack.grant({ kind: 'entity', entityId: SUBJECT_ID }, [ + { actions: ['create'], typeId: NOTE_TYPE_ID }, + ]); // APP_ID (the principal) has no grant of its own. const { token } = await t.ctx.adapter.createToken(APP_ID, { onBehalfOf: SUBJECT_ID }); @@ -1003,8 +1141,12 @@ describe('Records', () => { }); it('filters by ?principalId=', async () => { - await t.ctx.stack.grant(SUBJECT_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]); - await t.ctx.stack.grant(APP_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]); + await t.ctx.stack.grant({ kind: 'entity', entityId: SUBJECT_ID }, [ + { actions: ['create'], typeId: NOTE_TYPE_ID }, + ]); + await t.ctx.stack.grant({ kind: 'entity', entityId: APP_ID }, [ + { actions: ['create'], typeId: NOTE_TYPE_ID }, + ]); const { token } = await t.ctx.adapter.createToken(APP_ID, { onBehalfOf: SUBJECT_ID }); await req(t.app, 'POST', '/records', { token, @@ -1158,7 +1300,9 @@ describe('Records', () => { }); it('drops createdAt/updatedAt from a grantee create instead of forwarding them into a 403', async () => { - await t.ctx.stack.grant(OTHER_ENTITY_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]); + await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ + { actions: ['create'], typeId: NOTE_TYPE_ID }, + ]); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); const createdAt = new Date('2020-01-01T00:00:00.000Z'); const before = Date.now(); @@ -1177,8 +1321,12 @@ describe('Records', () => { const { token } = await t.ctx.adapter.createToken(TEST_ENTITY_ID, { onBehalfOf: OTHER_ENTITY_ID, }); - await t.ctx.stack.grant(OTHER_ENTITY_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]); - await t.ctx.stack.grant(TEST_ENTITY_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]); + await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ + { actions: ['create'], typeId: NOTE_TYPE_ID }, + ]); + await t.ctx.stack.grant({ kind: 'entity', entityId: TEST_ENTITY_ID }, [ + { actions: ['create'], typeId: NOTE_TYPE_ID }, + ]); const createdAt = new Date('2020-01-01T00:00:00.000Z'); const before = Date.now(); @@ -1230,7 +1378,9 @@ describe('Records', () => { }); it('a non-owner grantee with a plain create grant may create unlisted — not owner-only', async () => { - await t.ctx.stack.grant(OTHER_ENTITY_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]); + await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ + { actions: ['create'], typeId: NOTE_TYPE_ID }, + ]); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); const { status, data } = await req(t.app, 'POST', '/records', { token, @@ -1251,8 +1401,12 @@ 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.grant(subjectId, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]); - await t.ctx.stack.grant(appId, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]); + await t.ctx.stack.grant({ kind: 'entity', entityId: subjectId }, [ + { actions: ['create'], typeId: NOTE_TYPE_ID }, + ]); + await t.ctx.stack.grant({ kind: 'entity', entityId: appId }, [ + { actions: ['create'], typeId: NOTE_TYPE_ID }, + ]); const { token } = await t.ctx.adapter.createToken(appId, { onBehalfOf: subjectId }); const { status, data } = await req(t.app, 'POST', '/records', { @@ -1335,7 +1489,13 @@ describe('Records', () => { NOTE_TYPE_ID, { body: 'shared' }, { - permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }], + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], }, ); await t.ctx.stack.delete(record.id); @@ -1359,11 +1519,11 @@ describe('Records', () => { const res = await t.app.request(`/records/${record.id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${TEST_TOKEN}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ permissions: [{ access: 'public' }] }), + body: JSON.stringify({ permissions: [{ kind: 'anyone', label: 'read' }] }), }); expect(res.status).toBe(200); const body = (await res.json()) as { permissions: unknown[] }; - expect(body.permissions).toEqual([{ access: 'public' }]); + expect(body.permissions).toEqual([{ kind: 'anyone', label: 'read' }]); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); const { status: anonStatus } = await req(t.app, 'GET', `/records/${record.id}`); @@ -1376,7 +1536,7 @@ describe('Records', () => { const record = await t.ctx.stack.create( NOTE_TYPE_ID, { body: 'was public' }, - { permissions: [{ access: 'public' }] }, + { permissions: [{ kind: 'anyone', label: 'read' }] }, ); await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, @@ -1394,13 +1554,15 @@ describe('Records', () => { const record = await t.ctx.stack.create( NOTE_TYPE_ID, { body: 'x' }, - { permissions: [{ access: 'public' }] }, + { permissions: [{ kind: 'anyone', label: 'read' }] }, ); const { status, data } = await req(t.app, 'GET', `/records/${record.id}/permissions`, { token: TEST_TOKEN, }); expect(status).toBe(200); - expect((data as { permissions: unknown[] }).permissions).toEqual([{ access: 'public' }]); + expect((data as { permissions: unknown[] }).permissions).toEqual([ + { kind: 'anyone', label: 'read' }, + ]); }); it('returns 403 when a non-owner without write access names the key', async () => { @@ -1408,13 +1570,19 @@ describe('Records', () => { NOTE_TYPE_ID, { body: 'x' }, { - permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }], + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], }, ); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { token, - body: { permissions: [{ access: 'public' }] }, + body: { permissions: [{ kind: 'anyone', label: 'read' }] }, }); expect(status).toBe(403); }); @@ -1442,25 +1610,27 @@ describe('Records', () => { expect(followUp).toBe(404); }); - it('succeeds when If-Match names the current version', async () => { + it('leaves version and updatedAt where they stand', async () => { const record = await seedRecord(t.ctx); - const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const before = await t.ctx.adapter.getRecord(record.id); + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { permissions: [{ access: 'public' }] }, - headers: { 'If-Match': `"${record.version}"` }, + body: { permissions: [{ kind: 'anyone', label: 'read' }] }, }); expect(status).toBe(200); + expect((data as { version: number }).version).toBe(before!.version); + const after = await t.ctx.adapter.getRecord(record.id); + expect(after!.updatedAt.toISOString()).toBe(before!.updatedAt.toISOString()); }); - it('returns 412 version_conflict on an If-Match mismatch', async () => { + it('does not check an If-Match sent beside a change set that only names it', async () => { const record = await seedRecord(t.ctx); - const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { permissions: [{ access: 'public' }] }, - headers: { 'If-Match': `"${record.version + 1}"` }, + body: { permissions: [{ kind: 'anyone', label: 'read' }] }, + headers: { 'If-Match': `"${record.version + 99}"` }, }); - expect(status).toBe(412); - expect((data as { error: { code: string } }).error.code).toBe('version_conflict'); + expect(status).toBe(200); }); }); @@ -1469,7 +1639,7 @@ describe('Records', () => { const record = await t.ctx.stack.create( NOTE_TYPE_ID, { body: 'x' }, - { permissions: [{ access: 'public' }] }, + { permissions: [{ kind: 'anyone', label: 'read' }] }, ); const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, @@ -1477,8 +1647,9 @@ describe('Records', () => { }); expect(status).toBe(200); expect(typeof (data as { unlistedAt?: string }).unlistedAt).toBe('string'); - // permissions untouched — orthogonal fields. - expect((data as { version: number }).version).toBe(record.version + 1); + // permissions untouched — orthogonal fields — and a listing + // transition is its own inverse, so it takes no snapshot either. + expect((data as { version: number }).version).toBe(record.version); // Still directly reachable by anyone who could already read it. const anon = await req(t.app, 'GET', `/records/${record.id}`); @@ -1566,7 +1737,13 @@ describe('Records', () => { NOTE_TYPE_ID, { body: 'x' }, { - permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }], + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], }, ); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); @@ -1588,22 +1765,22 @@ describe('Records', () => { expect((data as { error: { code: string } }).error.code).toBe('conflict'); }); - it('succeeds when If-Match names the current version', async () => { + it('does not check an If-Match sent beside a change set that only names it', async () => { const record = await seedRecord(t.ctx); const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: { unlisted: true }, - headers: { 'If-Match': `"${record.version}"` }, + headers: { 'If-Match': `"${record.version + 99}"` }, }); expect(status).toBe(200); }); - it('returns 412 version_conflict on an If-Match mismatch', async () => { + 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 }, - headers: { 'If-Match': `"${record.version + 1}"` }, + 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'); @@ -1900,7 +2077,18 @@ describe('Records', () => { NOTE_TYPE_ID, { body: 'shared' }, { - permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }], + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + { + kind: 'permission', + label: 'write', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], }, ); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); diff --git a/tests/routes/versions.test.ts b/tests/routes/versions.test.ts index 95f3484..25ed106 100644 --- a/tests/routes/versions.test.ts +++ b/tests/routes/versions.test.ts @@ -106,7 +106,26 @@ describe('Versions', () => { TYPE_ID, { body: 'v1' }, { - permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write }], + permissions: write + ? [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + { + kind: 'permission', + label: 'write', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ] + : [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], }, ); await t.ctx.stack.patchContent(record.id, { body: 'v2' }); @@ -136,42 +155,58 @@ describe('Versions', () => { }); }); - it('strips permissions from a version snapshot for a non-owner write-holder, but keeps entityId', async () => { + it('carries no containment, listing or authority state on a snapshot', async () => { + const container = await t.ctx.stack.create(TYPE_ID, { body: 'container' }); const record = await t.ctx.stack.create( TYPE_ID, { body: 'v1' }, { - permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }], + parentId: container.id, + unlisted: true, + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], }, ); await t.ctx.stack.patchContent(record.id, { body: 'v2' }); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); - const { data } = await req(t.app, 'GET', `/records/${record.id}/versions`, { token }); - const versions = data as Array>; - expect(versions[0].permissions).toBeUndefined(); - - const { data: ownerData } = await req(t.app, 'GET', `/records/${record.id}/versions`, { + const { data } = await req(t.app, 'GET', `/records/${record.id}/versions`, { token: TEST_TOKEN, }); - const ownerVersions = ownerData as Array>; - expect(ownerVersions[0].permissions).toBeDefined(); + const snapshot = (data as Array>)[0]; + expect(snapshot.content).toEqual({ body: 'v1' }); + expect(snapshot.parentId).toBeUndefined(); + expect(snapshot.unlistedAt).toBeUndefined(); + expect(snapshot.permissions).toBeUndefined(); }); - it('bumps version on every mutation kind, not just content updates', async () => { + it('bumps version for a content mutation and for nothing else', async () => { const record = await t.ctx.stack.create(TYPE_ID, { body: 'v1' }); expect(record.version).toBe(1); + // 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' }); - expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(2); + expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(1); - await t.ctx.stack.mutate(record.id, { permissions: [{ access: 'public' }] }); - expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(3); + await t.ctx.stack.mutate(record.id, { permissions: [{ kind: 'anyone', label: 'read' }] }); + expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(1); + + await t.ctx.stack.mutate(record.id, { unlisted: true }); + expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(1); + + await t.ctx.stack.patchContent(record.id, { body: 'v2' }); + expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(2); await t.ctx.stack.delete(record.id); - expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(4); + expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(3); await t.ctx.stack.undelete(record.id); - expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(5); + expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(4); }); });