From 6a0936831af5034a4d1a21e08c0719480b2896d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 18:29:05 +0000 Subject: [PATCH 1/4] feat: adopt core 0.36's journal, permission and versioning surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four core releases moved the wire contract this server implements. The dependency bump is what forces the rest. Permissions are associations now. `Permission` is gone, replaced by two authority kinds — `permission`, whose bit is its label and whose grantee carries a required role, and `anyone`, which spells reach to the world affirmatively. Record-level ACL changes get their own verbs beside the declarative `permissions` key: POST /records/:id/permissions and POST /records/:id/permissions/delete amend the set where the key replaces it, both on the reshare gate rather than the write bit. Authority and data share storage and never share a call, so a kind sent to the wrong surface is a 400 — ScopedStack's refusal, not a route-level guard. GET /records/:id/journal serves the second durable tier. The mutate-surface gate and the authority-element projection a non-resharer gets are both ScopedStack.getJournal()'s; the route decodes the window with parseJournalParams(), bounds the page at 500 and reports `cursor`, which is the only end-of-log signal. Omitting `limit` reads the whole log by contract, so no default page size is supplied on the caller's behalf. A hard delete answers 200 with the record it destroyed rather than 204. That body is the requester's only report of the files the purge stranded — every other row naming them is gone by the time it lands — so the record is read ahead of the write, which also keeps an unreadable one at 404. Associations, permissions, parentId and unlisted all bump no version now, so the association endpoints stop reading If-Match and an If-Match beside a change set naming only no-bump keys is not checked. A snapshot carries content and its typeId alone, and a restore leaves the record in whatever container it is in now. Tests: the three new fixture blocks (grantAccess, revokeAccess, getJournal) are dispatched, along with six new error fixtures, two change-feed association frames, and the create/version fixtures core renamed. Every block's coverage gate passes. Requires the adapter-local journal fix from haverstack/core to be released: LocalAdapter drops opts.journal on createRecord/associate/dissociate, so the six journal fixtures fail against 0.35.0 and pass against the fix. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014M4YU2MeHyRijQv4V32zQ3 --- docs/api.md | 103 +++- package.json | 10 +- pnpm-lock.yaml | 78 +-- src/lib/queryLimit.ts | 14 +- src/routes/records.ts | 99 +++- tests/conformance.test.ts | 771 +++++++++++++++++++++++++++--- tests/routes/associations.test.ts | 68 ++- tests/routes/attachments.test.ts | 41 +- tests/routes/auth.test.ts | 2 +- tests/routes/changes.test.ts | 22 +- tests/routes/groups.test.ts | 20 +- tests/routes/records.test.ts | 301 +++++++++--- tests/routes/versions.test.ts | 69 ++- 13 files changed, 1341 insertions(+), 257 deletions(-) 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..69dda28 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.35.0", + "@haverstack/commons": "^0.30.0", + "@haverstack/core": "^0.36.0", + "@haverstack/wire-types": "^0.35.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.29.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..61892b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,17 +9,17 @@ importers: .: dependencies: '@haverstack/adapter-local': - specifier: ^0.31.0 - version: 0.31.0 + specifier: ^0.35.0 + version: 0.35.0 '@haverstack/commons': - specifier: ^0.26.0 - version: 0.26.0 + specifier: ^0.30.0 + version: 0.30.0 '@haverstack/core': - specifier: ^0.32.0 - version: 0.32.0 + specifier: ^0.36.0 + version: 0.36.0 '@haverstack/wire-types': - specifier: ^0.31.0 - version: 0.31.0 + specifier: ^0.35.0 + version: 0.35.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.29.0 + version: 0.29.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.35.0': + resolution: {integrity: sha512-cvzEDHxvHVW3lQj26sb/SvKUIdoexK+fsJTEGHnmEWq3NndcJg+8iyI7y5eF/SZF6WxeOkrMOer8xJy1HK5c/Q==} engines: {node: '>=22.5.0'} - '@haverstack/blob-adapter-disk@0.30.0': - resolution: {integrity: sha512-uvPKY5GImj7RNslw/HESWXd4KvhVIKuJ6ZIIi7kXKGemoWuFDxBo0cuvJxo3Qvwfr9pElgb1lQfyXfRp0I4syQ==} + '@haverstack/blob-adapter-disk@0.34.0': + resolution: {integrity: sha512-usxtw42BqdOAeWLy2NRhGngUwU3IEZs+HLgAdtMtxjcvH1d0O6mJhgZHOD9G+dpIxuRq7wMnj0t0QozTUV+cpg==} - '@haverstack/commons@0.26.0': - resolution: {integrity: sha512-BFOmiMDzV9NhOuSK4fZv47/ooBw+kDR9ME9sZoA2sdXD4pbt3Uek3kJE0JVtDkmLm7ZyK2frNg9lJp/+kGrAcg==} + '@haverstack/commons@0.30.0': + resolution: {integrity: sha512-ctxtIzmytjyQKWBb7d4pzdKLoxrGml61dPWjppZc6UVes0hiABIpZ5+oQwYb6M3KQDaRjUIKFLpSyVu5z3fIQA==} - '@haverstack/conformance-fixtures@0.25.0': - resolution: {integrity: sha512-IjLPUJh4Zr+JfpJ2gy0WZKmAS2tybK1/4rtytGB+LYm6hj2/K/ZXhDCjKmfFn0WYCQeVvbB+8j2RSnCFOW6GpA==} + '@haverstack/conformance-fixtures@0.29.0': + resolution: {integrity: sha512-u/DLwoFEEByYKiKI9NP/kkGaCSijqDALobrmSmD7TZD1bwXptMXZTwhFW4fm3poxS8204LHoyDZkyAhKDOmceg==} - '@haverstack/core@0.32.0': - resolution: {integrity: sha512-IPd3I9vJg3334MO+QLynGN+hULT0GG3Fv5aqvilFajJp3T4pyL1jfAQuYCD4I3ZuVZMMdsIjvQjFVB3F9v+xMg==} + '@haverstack/core@0.36.0': + resolution: {integrity: sha512-ntE5DbIO5bQgMiQjqAf7wNTm4hymINYfuq1XcUNSSMHdb7VZbZwH8EHIe7+FUBxz7y2mgB6zvQ2ymgWs4jXzkw==} - '@haverstack/record-adapter-sqlite@0.24.0': - resolution: {integrity: sha512-8to2k+i9ydCp/O0+SdEB5Pz6YbD6sG5NRo7pXySU4tIuG/+9iiWQ4fHL0Ig0ErYcpvIA/LjM9vMTthzJqFiPow==} + '@haverstack/record-adapter-sqlite@0.28.0': + resolution: {integrity: sha512-F3+kR0B+9HlR58eVwFXrRAfaJcl8YCVciRNGCjcZZJ65jIX9d6Xyb4N3axUbzpBzHRi7wDiMo6J98ZWcFmnHTQ==} engines: {node: '>=22.5.0'} - '@haverstack/wire-types@0.31.0': - resolution: {integrity: sha512-XoTsIfP3u72h7VMF9fq5KQi9qOvYKm3yIONdf6eNcFAnLjTk+fzylGFOISUXlUctLFWuay3xXMEHN1IWGavB5g==} + '@haverstack/wire-types@0.35.0': + resolution: {integrity: sha512-zDbKZWbFgPQs8epa6Ijh5rYOoGbD115hgT6gUU1ivZZ8WgRIjlMPYc0sVOwlToc+LcMBvgSP7J9xoac9hVaaKw==} '@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.35.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.34.0 + '@haverstack/core': 0.36.0 + '@haverstack/record-adapter-sqlite': 0.28.0 - '@haverstack/blob-adapter-disk@0.30.0': + '@haverstack/blob-adapter-disk@0.34.0': dependencies: - '@haverstack/core': 0.32.0 + '@haverstack/core': 0.36.0 - '@haverstack/commons@0.26.0': + '@haverstack/commons@0.30.0': dependencies: - '@haverstack/core': 0.32.0 + '@haverstack/core': 0.36.0 - '@haverstack/conformance-fixtures@0.25.0': + '@haverstack/conformance-fixtures@0.29.0': dependencies: - '@haverstack/wire-types': 0.31.0 + '@haverstack/wire-types': 0.35.0 - '@haverstack/core@0.32.0': {} + '@haverstack/core@0.36.0': {} - '@haverstack/record-adapter-sqlite@0.24.0': + '@haverstack/record-adapter-sqlite@0.28.0': dependencies: - '@haverstack/core': 0.32.0 + '@haverstack/core': 0.36.0 - '@haverstack/wire-types@0.31.0': + '@haverstack/wire-types@0.35.0': dependencies: - '@haverstack/core': 0.32.0 + '@haverstack/core': 0.36.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..e9827e7 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,18 +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); + // Read before the gate runs, so an unreadable record is the 404 the + // disclosure rule requires rather than the 403 delete() would raise. + const purged = hard ? await session.get(id) : null; + if (hard && !purged) throw new StackNotFoundError('Record not found'); + await session.delete(id, { hard, ifVersion: parseIfMatch(c.req.header('If-Match')) }); - if (hard) return c.body(null, 204); + if (hard) return c.json(serializeRecord(purged!)); return c.json(serializeRecord((await session.get(id))!)); }); @@ -156,6 +166,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 +208,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 +228,40 @@ 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 === 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..7a8d065 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); @@ -691,7 +757,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 +770,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 +809,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 +837,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 +851,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 +870,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 +963,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 +1042,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 +1065,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 +1097,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 +1116,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 +1130,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 +1289,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 +1310,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 +1367,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 +1390,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 +1478,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 +1508,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 +1525,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 +1543,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 +1559,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 +1599,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 +1628,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 +1636,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 +1726,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 +1754,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 +2066,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); }); }); From 731ebdef9f5f68ff11d8715a0a0092ccad39c90d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 19:06:41 +0000 Subject: [PATCH 2/4] fix(deps): require the adapter-local release that records a journal entry 0.35.0 drops opts.journal on createRecord/associate/dissociate, so a stack on it answers an empty log for a record whose history is not empty. The journal endpoint cannot be served correctly below 0.35.1, which makes it the floor rather than a range this happens to resolve past. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014M4YU2MeHyRijQv4V32zQ3 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 69dda28..2e72473 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "format:check": "prettier --check ." }, "dependencies": { - "@haverstack/adapter-local": "^0.35.0", + "@haverstack/adapter-local": "^0.35.1", "@haverstack/commons": "^0.30.0", "@haverstack/core": "^0.36.0", "@haverstack/wire-types": "^0.35.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61892b3..864533f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@haverstack/adapter-local': - specifier: ^0.35.0 - version: 0.35.0 + specifier: ^0.35.1 + version: 0.35.1 '@haverstack/commons': specifier: ^0.30.0 version: 0.30.0 @@ -264,8 +264,8 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@haverstack/adapter-local@0.35.0': - resolution: {integrity: sha512-cvzEDHxvHVW3lQj26sb/SvKUIdoexK+fsJTEGHnmEWq3NndcJg+8iyI7y5eF/SZF6WxeOkrMOer8xJy1HK5c/Q==} + '@haverstack/adapter-local@0.35.1': + resolution: {integrity: sha512-Wpdr3Fwq7LaQu29vFK0odpOysMUCtw6+nma0MZkby5imPOaoJLCzAJffzpbRCIkDPtVxsPJr8d1HuWmJNfd1Aw==} engines: {node: '>=22.5.0'} '@haverstack/blob-adapter-disk@0.34.0': @@ -1261,7 +1261,7 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@haverstack/adapter-local@0.35.0': + '@haverstack/adapter-local@0.35.1': dependencies: '@haverstack/blob-adapter-disk': 0.34.0 '@haverstack/core': 0.36.0 From bc1df07130941b632b81ec1842c029a829267099 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 20:35:50 +0000 Subject: [PATCH 3/4] fix: close hard-delete's read-then-delete race with deleteAndReturn() Bumps core to 0.37 (wire-types 0.36, adapter-local 0.36, commons 0.31, conformance-fixtures 0.30), which adds ScopedStack.deleteAndReturn(): the record is read and destroyed as one atomic adapter write instead of a separate get() before delete(). The old ordering left a window where a concurrent write could land between the read and the destroy, get purged by it, and never appear in the response body naming what the purge stranded. It also cost every hard delete an extra read. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01D3UaSogfUhAqKscin9vFQD --- package.json | 10 +++--- pnpm-lock.yaml | 76 +++++++++++++++++++++---------------------- src/routes/records.ts | 27 ++++++++------- 3 files changed, 56 insertions(+), 57 deletions(-) diff --git a/package.json b/package.json index 2e72473..088a4d7 100644 --- a/package.json +++ b/package.json @@ -41,10 +41,10 @@ "format:check": "prettier --check ." }, "dependencies": { - "@haverstack/adapter-local": "^0.35.1", - "@haverstack/commons": "^0.30.0", - "@haverstack/core": "^0.36.0", - "@haverstack/wire-types": "^0.35.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.29.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 864533f..a480f50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,17 +9,17 @@ importers: .: dependencies: '@haverstack/adapter-local': - specifier: ^0.35.1 - version: 0.35.1 + specifier: ^0.36.0 + version: 0.36.0 '@haverstack/commons': - specifier: ^0.30.0 - version: 0.30.0 + specifier: ^0.31.0 + version: 0.31.0 '@haverstack/core': + specifier: ^0.37.0 + version: 0.37.0 + '@haverstack/wire-types': specifier: ^0.36.0 version: 0.36.0 - '@haverstack/wire-types': - specifier: ^0.35.0 - version: 0.35.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.29.0 - version: 0.29.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.35.1': - resolution: {integrity: sha512-Wpdr3Fwq7LaQu29vFK0odpOysMUCtw6+nma0MZkby5imPOaoJLCzAJffzpbRCIkDPtVxsPJr8d1HuWmJNfd1Aw==} + '@haverstack/adapter-local@0.36.0': + resolution: {integrity: sha512-i5srwErYEYNzF/50teht6XaPSOj54Skn/9x5kfO+Q3kHb9IB3alJEangOmMSMu9vqU/i4ifnurYEgJwB6xsV5Q==} engines: {node: '>=22.5.0'} - '@haverstack/blob-adapter-disk@0.34.0': - resolution: {integrity: sha512-usxtw42BqdOAeWLy2NRhGngUwU3IEZs+HLgAdtMtxjcvH1d0O6mJhgZHOD9G+dpIxuRq7wMnj0t0QozTUV+cpg==} + '@haverstack/blob-adapter-disk@0.35.0': + resolution: {integrity: sha512-JWLGq0i8bBHjP3bF0qI6yVySZPuS6Vj8td9nPLi80z24s42FYFkYnylcXDHzbLFGYfD9tXyGeJjt5BIN1yBZbw==} - '@haverstack/commons@0.30.0': - resolution: {integrity: sha512-ctxtIzmytjyQKWBb7d4pzdKLoxrGml61dPWjppZc6UVes0hiABIpZ5+oQwYb6M3KQDaRjUIKFLpSyVu5z3fIQA==} + '@haverstack/commons@0.31.0': + resolution: {integrity: sha512-OhKy1IzXtZ9r6tFVwZ3fQb2Q+SbwBUu08FliU5BhwJXku/cFwVNBaOnZcrx1TQiOJ0i04ftz+WXq+BIDGblYxQ==} - '@haverstack/conformance-fixtures@0.29.0': - resolution: {integrity: sha512-u/DLwoFEEByYKiKI9NP/kkGaCSijqDALobrmSmD7TZD1bwXptMXZTwhFW4fm3poxS8204LHoyDZkyAhKDOmceg==} + '@haverstack/conformance-fixtures@0.30.0': + resolution: {integrity: sha512-20/m1byn9aUh96f2ny7fdAWwMgmrZdjCh4kSmv/VAVZHP9ihJOt0VLDN6DHrM6+QhxzhOA7GO/jJVp3ZAX3GWQ==} - '@haverstack/core@0.36.0': - resolution: {integrity: sha512-ntE5DbIO5bQgMiQjqAf7wNTm4hymINYfuq1XcUNSSMHdb7VZbZwH8EHIe7+FUBxz7y2mgB6zvQ2ymgWs4jXzkw==} + '@haverstack/core@0.37.0': + resolution: {integrity: sha512-tEFL/mVpVeOsQt0BDdtEJu8PQgy5DVoScCqEKID+DrAkvcspjcXrhUsGsCe87tWHbrYUWAMH8zW0UCSNw/ET0Q==} - '@haverstack/record-adapter-sqlite@0.28.0': - resolution: {integrity: sha512-F3+kR0B+9HlR58eVwFXrRAfaJcl8YCVciRNGCjcZZJ65jIX9d6Xyb4N3axUbzpBzHRi7wDiMo6J98ZWcFmnHTQ==} + '@haverstack/record-adapter-sqlite@0.29.0': + resolution: {integrity: sha512-r62aMZRZpW+B3bkQuZ0neJtNezHYgoBMR6s/kQnpoulJUMKViG6Lvs8bfW/MoVacZI1tWSDwtc8UsqAV6sblCA==} engines: {node: '>=22.5.0'} - '@haverstack/wire-types@0.35.0': - resolution: {integrity: sha512-zDbKZWbFgPQs8epa6Ijh5rYOoGbD115hgT6gUU1ivZZ8WgRIjlMPYc0sVOwlToc+LcMBvgSP7J9xoac9hVaaKw==} + '@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.35.1': + '@haverstack/adapter-local@0.36.0': dependencies: - '@haverstack/blob-adapter-disk': 0.34.0 - '@haverstack/core': 0.36.0 - '@haverstack/record-adapter-sqlite': 0.28.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.34.0': + '@haverstack/blob-adapter-disk@0.35.0': dependencies: - '@haverstack/core': 0.36.0 + '@haverstack/core': 0.37.0 - '@haverstack/commons@0.30.0': + '@haverstack/commons@0.31.0': dependencies: - '@haverstack/core': 0.36.0 + '@haverstack/core': 0.37.0 - '@haverstack/conformance-fixtures@0.29.0': + '@haverstack/conformance-fixtures@0.30.0': dependencies: - '@haverstack/wire-types': 0.35.0 + '@haverstack/wire-types': 0.36.0 - '@haverstack/core@0.36.0': {} + '@haverstack/core@0.37.0': {} - '@haverstack/record-adapter-sqlite@0.28.0': + '@haverstack/record-adapter-sqlite@0.29.0': dependencies: - '@haverstack/core': 0.36.0 + '@haverstack/core': 0.37.0 - '@haverstack/wire-types@0.35.0': + '@haverstack/wire-types@0.36.0': dependencies: - '@haverstack/core': 0.36.0 + '@haverstack/core': 0.37.0 '@hono/node-server@2.1.1(hono@4.13.7)': dependencies: diff --git a/src/routes/records.ts b/src/routes/records.ts index e9827e7..ccad2c5 100644 --- a/src/routes/records.ts +++ b/src/routes/records.ts @@ -122,26 +122,25 @@ 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); - // Read before the gate runs, so an unreadable record is the 404 the - // disclosure rule requires rather than the 403 delete() would raise. - const purged = hard ? await session.get(id) : null; - if (hard && !purged) throw new StackNotFoundError('Record not found'); - - await session.delete(id, { hard, ifVersion: parseIfMatch(c.req.header('If-Match')) }); - if (hard) return c.json(serializeRecord(purged!)); - 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 From 8551a01447067f80636a653e3d6ace52216adfaa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 20:36:06 +0000 Subject: [PATCH 4/4] fix: stop GET /:id/journal crashing on limit=0 parsePositiveInt accepts "0" (it checks digits, not positivity), so limit=0 reached clampJournalLimit() unclamped and produced an empty entries array with entries.length === limit true. The cursor computation then indexed entries[-1], throwing a bare TypeError that fell through to an unlabeled 500 instead of a clean empty page. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01D3UaSogfUhAqKscin9vFQD --- src/routes/records.ts | 3 ++- tests/routes/records.test.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/routes/records.ts b/src/routes/records.ts index ccad2c5..4894921 100644 --- a/src/routes/records.ts +++ b/src/routes/records.ts @@ -256,7 +256,8 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono 0 && entries.length === limit ? entries[entries.length - 1]!.seq : null, }; return c.json(body); }); diff --git a/tests/routes/records.test.ts b/tests/routes/records.test.ts index 7a8d065..9fda138 100644 --- a/tests/routes/records.test.ts +++ b/tests/routes/records.test.ts @@ -676,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