From 32c5c54d513f90ab8f0c1a08d87177870ffe2bd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:00:45 +0000 Subject: [PATCH 1/5] feat: adopt core 0.38's streamlined API Core 0.38 reshaped its public surface around one Actor type and one name per concept; the server follows so its wire matches the spec it implements. - Scope requests with Stack.asActor(), open with Stack.open(), and pass ownerEntityId to the adapter. - DELETE /records/:id takes ?purge=true; the change feed's resume token is `cursor` (ready frame and record frames); the journal bound is ?afterSeq=; POST /attachments/gc reports deletedFileIds. - POST /tokens takes { principalId?, subjectId? }, the same Actor names the response and GET /tokens already report, instead of { entityId, onBehalfOf }. - resumeBufferKey keys on every ChangeFilter field (baseId and both createdBy halves, as sets). It still read the removed entityId and knew nothing of baseId or createdBy, so two connections with different filters could have shared one buffer and received each other's frames. - Dispatch the renamed and new 0.32 conformance fixtures (purge, exact typeId and baseId feed filters, attachment and referencesFileId queries). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XrHN9A5Uzz9FQN6gEeiuX8 --- docs/api.md | 90 +++--- package.json | 10 +- pnpm-lock.yaml | 76 ++--- src/lib/json.ts | 6 +- src/lib/queryWorker/pool.ts | 3 +- src/lib/queryWorker/protocol.ts | 3 +- src/lib/queryWorker/worker.ts | 4 +- src/lib/resumeBuffer.ts | 31 +- src/lib/resumeCursor.ts | 6 +- src/middleware/auth.ts | 4 +- src/routes/attachments.ts | 6 +- src/routes/auth.ts | 11 +- src/routes/changes.ts | 27 +- src/routes/entity.ts | 6 +- src/routes/records.ts | 45 +-- src/routes/tokens.ts | 38 +-- src/routes/types.ts | 16 +- src/routes/wellknown.ts | 4 +- src/stack.ts | 6 +- src/testing.ts | 4 +- src/types.ts | 2 +- tests/app.test.ts | 8 +- tests/conformance.test.ts | 448 ++++++++++++++++++---------- tests/lib/changeFeedClient.test.ts | 10 +- tests/lib/resumeBuffer.test.ts | 18 +- tests/lib/resumeCursor.test.ts | 4 +- tests/middleware/errors.test.ts | 14 +- tests/routes/associations.test.ts | 24 +- tests/routes/attachments.test.ts | 88 +++--- tests/routes/auth.test.ts | 24 +- tests/routes/changes.test.ts | 79 +++-- tests/routes/contentQuery.test.ts | 18 +- tests/routes/entity.test.ts | 12 +- tests/routes/groups.test.ts | 18 +- tests/routes/queryScoping.test.ts | 89 +++--- tests/routes/records.test.ts | 327 +++++++++++--------- tests/routes/relatedToQuery.test.ts | 28 +- tests/routes/tokens.test.ts | 36 +-- tests/routes/tombstones.test.ts | 10 +- tests/routes/types.test.ts | 6 +- tests/routes/versions.test.ts | 18 +- 41 files changed, 973 insertions(+), 704 deletions(-) diff --git a/docs/api.md b/docs/api.md index 22d3ef1..f81fbbb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -51,7 +51,7 @@ POST /auth/token { "did": "...", "nonce": "...", "signature": "..." } `signature` is base64url. What gets signed is not the nonce alone but a domain-separated payload built by `buildAuthChallengePayload()` in `@haverstack/core/wire` — `haverstack-auth-v1\n\n\n` — where `` is this server's own configured public origin (`BASE_URL`), never a request header. Binding the origin into the signed payload is what stops a signature obtained for one server from being redeemed at another. `verifyAuthChallenge()` verifies it server-side; a client uses `signAuthChallenge()` / `didCredentialFromKeypair()` from the same module so both sides build the identical payload. -`POST /auth/token` never delegates: `principalId` and `subjectId` in the response are always equal, since proving key possession proves the principal and says nothing about whom that key may act for. A delegated token is only ever asserted by the owner out of band, via `POST /tokens`' `onBehalfOf`. +`POST /auth/token` never delegates: `principalId` and `subjectId` in the response are always equal, since proving key possession proves the principal and says nothing about whom that key may act for. A delegated token is only ever asserted by the owner out of band, via `POST /tokens`' `subjectId`. A nonce is single-use, bound to the DID it was issued for, and expires per the `expiresAt` `POST /auth/challenge` returns (5 minutes). Redeeming it — successfully or not — spends it; a second redemption of the same nonce always fails. A redemption naming a _different_ DID than the nonce was issued to spends nothing, so a third party who learns a nonce cannot burn it out from under its holder. @@ -74,26 +74,26 @@ The retryable column is why these carry a `code` at all rather than being bodyle ## Records -| Method | Path | Auth | Description | -| ------ | ---------------------------------- | ---------- | --------------------------------------- | -| GET | `/records` | Optional | Query records via URL params | -| POST | `/records/query` | Optional | Query records with content filters | -| POST | `/records` | Required | Create a record | -| GET | `/records/:id` | Optional | Get a record by ID | -| PATCH | `/records/:id` | Required | Apply a change set to a record | -| DELETE | `/records/:id` | Required | Soft-delete (or 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 | -| POST | `/records/:id/migrate` | Owner only | Change a record's typeId | +| Method | Path | Auth | Description | +| ------ | ---------------------------------- | ---------- | ----------------------------------------- | +| GET | `/records` | Optional | Query records via URL params | +| POST | `/records/query` | Optional | Query records with content filters | +| POST | `/records` | Required | Create a record | +| GET | `/records/:id` | Optional | Get a record by ID | +| PATCH | `/records/:id` | Required | Apply a change set to a record | +| DELETE | `/records/:id` | Required | Soft-delete (or purge with `?purge=true`) | +| POST | `/records/:id/undelete` | Required | Reverse a soft delete | +| GET | `/records/:id/permissions` | Optional | Get permissions | +| POST | `/records/:id/permissions` | Required | Grant one permission element | +| POST | `/records/:id/permissions/delete` | Required | Revoke one permission element | +| GET | `/records/:id/associations` | Optional | List associations | +| POST | `/records/:id/associations` | Required | Add an association | +| POST | `/records/:id/associations/delete` | Required | Remove an association | +| GET | `/records/:id/journal` | Optional | Read the change journal | +| GET | `/records/:id/versions` | Optional | List version history | +| GET | `/records/:id/versions/:version` | Optional | Get a specific version | +| POST | `/records/:id/restore/:version` | Required | Restore a previous version | +| POST | `/records/:id/migrate` | Owner only | Change a record's typeId | ### The change set @@ -134,9 +134,9 @@ Removing an association is `POST /records/:id/associations/delete`, not a body-b A `relationship` association's `target` is a discriminated union naming which identifier space the value belongs to — a Record (in this Stack or another), an identity, or something outside the Stack entirely: ```json -{ "kind": "relationship", "label": "reply-to", "target": { "scope": "record", "recordId": "xyz789" } } -{ "kind": "relationship", "label": "author", "target": { "scope": "entity", "entityId": "did:key:z6Mk..." } } -{ "kind": "relationship", "label": "syndicated-to", "target": { "scope": "external", "ns": "atproto", "id": "at://..." } } +{ "kind": "relationship", "label": "reply-to", "target": { "kind": "record", "recordId": "xyz789" } } +{ "kind": "relationship", "label": "author", "target": { "kind": "entity", "entityId": "did:key:z6Mk..." } } +{ "kind": "relationship", "label": "syndicated-to", "target": { "kind": "external", "ns": "atproto", "id": "at://..." } } ``` A change set's `permissions` key replaces every permission on the record and answers `200` with the updated record. An empty array makes the record private (owner-only). `GET /records/:id/permissions` reads them back under the `{ "permissions": [...] }` envelope, and `POST /records/:id/permissions[/delete]` amend the set one element at a time — see [Permissions](#permissions). @@ -145,15 +145,15 @@ A change set's `permissions` key replaces every permission on the record and ans `POST /records/:id/undelete` reverses a soft delete and returns the record as it now stands (`deletedAt` absent). Idempotent — a second call on an already-active record returns the same result. -`POST /records` returns `200`, not `201` — the response is the created record, same shape as every other write. The body is the full record: `id` is client-minted (12 lowercase Crockford base-32 characters, no reserved `_` prefix — omit it to let the server generate one) and, when supplied, must encode a creation timestamp within the server's clock-skew tolerance; `version` is never accepted from the client — that, like `entityId`/`principalId`, is always server-assigned. A duplicate `id` returns `409` with code `conflict`. +`POST /records` returns `200`, not `201` — the response is the created record, same shape as every other write. The body is the full record: `id` is client-minted (12 lowercase Crockford base-32 characters, no reserved `_` prefix — omit it to let the server generate one) and, when supplied, must encode a creation timestamp within the server's clock-skew tolerance; `version` is never accepted from the client — that, like `createdBy`/`updatedBy`, is always server-assigned. A duplicate `id` returns `409` with code `conflict`. -`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`. +`createdAt`/`updatedAt` are honored only from the stack owner acting alone (undelegated, authenticated as the owner) — the same tier that gates purge, `commitMigration()`, and `includeUnlisted`. From anyone else, both are dropped rather than forwarded: forwarding them would otherwise turn an ordinary grantee's create into a `403`, since a non-owner backdating attempt is refused outright rather than silently ignored. For an owner-acting-alone create: `updatedAt` defaults to `createdAt` (never to now), so a plain import doesn't fabricate an edit; `updatedAt` earlier than `createdAt` is a `422` validation error, including when `createdAt` itself defaulted to now; omitting `id` derives it from `createdAt` instead of the current time; supplying both `id` and `createdAt` checks them against each other under the same clock-skew tolerance. An owner's plain `id`-only create (no `createdAt`) is unaffected — it still gets the ordinary id-vs-now check. A malformed `createdAt`/`updatedAt` value returns `422` with code `validation`. `If-Match: ""` is accepted for optimistic concurrency on every endpoint that bumps a record's version — `PATCH /records/:id`, `DELETE /records/:id`, `POST /records/:id/undelete`, `POST /records/:id/restore/:version`, and `POST /records/:id/migrate`. One `If-Match` fences a whole multi-aspect change set. The association and permission endpoints read no `If-Match`, and one sent to any of them is ignored rather than refused: a set add or remove composes whatever the write order, so there is no race for a precondition to guard. A mismatch returns `412` with code `version_conflict` and a `versionConflict: { recordId, expectedVersion, actualVersion }` payload; omitting the header keeps last-writer-wins. A malformed value (not a bare, optionally quoted version — a weak comparator like `W/"5"` included) is `400`, not read as absent: a header sent to fence a write that silently degrades to unconditional last-writer-wins defeats the only thing it was sent to do. ### Query parameters -`GET /records` accepts, among others: `typeId`, `parentId`, `appId`, `entityId`, `principalId`, `tag`, `hasAttachment`, `attachmentFileId`, `relatedTo` (+ `relatedToStack`, `relatedToLabel`), `relatedToEntity` (+ `relatedToLabel`), `relatedToNs` (+ `relatedToId`, `relatedToLabel`), `search`, `createdBefore`/`createdAfter`, `updatedBefore`/`updatedAfter`, `includeDeleted`, `includeUnlisted`, `sort`/`sortContent`/`direction`, `limit`, `cursor`. `entityId` filters by the record's attributed subject; `principalId` filters by the delegating app, if any (see [Permissions](#permissions) below). `includeUnlisted` is owner-only — see [Unlisted](#unlisted) below. `POST /records/query` accepts the same filters as a JSON body, plus `content` (field-equality) and `contentPresent` (field-presence) filters. Omitting `limit` returns one default-sized page (50 records), never the whole result set — `cursor` is the only end-of-results signal; a `limit` above this server's own ceiling (1000) is clamped down to it rather than rejected. +`GET /records` accepts, among others: `typeId`, `parentId`, `appId`, `createdBySubject`, `createdByPrincipal`, `tag`, `attachmentLabel`, `attachmentFileId`, `referencesFileId`, `relatedTo` (+ `relatedToStack`, `relatedToLabel`), `relatedToEntity` (+ `relatedToLabel`), `relatedToNs` (+ `relatedToId`, `relatedToLabel`), `search`, `createdBefore`/`createdAfter`, `updatedBefore`/`updatedAfter`, `includeDeleted`, `includeUnlisted`, `sort`/`sortContent`/`direction`, `limit`, `cursor`. `createdBySubject` filters by the record's author; `createdByPrincipal` by the principal behind that create, if delegated (see [Permissions](#permissions) below). Both are repeatable. `attachmentLabel` and `attachmentFileId` together match a single attachment association (either is valid alone); `referencesFileId` matches any record referencing the file, through an attachment association under any label or a top-level `file-ref` field. `includeUnlisted` is owner-only — see [Unlisted](#unlisted) below. `POST /records/query` accepts the same filters as a JSON body, plus `content` (field-equality) and `contentPresent` (field-presence) filters. Omitting `limit` returns one default-sized page (50 records), never the whole result set — `cursor` is the only end-of-results signal; a `limit` above this server's own ceiling (1000) is clamped down to it rather than rejected. Parsing and encoding these parameters is `@haverstack/core/wire`'s job, not this server's — `parseQueryParams`, `parseQueryBody`, and `parseChangeParams` there are the exact inverse of what `@haverstack/adapter-api` builds, and are the normative reference for every rule below. `docs/spec/wire-format.md` § Query parameters in `haverstack/core` documents the full parameter set and its edge cases; what follows here is a summary, kept for this server's own reference. @@ -170,7 +170,7 @@ A relationship association's target names one of three scopes — a Record, an i | `relatedToNs` | `external` | `relatedToId` (omit to match the whole namespace) | | `relatedToLabel` | any, or none | narrows any of the above; valid alone | -`relatedTo`, `relatedToEntity`, and `relatedToNs` name different, mutually exclusive scopes — mixing parameters from two of them is `400`. `relatedToStack` is only meaningful alongside `relatedTo`, and `relatedToId` only alongside `relatedToNs`; either appearing without its pair is `400`. Absence and emptiness are not the same thing: omitting `relatedToStack` means the target has no `stackUrl` (i.e. this Stack) and does _not_ match a target that carries one, while an _empty_ `relatedToStack` is `400` rather than being read as "local" or "any". The same holds for `relatedToId`: omit it to match the whole namespace; an empty value is `400`. A bare `relatedToLabel`, with none of the scope parameters, is valid and matches every target under that label. `filter.relatedTo` in the `POST /records/query` body carries the same rule as a `{ label?, target? }` object, `target` being `{ scope: 'record' | 'entity' | 'external', ... }`. +`relatedTo`, `relatedToEntity`, and `relatedToNs` name different, mutually exclusive scopes — mixing parameters from two of them is `400`. `relatedToStack` is only meaningful alongside `relatedTo`, and `relatedToId` only alongside `relatedToNs`; either appearing without its pair is `400`. Absence and emptiness are not the same thing: omitting `relatedToStack` means the target has no `stackUrl` (i.e. this Stack) and does _not_ match a target that carries one, while an _empty_ `relatedToStack` is `400` rather than being read as "local" or "any". The same holds for `relatedToId`: omit it to match the whole namespace; an empty value is `400`. A bare `relatedToLabel`, with none of the scope parameters, is valid and matches every target under that label. `filter.relatedTo` in the `POST /records/query` body carries the same rule as a `{ label?, target? }` object, `target` being `{ kind: 'record' | 'entity' | 'external', ... }`. #### Content filter keys @@ -178,11 +178,11 @@ A relationship association's target names one of three scopes — a Record, an i ### Soft delete and tombstones -`DELETE /records/:id` without `?hard=true` leaves a **tombstone**: the record continues to exist and remains reachable by `id`, but its `content` is projected as `{}` and `deletedAt` is set. This applies uniformly everywhere the record is served with content attached — `GET /records/:id`, `GET`/`POST /records/query` with `filter.includeDeleted: true`, and a `deleted`-kind change feed frame with `?include=record` — so a client sees the same emptied shape regardless of which route it came through. `GET /records/:id/versions` and `GET /records/:id/versions/:version` are the deliberate exception: version history is never tombstoned and continues to serve full content. +`DELETE /records/:id` without `?purge=true` leaves a **tombstone**: the record continues to exist and remains reachable by `id`, but its `content` is projected as `{}` and `deletedAt` is set. This applies uniformly everywhere the record is served with content attached — `GET /records/:id`, `GET`/`POST /records/query` with `filter.includeDeleted: true`, and a `deleted`-kind change feed frame with `?include=record` — so a client sees the same emptied shape regardless of which route it came through. `GET /records/:id/versions` and `GET /records/:id/versions/:version` are the deliberate exception: version history is never tombstoned and continues to serve full content. 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. +**A purge answers `200` with the record it destroyed**, as it last stood, and `404` for a record that was not there. It is the one response that is not the record a write produced, because this write produces none: the attachment associations and `file-ref` fields in that body are the files the purge stranded, and the purge has just destroyed every other row naming them, so a client answered an empty body could not name the bytes it may now need to erase. A `purged` change-feed frame still carries nothing — that fans out to every subscriber, while this body goes only to the owner who authorized the purge. ## Journal @@ -190,7 +190,7 @@ Every write that emits a change event also appends one entry to the record's jou ``` GET /records/:id/journal — the log, oldest first -GET /records/:id/journal?sinceSeq=4 — entries after seq 4, exclusive +GET /records/:id/journal?afterSeq=4 — entries after seq 4, exclusive GET /records/:id/journal?limit=50 — at most 50 entries ``` @@ -204,7 +204,7 @@ GET /records/:id/journal?limit=50 — at most 50 entries "ops": ["associate"], "version": 1, "typeId": "com.example/note@1", - "actor": { "entityId": "did:key:z6Mk..." }, + "actor": { "subjectId": "did:key:z6Mk..." }, "associations": [ { "op": "repoint", @@ -232,13 +232,13 @@ GET /records/:id/journal?limit=50 — at most 50 entries **`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. +**`cursor` is the only end-of-log signal.** This server caps a page at 500 entries and reports the `seq` to send back as `afterSeq`; 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. +**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 `reshare`, 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. +A record the server does not have — never created, or purged — is `404`, never an empty log: a purge 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 @@ -274,17 +274,17 @@ A `POST /records` body may carry `unlistedAt` to create the record already unlis `GET /changes` opens a Server-Sent Events connection scoped exactly like `GET /records` — an authenticated requester sees what their session may read, an anonymous one sees only public records — via the same `canRead` predicate `get()`/`query()` answer with. Every connection gets a `ready` frame first, always, before anything else: it's what makes subscribe-then-query gap-free, since a client that awaits it before querying knows every later change reaches it as a frame. -This server mints resume cursors: `ready` carries the current head as `seq`, and every `record` frame carries that same value as its SSE `id:`. A reconnect presenting `Last-Event-ID` (or the equivalent `?since=`) resumes from there — receiving exactly the changes it missed, never one it already has — or gets a `reset` frame naming why it can't: `cursor_expired` for a cursor this server no longer recognizes (its buffer aged out, or was minted for different query params — a cursor is self-describing, so a mismatch is detectable rather than silently resumed against the wrong stream), or `overflow` for a recognized buffer that has already dropped what a full resume would need. A cursor outside the base64url alphabet is refused locally as a `400`, before the connection ever opens, rather than answered with a `reset` — it isn't a value this server could ever have minted, so there's nothing to reconcile by resuming from it. +This server mints resume cursors: `ready` carries the current head as `cursor`, and every `record` frame carries that same value as its SSE `id:`. A reconnect presenting `Last-Event-ID` (or the equivalent `?since=`) resumes from there — receiving exactly the changes it missed, never one it already has — or gets a `reset` frame naming why it can't: `cursor_expired` for a cursor this server no longer recognizes (its buffer aged out, or was minted for different query params — a cursor is self-describing, so a mismatch is detectable rather than silently resumed against the wrong stream), or `overflow` for a recognized buffer that has already dropped what a full resume would need. A cursor outside the base64url alphabet is refused locally as a `400`, before the connection ever opens, rather than answered with a `reset` — it isn't a value this server could ever have minted, so there's nothing to reconcile by resuming from it. Resume is per-connection state, not global: each distinct (session, filter) pairing gets its own buffer, retained for a bounded time and depth past the last connection using it — long enough for an ordinary reconnect, not indefinitely. A gap resumption can't close is exactly what `reset` exists for; a client's repair is the same either way — reconcile by querying `GET /records` / `POST /records/query`. A `purged` frame in a replayed backlog is never re-checked against current permissions (there is no record left to check, and the mutation-time decision is the only one that will ever exist); every other replayed frame is. -Query parameters, all optional and composable: `typeId` (repeatable, matched by baseId so a type-version bump never orphans a subscription), `parentId` (`"null"` selects root records, same as `GET /records`), `entityId` (the record's author, not who made the change), `kind` (repeatable: `created`, `changed`, `deleted`, `purged`), `include=record` (attach the record as of the change; ignored for `kind=purged`), `includeUnlisted` (owner-only — see [Unlisted](#unlisted)). Filtering is exact, not advisory — a filtered connection never receives a frame it did not match, and an unrecognized `kind` or `include` value is a `400`. A `parentId` filter is the one place where matching and frame contents come apart, and a subscriber that reads the two as the same thing will misroute a move: +Query parameters, all optional and composable: `typeId` (repeatable, exact match as on `GET /records`), `baseId` (repeatable, the whole type family — so a type-version bump never orphans a subscription), `parentId` (`"null"` selects root records, same as `GET /records`), `createdBySubject` / `createdByPrincipal` (repeatable; the record's author, not who made the change), `kind` (repeatable: `created`, `changed`, `deleted`, `purged`), `include=record` (attach the record as of the change; ignored for `kind=purged`), `includeUnlisted` (owner-only — see [Unlisted](#unlisted)). Filtering is exact, not advisory — a filtered connection never receives a frame it did not match, and an unrecognized `kind` or `include` value is a `400`. A `parentId` filter is the one place where matching and frame contents come apart, and a subscriber that reads the two as the same thing will misroute a move: 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 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. +Every change (`event: record`) carries `kind`, `ops`, `recordId`, `typeId`, `version`, `updatedAt`, and — when known — `actor` (who made the change; never the record's own author, which is what `createdBySubject` filters on). `kind` is the coarse branch a handler can be complete on; `ops` names every aspect the write actually moved (`create`, `patch`, `associate`, `dissociate`, `reshare`, `reparent`, `migrate`, `restore`, `delete`, `undelete`, `purge`, `unlist`, `list`) for a consumer that distinguishes, say, a reshare from an edit. It is derived by diffing the record against its prior state rather than read off the request, so a change set naming an aspect without moving it is never reported as moving it; it is never empty, and is multi-entry only for a change set, since every other op names a whole-record transition and is emitted alone. Most ops bump no `version` — only `patch` and the whole-record transitions do — so a frame for one of the rest carries whatever `version`/`updatedAt` the record already held. `kind` resolves to the most conservative entry — a change set carrying `unlist` is `deleted` whatever else it carries, because a subscriber holding the record still has to drop it. A frame whose `ops` include `associate` or `dissociate` carries `associationsAdded` / `associationsRemoved`, reporting what the call moved: an added association as it now stands, annotation included, and a removed one by identity only, since its annotation no longer describes anything current. Both report only what is true now — the [journal](#journal) is where an overwritten or removed annotation survives. -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 `purged` frame — from a purge — carries none of `record`, `parentId`, or the record's own author, whatever the connection asked for: purge is the erasure primitive, and a frame naming what was destroyed is deliberately all that survives it. 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. @@ -311,9 +311,9 @@ This server is single-process: `GET /changes` reports only writes made through t `POST /attachments` takes the raw bytes as the request body, `Content-Type` as the declared `mimeType` (defaults to `application/octet-stream`), an optional `Content-Disposition` header for the filename (RFC 5987 `filename*` form), and an optional `?appId=` query param. It returns the created `_attachment@1` record (`200`) — the same shape as `POST /records` — not a bare `fileId`. Requires a `create` grant on `_attachment@1`; anonymous requests get `401`, an authenticated requester with no grant gets `403`. -`GET /attachments/:fileId` resolves `Content-Type` from three sources, in order: the `?contentType` query param, extension inference from `?filename`, then the fileId's stored metadata (the first-recorded `_attachment@1` record's `mimeType`, by earliest `createdAt`). Whichever candidate wins is checked against a safe-list (`image/*` except `svg+xml`, `video/*`, `audio/*`, `application/pdf`, `text/plain`, `application/octet-stream`); anything else is served as `application/octet-stream` instead, regardless of which source produced it. When the candidate is forced this way, `Content-Disposition` is also forced to a bare `attachment` with no filename — Content-Type forcing alone doesn't stop a browser from sniffing the body back into the original type without both `nosniff` and a non-inline disposition. Otherwise `Content-Disposition`'s filename resolves as: `?filename` if given, else the requester's own `_attachment@1` record (by `entityId`), else the first-recorded record's. `X-Content-Type-Options: nosniff` is set on every response. For a non-owner requester, a missing fileId and one they simply can't access are indistinguishable — both return `403` (`401` if the requester is anonymous) — so a guessed fileId can't be used to probe whether specific bytes exist on the stack. +`GET /attachments/:fileId` resolves `Content-Type` from three sources, in order: the `?contentType` query param, extension inference from `?filename`, then the fileId's stored metadata (the first-recorded `_attachment@1` record's `mimeType`, by earliest `createdAt`). Whichever candidate wins is checked against a safe-list (`image/*` except `svg+xml`, `video/*`, `audio/*`, `application/pdf`, `text/plain`, `application/octet-stream`); anything else is served as `application/octet-stream` instead, regardless of which source produced it. When the candidate is forced this way, `Content-Disposition` is also forced to a bare `attachment` with no filename — Content-Type forcing alone doesn't stop a browser from sniffing the body back into the original type without both `nosniff` and a non-inline disposition. Otherwise `Content-Disposition`'s filename resolves as: `?filename` if given, else the requester's own `_attachment@1` record (by `createdBy.subjectId`), else the first-recorded record's. `X-Content-Type-Options: nosniff` is set on every response. For a non-owner requester, a missing fileId and one they simply can't access are indistinguishable — both return `403` (`401` if the requester is anonymous) — so a guessed fileId can't be used to probe whether specific bytes exist on the stack. -`POST /attachments/gc` sweeps for attachment bytes unreachable from any record — live or soft-deleted — and deletes both the bytes and their `_attachment@1` metadata. Body is `{ graceMs?, dryRun? }`, both optional: `graceMs` is how recently-uploaded an unreferenced file must be to survive collection (default 24 hours, covering the upload-then-associate window; `0` collects immediately), `dryRun` computes the result without deleting anything. Returns `{ deleted: [fileId...], reclaimedBytes }`. No built-in scheduling — invoke it directly, or drive it from an external cron; `dryRun` makes a probe-first workflow safe. +`POST /attachments/gc` sweeps for attachment bytes unreachable from any record — live or soft-deleted — and deletes both the bytes and their `_attachment@1` metadata. Body is `{ graceMs?, dryRun? }`, both optional: `graceMs` is how recently-uploaded an unreferenced file must be to survive collection (default 24 hours, covering the upload-then-associate window; `0` collects immediately), `dryRun` computes the result without deleting anything. Returns `{ deletedFileIds: [fileId...], reclaimedBytes }`. No built-in scheduling — invoke it directly, or drive it from an external cron; `dryRun` makes a probe-first workflow safe. An `attachment` association (`{ kind: "attachment", label, fileId }`, set via `POST /records/:id/associations`) may carry an optional `attachmentRecordId`, naming the `_attachment` record whose upload established that particular reference — useful when several records share one `fileId` but each wants its own uploader's filename. The pointer annotates the reference rather than identifying it: association identity stays `(kind, label, fileId)`, so `dissociate()` matches without it, and re-associating with a different (or absent) `attachmentRecordId` re-points the existing association in place rather than adding a second one. `GET /attachments/:fileId` itself never resolves this pointer — a plain download names a fileId, not a reference — so a client holding the association resolves the filename itself (via `resolveReferencedAttachment()` from `@haverstack/core/wire`) and passes it as `?filename`. @@ -327,15 +327,15 @@ An `attachment` association (`{ kind: "attachment", label, fileId }`, set via `P | POST | `/tokens` | Owner only | Create an API token | | DELETE | `/tokens/:id` | Owner only | Revoke an API token | -`POST /tokens` accepts `{ entityId, onBehalfOf?, label?, expiresAt? }`. `entityId` is the token's principal (who authenticates); `onBehalfOf` optionally asserts a delegation — the subject the principal acts for. Both, when given, must be DIDs (`422` otherwise). The response always reports both: `{ id, token, principalId, subjectId, label, createdAt, expiresAt }`. Omitting `onBehalfOf` issues an undelegated token, where `subjectId` equals `principalId`. +`POST /tokens` accepts `{ principalId?, subjectId?, label?, expiresAt? }` — the same `Actor` names the response and `GET /tokens` report. `principalId` is the token's principal (who authenticates), defaulting to the owner; `subjectId` optionally asserts a delegation — the subject the principal acts for. Both, when given, must be DIDs (`422` otherwise). The response always reports both: `{ id, token, principalId, subjectId, label, createdAt, expiresAt }`. Omitting `subjectId` issues an undelegated token, where `subjectId` equals `principalId`. ## Permissions 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 -{ "kind": "permission", "label": "read", "grantee": { "scope": "entity", "entityId": "did:key:..." } } -{ "kind": "permission", "label": "write", "grantee": { "scope": "group", "groupId": "1hk153x0000g", "role": "member" } } +{ "kind": "permission", "label": "read", "grantee": { "kind": "entity", "entityId": "did:key:..." } } +{ "kind": "permission", "label": "write", "grantee": { "kind": "group", "groupId": "1hk153x0000g", "role": "member" } } { "kind": "anyone", "label": "read" } ``` @@ -389,4 +389,4 @@ Withdrawal is a soft delete, and only deletion withdraws a grant. `DELETE /recor ### Principal and subject -A token names two identities: the **principal**, who authenticated (governs authority — grant lookups, a change set's `permissions` key), and the **subject**, who the principal acts for (governs attribution — `record.entityId` on writes, `-own` matching). They're equal unless the token was issued with `onBehalfOf`. A delegated write stamps both: `entityId` is the subject, and `principalId` appears on the record when the two differ. `GET /records` and `POST /records/query` can filter on either via `entityId`/`principalId`. Effective authority under delegation is the intersection of both parties' grants — a delegated app can't act beyond what the subject itself also permits. +A token names two identities: the **principal**, who authenticated (governs authority — grant lookups, a change set's `permissions` key), and the **subject**, who the principal acts for (governs attribution — `record.createdBy.subjectId` on writes, `-own` matching). They're equal unless the token was issued with a distinct `subjectId`. A delegated write stamps both into `createdBy` (and `updatedBy`): `subjectId` is the subject, and `principalId` is present when the two differ. `GET /records` and `POST /records/query` can filter on either via `createdBySubject`/`createdByPrincipal`. Effective authority under delegation is the intersection of both parties' grants — a delegated app can't act beyond what the subject itself also permits. diff --git a/package.json b/package.json index 3979389..bc6187d 100644 --- a/package.json +++ b/package.json @@ -41,10 +41,10 @@ "format:check": "prettier --check ." }, "dependencies": { - "@haverstack/adapter-local": "^0.36.0", - "@haverstack/commons": "^0.31.0", - "@haverstack/core": "^0.37.0", - "@haverstack/wire-types": "^0.36.0", + "@haverstack/adapter-local": "^0.37.0", + "@haverstack/commons": "^0.32.0", + "@haverstack/core": "^0.38.0", + "@haverstack/wire-types": "^0.37.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.31.0", + "@haverstack/conformance-fixtures": "^0.32.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 a1686e0..3fb913b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,17 +9,17 @@ importers: .: dependencies: '@haverstack/adapter-local': - specifier: ^0.36.0 - version: 0.36.0 + specifier: ^0.37.0 + version: 0.37.0 '@haverstack/commons': - specifier: ^0.31.0 - version: 0.31.0 + specifier: ^0.32.0 + version: 0.32.0 '@haverstack/core': + specifier: ^0.38.0 + version: 0.38.0 + '@haverstack/wire-types': specifier: ^0.37.0 version: 0.37.0 - '@haverstack/wire-types': - specifier: ^0.36.0 - version: 0.36.0 '@hono/node-server': specifier: ^2.1.1 version: 2.1.1(hono@4.13.7) @@ -37,8 +37,8 @@ importers: specifier: ^10.0.1 version: 10.0.1(eslint@10.8.1) '@haverstack/conformance-fixtures': - specifier: ^0.31.0 - version: 0.31.0 + specifier: ^0.32.0 + version: 0.32.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.36.0': - resolution: {integrity: sha512-i5srwErYEYNzF/50teht6XaPSOj54Skn/9x5kfO+Q3kHb9IB3alJEangOmMSMu9vqU/i4ifnurYEgJwB6xsV5Q==} + '@haverstack/adapter-local@0.37.0': + resolution: {integrity: sha512-uUQR3xkuiIlHdI4OIsP+qNnkGpW6NOiQVOosCEvIiSqZYO3ewBOOhFhQqPigrUQ814/92OgqhN633KqRmqRWnA==} engines: {node: '>=22.5.0'} - '@haverstack/blob-adapter-disk@0.35.0': - resolution: {integrity: sha512-JWLGq0i8bBHjP3bF0qI6yVySZPuS6Vj8td9nPLi80z24s42FYFkYnylcXDHzbLFGYfD9tXyGeJjt5BIN1yBZbw==} + '@haverstack/blob-adapter-disk@0.36.0': + resolution: {integrity: sha512-OfjZMQ3fAl4K5gkzQOPxH3XoBM8FaCLY2wUdFqKe2yI6bFqhXWlXkNhGue8syCX6BHn8DR566MwWgWo/qCJYBA==} - '@haverstack/commons@0.31.0': - resolution: {integrity: sha512-OhKy1IzXtZ9r6tFVwZ3fQb2Q+SbwBUu08FliU5BhwJXku/cFwVNBaOnZcrx1TQiOJ0i04ftz+WXq+BIDGblYxQ==} + '@haverstack/commons@0.32.0': + resolution: {integrity: sha512-60J73vmQhu+PePMngDwxM6jjK0o3/Y4jWQhf/8c68hXAMfIy28LrrQqRB6r8bBpD/SX2VZODjh5DX2WqDrwlmA==} - '@haverstack/conformance-fixtures@0.31.0': - resolution: {integrity: sha512-2+0QR6L95iDl0BhGbs7q/BVz4CJ1zw0fbvUD0Fl6xAL90r5/M0ffRwY+rokTjgtxPK/ZsgVcr0nbDhlsD1Xokg==} + '@haverstack/conformance-fixtures@0.32.0': + resolution: {integrity: sha512-NpYsfzdt2/cecgtQOzPMuqNdsprD9baQpbvY1lwbygkZteQvPjpCqsjNYPtsTaAww1upvs3iDx9LVs5ItLE+hQ==} - '@haverstack/core@0.37.0': - resolution: {integrity: sha512-tEFL/mVpVeOsQt0BDdtEJu8PQgy5DVoScCqEKID+DrAkvcspjcXrhUsGsCe87tWHbrYUWAMH8zW0UCSNw/ET0Q==} + '@haverstack/core@0.38.0': + resolution: {integrity: sha512-9ltPYIU/gc0h+bZntc2IFZ4ke+yodbT6R2ct1hWMMnh6ZBZ7I+MeJDVPPZKxNi5UcEGi+NVdqSEYiIlwq2vBFA==} - '@haverstack/record-adapter-sqlite@0.29.0': - resolution: {integrity: sha512-r62aMZRZpW+B3bkQuZ0neJtNezHYgoBMR6s/kQnpoulJUMKViG6Lvs8bfW/MoVacZI1tWSDwtc8UsqAV6sblCA==} + '@haverstack/record-adapter-sqlite@0.30.0': + resolution: {integrity: sha512-ZoCeWrxWQau/xc2UXZxb4tUWBFLgu+IlRZEp687ZKI14+Jf+ruWZs38yyw8lG7AZzlsgUtKnxu7hx4FGT2K9Yw==} engines: {node: '>=22.5.0'} - '@haverstack/wire-types@0.36.0': - resolution: {integrity: sha512-alZbtPvyCtIrdJztDHlwUqhZu3Lns38EB6x31hBo8Ns/q2aQXTMcewlgLbLT0KdqJDZkpBJDnfTKTTHJ/aoENQ==} + '@haverstack/wire-types@0.37.0': + resolution: {integrity: sha512-5x9LC+hIMSO31BvOWc2cjcJ2t9sKRhqW9+IzAqjjLAxTtTOef3Z7w3RBJd6BPYn9SHVW22wJo1tjAhdrjSBU1A==} '@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.36.0': + '@haverstack/adapter-local@0.37.0': dependencies: - '@haverstack/blob-adapter-disk': 0.35.0 - '@haverstack/core': 0.37.0 - '@haverstack/record-adapter-sqlite': 0.29.0 + '@haverstack/blob-adapter-disk': 0.36.0 + '@haverstack/core': 0.38.0 + '@haverstack/record-adapter-sqlite': 0.30.0 - '@haverstack/blob-adapter-disk@0.35.0': + '@haverstack/blob-adapter-disk@0.36.0': dependencies: - '@haverstack/core': 0.37.0 + '@haverstack/core': 0.38.0 - '@haverstack/commons@0.31.0': + '@haverstack/commons@0.32.0': dependencies: - '@haverstack/core': 0.37.0 + '@haverstack/core': 0.38.0 - '@haverstack/conformance-fixtures@0.31.0': + '@haverstack/conformance-fixtures@0.32.0': dependencies: - '@haverstack/wire-types': 0.36.0 + '@haverstack/wire-types': 0.37.0 - '@haverstack/core@0.37.0': {} + '@haverstack/core@0.38.0': {} - '@haverstack/record-adapter-sqlite@0.29.0': + '@haverstack/record-adapter-sqlite@0.30.0': dependencies: - '@haverstack/core': 0.37.0 + '@haverstack/core': 0.38.0 - '@haverstack/wire-types@0.36.0': + '@haverstack/wire-types@0.37.0': dependencies: - '@haverstack/core': 0.37.0 + '@haverstack/core': 0.38.0 '@hono/node-server@2.1.1(hono@4.13.7)': dependencies: diff --git a/src/lib/json.ts b/src/lib/json.ts index 0ec77d9..2e73042 100644 --- a/src/lib/json.ts +++ b/src/lib/json.ts @@ -1,5 +1,5 @@ import type { Context } from 'hono'; -import { StackQueryError } from '@haverstack/core'; +import { StackBadRequestError } from '@haverstack/core'; import type { AppEnv } from '../types.js'; /** @@ -13,7 +13,7 @@ export async function readJson(c: Context): Promise { try { parsed = await c.req.json(); } catch (err) { - if (err instanceof SyntaxError) throw new StackQueryError('Invalid JSON in request body'); + if (err instanceof SyntaxError) throw new StackBadRequestError('Invalid JSON in request body'); throw err; } // `null`, a bare string or a number parses fine, but every call site @@ -21,7 +21,7 @@ export async function readJson(c: Context): Promise { // throws a bare TypeError — another unlabeled 500, unauthenticated on the // /auth routes. Structurally invalid like malformed JSON, so a 400 too. if (parsed === null || typeof parsed !== 'object') { - throw new StackQueryError('Request body must be a JSON object'); + throw new StackBadRequestError('Request body must be a JSON object'); } return parsed as T; } diff --git a/src/lib/queryWorker/pool.ts b/src/lib/queryWorker/pool.ts index 785f7bb..cbe4cff 100644 --- a/src/lib/queryWorker/pool.ts +++ b/src/lib/queryWorker/pool.ts @@ -40,7 +40,8 @@ * by-id reads stay on the main thread. */ import { Worker } from 'node:worker_threads'; -import type { StackQuery, QueryResult, TokenSession } from '@haverstack/core'; +import type { StackQuery, QueryResult } from '@haverstack/core'; +import type { TokenSession } from '@haverstack/core/wire'; import { StackTimeoutError } from '@haverstack/core'; import { deserializeError } from '@haverstack/wire-types'; import type { Logger } from 'pino'; diff --git a/src/lib/queryWorker/protocol.ts b/src/lib/queryWorker/protocol.ts index 570f1b8..be19c49 100644 --- a/src/lib/queryWorker/protocol.ts +++ b/src/lib/queryWorker/protocol.ts @@ -7,7 +7,8 @@ * (serializeError/deserializeError from @haverstack/wire-types) since a * custom Error subclass doesn't survive structured clone as itself. */ -import type { StackQuery, QueryResult, TokenSession } from '@haverstack/core'; +import type { StackQuery, QueryResult } from '@haverstack/core'; +import type { TokenSession } from '@haverstack/core/wire'; import type { WireError } from '@haverstack/wire-types'; /** diff --git a/src/lib/queryWorker/worker.ts b/src/lib/queryWorker/worker.ts index 18cb0ed..7e749c0 100644 --- a/src/lib/queryWorker/worker.ts +++ b/src/lib/queryWorker/worker.ts @@ -31,12 +31,12 @@ const init = workerData as QueryWorkerInit; // pool.ts). open() (not openOrInitialize()) reflects that: there is no // first-run path to handle here. const adapter = await LocalAdapter.open({ path: init.dbPath }); -const stack = await Stack.create(adapter); +const stack = await Stack.open(adapter); parentPort.on('message', async (req: QueryRequest) => { const port = parentPort!; try { - const scoped = req.session ? stack.forSession(req.session) : stack.asEntity(null); + const scoped = req.session ? stack.asActor(req.session) : stack.asEntity(null); const result = await scoped.query(req.query); port.postMessage({ id: req.id, ok: true, result } satisfies QueryResponse); } catch (err) { diff --git a/src/lib/resumeBuffer.ts b/src/lib/resumeBuffer.ts index 2a76be5..fce5a6f 100644 --- a/src/lib/resumeBuffer.ts +++ b/src/lib/resumeBuffer.ts @@ -23,7 +23,7 @@ export type ResumeEntry = { n: number; recordId: string; isPurge: boolean; - /** Wire-ready, seq already attached. */ + /** Wire-ready, cursor already attached. */ frame: WireRecordChange; }; @@ -61,8 +61,8 @@ export class ResumeBuffer { */ append(change: RecordChange): ResumeEntry { this.currentN += 1; - const seq = encodeCursor(this.id, this.currentN); - const frame = serializeChange({ ...change, seq }); + const cursor = encodeCursor(this.id, this.currentN); + const frame = serializeChange({ ...change, cursor }); const entry: ResumeEntry = { n: this.currentN, recordId: change.recordId, @@ -110,8 +110,9 @@ export type ResumeBufferKeyParts = { /** * Reproducible across a reconnect that re-sends the same query params — * order-independent on the parts of `filter` that don't carry order of - * their own (`typeId`/`kinds` are matched as sets, not sequences, so - * re-sending them in a different order must key the same buffer). + * their own (`typeId`, `baseId`, `createdBy`'s halves and `kinds` are + * matched as sets, not sequences, so re-sending them in a different order + * must key the same buffer). * * Two filters that mean different things must never key the same buffer. * A buffer opens exactly one `ScopedStack.subscribe()`, carrying the @@ -130,22 +131,32 @@ export type ResumeBufferKeyParts = { */ export function resumeBufferKey(parts: ResumeBufferKeyParts): string { const { filter } = parts; - const typeId = filter.typeId - ? [...(Array.isArray(filter.typeId) ? filter.typeId : [filter.typeId])].sort() - : undefined; - const kinds = filter.kinds ? [...filter.kinds].sort() : undefined; + const typeId = asSortedSet(filter.typeId); + const baseId = asSortedSet(filter.baseId); + const createdBy = filter.createdBy && { + subjectId: asSortedSet(filter.createdBy.subjectId), + principalId: asSortedSet(filter.createdBy.principalId), + }; + const kinds = asSortedSet(filter.kinds); return JSON.stringify({ principalId: parts.principalId, subjectId: parts.subjectId, includeRecords: parts.includeRecords, includeUnlisted: parts.includeUnlisted, ...(typeId !== undefined && { typeId }), + ...(baseId !== undefined && { baseId }), ...(filter.parentId !== undefined && { parentId: filter.parentId }), - ...(filter.entityId !== undefined && { entityId: filter.entityId }), + ...(createdBy !== undefined && { createdBy }), ...(kinds !== undefined && { kinds }), }); } +/** A one-or-many filter value, matched as a set: normalized to a sorted array. */ +function asSortedSet(value: T | T[] | undefined): T[] | undefined { + if (value === undefined) return undefined; + return [...(Array.isArray(value) ? value : [value])].sort(); +} + export type ResumeBufferRegistryOptions = { /** Max entries retained per buffer before the oldest is dropped. */ depth: number; diff --git a/src/lib/resumeCursor.ts b/src/lib/resumeCursor.ts index cdbfb0b..30bf534 100644 --- a/src/lib/resumeCursor.ts +++ b/src/lib/resumeCursor.ts @@ -1,6 +1,6 @@ /** * Resume cursor codec. A cursor is opaque and base64url by wire contract - * (`isValidSeq()` in @haverstack/wire-types), but this server's own are + * (`isValidCursor()` in @haverstack/wire-types), but this server's own are * self-describing: `base64url(bufferId + ":" + n)`. That makes a presented * cursor's origin checkable — a reconnect naming a buffer this server * doesn't hold (a different filter, a restart, a buffer past its retention @@ -30,10 +30,10 @@ export function encodeCursor(bufferId: string, n: number): string { * (A charset-*invalid* cursor is refused before this is ever called — see * `src/routes/changes.ts`.) */ -export function decodeCursor(seq: string): DecodedCursor | null { +export function decodeCursor(cursor: string): DecodedCursor | null { let text: string; try { - text = new TextDecoder('utf-8', { fatal: true }).decode(base64urlDecode(seq)); + text = new TextDecoder('utf-8', { fatal: true }).decode(base64urlDecode(cursor)); } catch { return null; } diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 93b1fc4..f4a019e 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,7 +1,7 @@ import { timingSafeEqual } from 'node:crypto'; import type { MiddlewareHandler } from 'hono'; import { StackPermissionError } from '@haverstack/core'; -import type { TokenSession } from '@haverstack/core'; +import type { TokenSession } from '@haverstack/core/wire'; import type { AppEnv } from '../types.js'; import type { StackContext } from '../stack.js'; import { wireError } from '../wireError.js'; @@ -57,7 +57,7 @@ export function requireAuth(): MiddlewareHandler { * authenticated as the owner rather than merely delegated for it. Being the * owner is never on its own sufficient under delegation — a delegated * session with the owner as principal still fails this, matching - * `ScopedStack`'s own owner-only gates (e.g. hard delete). See + * `ScopedStack`'s own owner-only gates (e.g. purge). See * docs/spec/access-control.md § Delegation. */ export function isOwnerActingAlone(auth: TokenSession | null, ownerEntityId: string): boolean { diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts index d36ec5d..28c71a6 100644 --- a/src/routes/attachments.ts +++ b/src/routes/attachments.ts @@ -42,7 +42,7 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): const appId = c.req.query('appId') || undefined; const data = new Uint8Array(await c.req.arrayBuffer()); - const record = await stack.forSession(auth).putAttachment(data, mimeType, filename, appId); + const record = await stack.asActor(auth).putAttachment(data, { mimeType, filename, appId }); return c.json(serializeRecord(record), 200); }); @@ -53,7 +53,7 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): let data: Uint8Array; try { - data = await (auth ? stack.forSession(auth) : stack.asEntity(null)).getAttachment(fileId); + data = await (auth ? stack.asActor(auth) : stack.asEntity(null)).getAttachment(fileId); } catch (e) { // Anonymous and denied is a transport-auth failure, distinct from an // authenticated requester lacking access; everything else belongs to @@ -119,7 +119,7 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): const auth = c.get('auth')!; const raw = await c.req.text(); const body = raw ? (JSON.parse(raw) as { graceMs?: number; dryRun?: boolean }) : {}; - const result = await stack.forSession(auth).collectAttachmentGarbage({ + const result = await stack.asActor(auth).collectAttachmentGarbage({ ...(typeof body.graceMs === 'number' && { graceMs: body.graceMs }), ...(body.dryRun === true && { dryRun: true }), }); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 6da3a6d..c0137dd 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -105,10 +105,13 @@ export function authRoutes(ctx: StackContext, authOrigin: string, logger: Logger // Undelegated by construction: principalId and subjectId are both the // proven DID. Never let a client name its own subject. const expiresAt = new Date(Date.now() + AUTH_TOKEN_TTL_MS); - const { token } = await ctx.tokens.createToken(body.did, { - expiresAt, - label: AUTH_TOKEN_LABEL, - }); + const { token } = await ctx.tokens.createToken( + { subjectId: body.did, principalId: body.did }, + { + expiresAt, + label: AUTH_TOKEN_LABEL, + }, + ); // Reported from the value just written rather than read back through // listTokens(), an unpaginated scan of every token ever issued — on an diff --git a/src/routes/changes.ts b/src/routes/changes.ts index 94d075d..d54587c 100644 --- a/src/routes/changes.ts +++ b/src/routes/changes.ts @@ -4,10 +4,11 @@ import { streamSSE } from 'hono/streaming'; import type { AppEnv } from '../types.js'; import type { StackContext } from '../stack.js'; import type { Config } from '../config.js'; -import type { ScopedStack, TokenSession, RecordChange } from '@haverstack/core'; -import { StackQueryError, StackPermissionError } from '@haverstack/core'; +import type { ScopedStack, RecordChange } from '@haverstack/core'; +import type { TokenSession } from '@haverstack/core/wire'; +import { StackBadRequestError, StackPermissionError } from '@haverstack/core'; import { parseChangeParams } from '@haverstack/core/wire'; -import { serializeChange, isValidSeq } from '@haverstack/wire-types'; +import { serializeChange, isValidCursor } from '@haverstack/wire-types'; import type { ChangeResetReason } from '@haverstack/wire-types'; import type { Logger } from 'pino'; import { safeCompare, isOwnerActingAlone } from '../middleware/auth.js'; @@ -40,7 +41,7 @@ export type ChangeRouteOptions = { * Whether a presented cursor is honored at all. Default true and never * false in production, since discovery advertises resume unconditionally. * It exists because `resume: false` is real, spec-defined behavior - * (`ready` with no `seq`, then `reset` with reason `not_supported`) that + * (`ready` with no `cursor`, then `reset` with reason `not_supported`) that * a conformance fixture needs a way to reach. */ resume?: boolean; @@ -52,7 +53,7 @@ export type ChangeRouteOptions = { * Raw cursor text presented on this connection, if any — `Last-Event-ID` * takes priority over `?since=` (a browser EventSource-style reconnect * sends the header; `?since=` exists for a client whose transport can't - * set one). Not yet validated or decoded — see isValidSeq()/decodeCursor(). + * set one). Not yet validated or decoded — see isValidCursor()/decodeCursor(). */ function presentedCursorRaw(c: Context, url: URL): string | undefined { return c.req.header('Last-Event-ID') ?? url.searchParams.get('since') ?? undefined; @@ -76,7 +77,7 @@ export function changeRoutes( /** Scope to a session if authenticated, else the anonymous (public-only) view. */ function scopeFor(auth: TokenSession | null): ScopedStack { - return auth ? ctx.stack.forSession(auth) : ctx.stack.asEntity(null); + return auth ? ctx.stack.asActor(auth) : ctx.stack.asEntity(null); } // Single-process only: events exist only in the process owning the @@ -100,10 +101,10 @@ export function changeRoutes( // A charset-invalid cursor is refused rather than treated as a cache // miss: no conformant server could have minted it, so there is nothing - // to reconcile. isValidSeq() is the rule minted cursors meet on the way + // to reconcile. isValidCursor() is the rule minted cursors meet on the way // out, applied here on the way in. - if (resumeEnabled && presentedRaw !== undefined && !isValidSeq(presentedRaw)) { - throw new StackQueryError(`Invalid cursor: "${presentedRaw}"`); + if (resumeEnabled && presentedRaw !== undefined && !isValidCursor(presentedRaw)) { + throw new StackBadRequestError(`Invalid cursor: "${presentedRaw}"`); } // Never accepted: a bearer token is read only from the Authorization @@ -174,7 +175,7 @@ export function changeRoutes( const queued: ResumeEntry[] = []; const detachLive = buffer.subscribeLive((entry) => { if (replaying) queued.push(entry); - else send('record', entry.frame, entry.frame.seq); + else send('record', entry.frame, entry.frame.cursor); }); // Registered before the first `await` below, so a subscription // opened above is always released — including when that await @@ -200,7 +201,7 @@ export function changeRoutes( } } - send('ready', { seq: buffer.headCursor() }); + send('ready', { cursor: buffer.headCursor() }); if (resetReason) { send('reset', { reason: resetReason }); @@ -216,13 +217,13 @@ export function changeRoutes( } // Once the gate trips this connection is closing, so the // remaining entries are permission checks nobody will read. - if (!send('record', entry.frame, entry.frame.seq)) break; + if (!send('record', entry.frame, entry.frame.cursor)) break; } } replaying = false; for (const entry of queued) { - if (!send('record', entry.frame, entry.frame.seq)) break; + if (!send('record', entry.frame, entry.frame.cursor)) break; } } diff --git a/src/routes/entity.ts b/src/routes/entity.ts index 2444224..7949d55 100644 --- a/src/routes/entity.ts +++ b/src/routes/entity.ts @@ -13,18 +13,18 @@ export function entityRoutes(ctx: StackContext): Hono { app.get('/', requireAuth(), async (c) => { const auth = c.get('auth')!; - const record = await stack.forSession(auth).getOwnerEntity(); + const record = await stack.asActor(auth).getOwnerEntity(); if (!record) throw new StackNotFoundError('Entity record not found'); return c.json(serializeRecord(record)); }); app.patch('/', requireOwner(ownerEntityId), async (c) => { const auth = c.get('auth')!; - const record = await stack.forSession(auth).getOwnerEntity(); + const record = await stack.asActor(auth).getOwnerEntity(); if (!record) throw new StackNotFoundError('Entity record not found'); const body = await readJson>(c); const updated = await stack - .forSession(auth) + .asActor(auth) .patchContent(record.id, (body.content ?? {}) as Record); return c.json(serializeRecord(updated)); }); diff --git a/src/routes/records.ts b/src/routes/records.ts index 4894921..d3be309 100644 --- a/src/routes/records.ts +++ b/src/routes/records.ts @@ -1,7 +1,8 @@ import { Hono } from 'hono'; import type { AppEnv } from '../types.js'; import type { StackContext } from '../stack.js'; -import type { ScopedStack, TokenSession } from '@haverstack/core'; +import type { ScopedStack } from '@haverstack/core'; +import type { TokenSession } from '@haverstack/core/wire'; import { requireAuth, requireOwner } from '../middleware/auth.js'; import { readJson } from '../lib/json.js'; import { @@ -16,7 +17,7 @@ import { 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 { StackValidationError, StackBadRequestError, StackNotFoundError } from '@haverstack/core'; import type { AuthorityAssociation, DataAssociation, TypeId } from '@haverstack/core'; // --------------------------------------------------------------------------- @@ -36,7 +37,7 @@ 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); + const purge = new URL(c.req.url).searchParams.get('purge') === 'true'; + const session = stack.asActor(auth); const { record } = await session.deleteAndReturn(id, { - hard, + purge, ifVersion: parseIfMatch(c.req.header('If-Match')), }); if (!record) throw new StackNotFoundError('Record not found'); @@ -148,7 +149,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono(c); - const updated = await stack.forSession(auth).grantAccess(id, body); + const updated = await stack.asActor(auth).grantAccess(id, body); return c.json(serializeRecord(updated)); }); @@ -186,7 +187,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono(c); - const updated = await stack.forSession(auth).revokeAccess(id, body); + const updated = await stack.asActor(auth).revokeAccess(id, body); return c.json(serializeRecord(updated)); }); @@ -216,8 +217,8 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono(c); - if (!body.kind || !body.label) throw new StackQueryError('kind and label are required'); - const updated = await stack.forSession(auth).associate(id, body); + if (!body.kind || !body.label) throw new StackBadRequestError('kind and label are required'); + const updated = await stack.asActor(auth).associate(id, body); return c.json(serializeRecord(updated)); }); @@ -228,7 +229,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono(c); - const updated = await stack.forSession(auth).dissociate(id, body); + const updated = await stack.asActor(auth).dissociate(id, body); return c.json(serializeRecord(updated)); }); @@ -288,7 +289,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono>(c); if (!body.toTypeId || typeof body.toTypeId !== 'string') - throw new StackQueryError('toTypeId is required'); + throw new StackBadRequestError('toTypeId is required'); if (!body.content || typeof body.content !== 'object') - throw new StackQueryError('content is required'); + throw new StackBadRequestError('content is required'); if (!(await stack.getType(body.toTypeId as TypeId))) throw new StackValidationError([ { path: 'toTypeId', message: `Unknown type: "${body.toTypeId}"` }, ]); const migrated = await stack - .forSession(auth) + .asActor(auth) .commitMigration(id, body.toTypeId as TypeId, body.content as Record, { ifVersion: parseIfMatch(c.req.header('If-Match')), }); diff --git a/src/routes/tokens.ts b/src/routes/tokens.ts index 3baad23..a3ff200 100644 --- a/src/routes/tokens.ts +++ b/src/routes/tokens.ts @@ -13,32 +13,36 @@ export function tokenRoutes(ctx: StackContext): Hono { const { tokens, stack } = ctx; const ownerEntityId = stack.ownerEntityId; - // POST /tokens — issue a new token (owner only). `onBehalfOf` asserts a - // delegation out of band: the owner names the subject the issued - // principal acts for, per docs/spec/wire-format.md § The session a - // token names. + // POST /tokens — issue a new token (owner only). The body names an Actor: + // `principalId` is the identity the token authenticates as (default: the + // owner), and `subjectId` asserts a delegation out of band — the subject + // that principal acts for (default: the principal itself), per + // docs/spec/wire-format.md § The session a token names. app.post('/', requireOwner(ownerEntityId), async (c) => { const body = await readJson<{ - entityId?: string; - onBehalfOf?: string; + principalId?: string; + subjectId?: string; label?: string; expiresAt?: string; }>(c); - if (body.entityId !== undefined && !isValidDid(body.entityId)) - throw new StackValidationError([{ path: 'entityId', message: 'Must be a DID' }]); - if (body.onBehalfOf !== undefined && !isValidDid(body.onBehalfOf)) - throw new StackValidationError([{ path: 'onBehalfOf', message: 'Must be a DID' }]); + if (body.principalId !== undefined && !isValidDid(body.principalId)) + throw new StackValidationError([{ path: 'principalId', message: 'Must be a DID' }]); + if (body.subjectId !== undefined && !isValidDid(body.subjectId)) + throw new StackValidationError([{ path: 'subjectId', message: 'Must be a DID' }]); - const principalId = body.entityId ?? ownerEntityId; + const principalId = body.principalId ?? ownerEntityId; + const subjectId = body.subjectId ?? principalId; const expiresAt = body.expiresAt ? parseDate(body.expiresAt) : undefined; if (body.expiresAt && !expiresAt) throw new StackValidationError([{ path: 'expiresAt', message: 'Invalid date' }]); - const { id, token } = await tokens.createToken(principalId, { - onBehalfOf: body.onBehalfOf, - label: body.label, - expiresAt, - }); + const { id, token } = await tokens.createToken( + { subjectId, principalId }, + { + label: body.label, + expiresAt, + }, + ); // Read the row back rather than fabricating createdAt here — the store // is the source of truth, and GET /tokens must report the same value. @@ -49,7 +53,7 @@ export function tokenRoutes(ctx: StackContext): Hono { id, token, principalId, - subjectId: body.onBehalfOf ?? principalId, + subjectId, label: stored.label ?? null, createdAt: stored.createdAt.toISOString(), expiresAt: stored.expiresAt?.toISOString() ?? null, diff --git a/src/routes/types.ts b/src/routes/types.ts index c9e7631..eded0e5 100644 --- a/src/routes/types.ts +++ b/src/routes/types.ts @@ -6,7 +6,7 @@ import { readJson } from '../lib/json.js'; import { serializeType } from '@haverstack/wire-types'; import { hashSchema, - StackQueryError, + StackBadRequestError, StackNotFoundError, StackValidationError, } from '@haverstack/core'; @@ -30,12 +30,13 @@ export function typeRoutes(ctx: StackContext): Hono { app.post('/', requireOwner(stack.ownerEntityId), async (c) => { const body = await readJson>(c); - if (!body.id || typeof body.id !== 'string') throw new StackQueryError('id is required'); - if (!body.name || typeof body.name !== 'string') throw new StackQueryError('name is required'); + if (!body.id || typeof body.id !== 'string') throw new StackBadRequestError('id is required'); + if (!body.name || typeof body.name !== 'string') + throw new StackBadRequestError('name is required'); if (!body.schema || typeof body.schema !== 'object') - throw new StackQueryError('schema is required'); + throw new StackBadRequestError('schema is required'); if (!body.schemaHash || typeof body.schemaHash !== 'string') - throw new StackQueryError('schemaHash is required'); + throw new StackBadRequestError('schemaHash is required'); const computedHash = await hashSchema(body.schema as TypeSchema); if (body.schemaHash !== computedHash) @@ -47,7 +48,10 @@ export function typeRoutes(ctx: StackContext): Hono { // schema-drift check: redefining an existing typeId with anything // beyond additive evolution throws StackSchemaDriftError, which // adapter.saveType() alone has no way to enforce — it's a raw write. - const type = await stack.defineType(body.id, body.name, body.schema as TypeSchema, { + const type = await stack.defineType({ + id: body.id, + name: body.name, + schema: body.schema as TypeSchema, ...(body.migratesFrom ? { migratesFrom: body.migratesFrom as string } : {}), }); return c.json(serializeType(type), 201); diff --git a/src/routes/wellknown.ts b/src/routes/wellknown.ts index d94f0b8..a1da2a5 100644 --- a/src/routes/wellknown.ts +++ b/src/routes/wellknown.ts @@ -22,7 +22,7 @@ export function wellknownRoutes(ctx: StackContext, config: Config): Hono // imposes a limit, but this server enforces both. See // docs/spec/wire-format.md § Discovery. capabilities: { - ...ctx.stack.features, + ...ctx.stack.capabilities, limits: { attachmentBytes: config.maxAttachmentBytes, contentBytes: config.maxContentBytes, @@ -34,7 +34,7 @@ export function wellknownRoutes(ctx: StackContext, config: Config): Hono // rather than discovering it as a 404 partway through one. auth: { methods: [AUTH_METHOD_DID_CHALLENGE] }, // Top-level rather than inside `capabilities`, which carries only - // what the `...ctx.stack.features` spread brings. An object rather + // what the `...ctx.stack.capabilities` spread brings. An object rather // than a boolean for the same reason `auth` is: the surface grows // entries — another transport, batched frames — not more booleans. // diff --git a/src/stack.ts b/src/stack.ts index 87ea1c6..ba16175 100644 --- a/src/stack.ts +++ b/src/stack.ts @@ -39,13 +39,13 @@ export type StackContext = { export async function initStack(config: Config, logger: Logger): Promise { // openOrInitialize() decides between open and create without a TOCTOU - // gap. entityId goes in as a lazy provider so openOrInitialize()'s own + // gap. ownerEntityId goes in as a lazy provider so openOrInitialize()'s own // owner-mismatch check, which throws, never runs on the open path — // ENTITY_ID divergence is a warning below, not a failure — and so a // missing ENTITY_ID is an error only when creating a database. const adapter = await LocalAdapter.openOrInitialize({ path: config.dbPath, - entityId: () => { + ownerEntityId: () => { if (!config.entityId) { throw new Error( 'ENTITY_ID is required when initializing a new database (DB_PATH does not exist yet)', @@ -63,7 +63,7 @@ export async function initStack(config: Config, logger: Logger): Promise { const adapter = await LocalAdapter.initialize({ path: dbPath, - entityId: TEST_ENTITY_ID, + ownerEntityId: TEST_ENTITY_ID, ...(opts.timezone !== undefined && { timezone: opts.timezone }), }); - const stack = await Stack.create(adapter); + const stack = await Stack.open(adapter); const tokens = await NativeTokenStore.open({ path: defaultTokenStorePath(dbPath) }); const nonces = AuthNonceStore.open(defaultNonceStorePath(dbPath)); const queryWorker = new QueryWorkerPool({ diff --git a/src/types.ts b/src/types.ts index 14b51ac..4ec19d0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import type { TokenSession } from '@haverstack/core'; +import type { TokenSession } from '@haverstack/core/wire'; /** Hono context variable map shared across all route files. */ export type AppEnv = { diff --git a/tests/app.test.ts b/tests/app.test.ts index c9a93b3..c3c2f0e 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -8,8 +8,12 @@ describe('request body size limit', () => { let t: TestApp; beforeEach(async () => { t = await buildTestApp(); - await t.ctx.stack.defineType(NOTE_TYPE_ID, 'Note', { - body: { kind: 'text' as const, required: false as const }, + await t.ctx.stack.defineType({ + id: NOTE_TYPE_ID, + name: 'Note', + schema: { + body: { kind: 'text' as const, required: false as const }, + }, }); }); afterEach(async () => { diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index 8cefd80..8a0680d 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -82,7 +82,7 @@ import type { AppEnv } from '../src/types.js'; /** * Fixture ids embed a fixed, long-past timestamp (they were authored once - * and never touched again); this server's ScopedStack.create() rejects any + * and never touched again); this server's ScopedStack.open() rejects any * client-supplied id whose embedded timestamp is outside a clock-skew * tolerance of "now" (default 24h — see validateIdTimestampSkew() in * @haverstack/core). A literal fixture id would fail that freshness check @@ -123,17 +123,29 @@ const BLOG_APP_ID = 'com.example.blog'; const BLOG_SUBJECT_ID = 'entity-blog-subject-131415'; async function seedTypes(ctx: TestApp['ctx']) { - await ctx.stack.defineType(NOTE_TYPE, 'Note', { - title: { kind: 'string' }, - body: { kind: 'text' }, - pinned: { kind: 'boolean' }, + await ctx.stack.defineType({ + id: NOTE_TYPE, + name: 'Note', + schema: { + title: { kind: 'string' }, + body: { kind: 'text' }, + pinned: { kind: 'boolean' }, + }, }); - await ctx.stack.defineType(NOTE_TYPE_V2, 'Note', { - title: { kind: 'string' }, - pinned: { kind: 'boolean' }, + await ctx.stack.defineType({ + id: NOTE_TYPE_V2, + name: 'Note', + schema: { + title: { kind: 'string' }, + pinned: { kind: 'boolean' }, + }, }); - await ctx.stack.defineType(COMMENT_TYPE, 'Comment', { - body: { kind: 'text', required: true }, + await ctx.stack.defineType({ + id: COMMENT_TYPE, + name: 'Comment', + schema: { + body: { kind: 'text', required: true }, + }, }); } @@ -268,10 +280,9 @@ describe('createRecord fixtures', () => { expect(d.typeId).toBe(body.typeId); expect(d.content).toEqual(body.content); expect(d.version).toBe(1); - // The owner token acts as the owner entity itself, so entityId is - // stamped to it — undelegated, so principalId stays absent. - expect(d.entityId).toBe(TEST_ENTITY_ID); - expect(d.principalId).toBeUndefined(); + // The owner token acts as the owner entity itself, so createdBy names + // it — undelegated, so principalId stays absent. + expect(d.createdBy).toEqual({ subjectId: TEST_ENTITY_ID }); }); test('create-record-ignores-client-supplied-entity-and-principal', async () => { @@ -280,17 +291,17 @@ describe('createRecord fixtures', () => { )!; handled.add(fixture.name); const body = withFreshId(fixture.requestBody as WireRecord & { appId?: string }); - await t.ctx.stack.grant({ kind: 'entity', entityId: CONTRIBUTOR_ID }, [ - { actions: ['create'], typeId: body.typeId }, - ]); - const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + await t.ctx.stack.grantType(body.typeId, { + actions: ['create'], + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, + }); + const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); const { status, data } = await req(t.app, 'POST', fixture.path, { token, body }); expect(status).toBe(fixture.responseStatus); const d = data as Record; expect(d.content).toEqual(body.content); // entityId/principalId are stamped from the session, never the body. - expect(d.entityId).toBe(CONTRIBUTOR_ID); - expect(d.principalId).toBeUndefined(); + expect(d.createdBy).toEqual({ subjectId: CONTRIBUTOR_ID }); // appId is the deliberate exception: self-reported, honored verbatim. expect(d.appId).toBe(body.appId); }); @@ -301,19 +312,23 @@ describe('createRecord fixtures', () => { )!; handled.add(fixture.name); const body = withFreshId(fixture.requestBody as WireRecord & { appId?: string }); - 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 }); + await t.ctx.stack.grantType(body.typeId, { + actions: ['create'], + grantee: { kind: 'entity', entityId: BLOG_SUBJECT_ID }, + }); + await t.ctx.stack.grantType(body.typeId, { + actions: ['create'], + grantee: { kind: 'entity', entityId: BLOG_APP_ID }, + }); + const { token } = await t.ctx.adapter.createToken({ + subjectId: BLOG_SUBJECT_ID, + principalId: BLOG_APP_ID, + }); const { status, data } = await req(t.app, 'POST', fixture.path, { token, body }); expect(status).toBe(fixture.responseStatus); const d = data as Record; expect(d.content).toEqual(body.content); - expect(d.entityId).toBe(BLOG_SUBJECT_ID); - expect(d.principalId).toBe(BLOG_APP_ID); + expect(d.createdBy).toEqual({ subjectId: BLOG_SUBJECT_ID, principalId: BLOG_APP_ID }); expect(d.appId).toBe(body.appId); }); @@ -349,20 +364,23 @@ describe('createRecord fixtures', () => { // the contributor may read, carrying an attachment association for the // same file. The bytes stay the owner's — the contributor never uploads // and never proves possession, which is the whole point of the carve-out. - const uploaded = await t.ctx.stack.putAttachment( - new Uint8Array([1, 2, 3]), - body.content.mimeType, - 'owner.png', - ); + const uploaded = await t.ctx.stack.putAttachment(new Uint8Array([1, 2, 3]), { + mimeType: body.content.mimeType, + filename: 'owner.png', + }); 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({ kind: 'entity', entityId: CONTRIBUTOR_ID }, [ - { actions: ['read-any'], typeId: NOTE_TYPE }, - { actions: ['create'], typeId: ATTACHMENT_TYPE }, - ]); + await t.ctx.stack.grantType(NOTE_TYPE, { + actions: ['read-any'], + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, + }); + await t.ctx.stack.grantType(ATTACHMENT_TYPE, { + actions: ['create'], + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, + }); const content = { ...body.content, fileId }; - const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); const { status, data } = await req(t.app, 'POST', fixture.path, { token, body: { ...body, content }, @@ -372,7 +390,7 @@ describe('createRecord fixtures', () => { // Their own record — own id, own filename — not a dedup of the owner's. expect(d.id).toBe(body.id); expect(d.content).toEqual(content); - expect(d.entityId).toBe(CONTRIBUTOR_ID); + expect((d.createdBy as { subjectId: string }).subjectId).toBe(CONTRIBUTOR_ID); }); test('create-record-unlisted — unlistedAt in the create body suppresses enumeration from create time', async () => { @@ -553,6 +571,59 @@ describe('queryRecords fixtures', () => { expect(page.cursor).toBeNull(); }); + // Real uploads rather than the fixture's literal hash, so the filter has + // something to match — and something it must not. + async function seedCoverAndThumbnail() { + const [a, b] = await Promise.all([ + t.ctx.stack.putAttachment(new Uint8Array([1]), { mimeType: 'image/png' }), + t.ctx.stack.putAttachment(new Uint8Array([2]), { mimeType: 'image/png' }), + ]); + const coverFileId = (a.content as { fileId: string }).fileId; + const thumbFileId = (b.content as { fileId: string }).fileId; + const note = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'has a cover' }, + { + associations: [ + { kind: 'attachment', label: 'cover', fileId: coverFileId, attachmentRecordId: a.id }, + { kind: 'attachment', label: 'thumbnail', fileId: thumbFileId, attachmentRecordId: b.id }, + ], + }, + ); + return { note, coverFileId, thumbFileId }; + } + + test('query-attachment-label-and-file — both halves match one association', async () => { + const fixture = queryRecordsFixtures.find((f) => f.name === 'query-attachment-label-and-file')!; + const empty = await req(t.app, fixture.method, fixture.path, { token: TEST_TOKEN }); + expect(empty.status).toBe(fixture.responseStatus); + expect(empty.data).toEqual(fixture.responseBody); + + const { note, coverFileId, thumbFileId } = await seedCoverAndThumbnail(); + const ids = async (qs: string) => { + const { data } = await req(t.app, 'GET', `/records?${qs}`, { token: TEST_TOKEN }); + return (data as { records: Array<{ id: string }> }).records.map((r) => r.id); + }; + expect(await ids(`attachmentLabel=cover&attachmentFileId=${coverFileId}`)).toEqual([note.id]); + expect(await ids(`attachmentLabel=cover&attachmentFileId=${thumbFileId}`)).toEqual([]); + expect(await ids(`attachmentLabel=thumbnail`)).toEqual([note.id]); + }); + + test('query-references-file — any attachment label matches', async () => { + const fixture = queryRecordsFixtures.find((f) => f.name === 'query-references-file')!; + const empty = await req(t.app, fixture.method, fixture.path, { token: TEST_TOKEN }); + expect(empty.status).toBe(fixture.responseStatus); + expect(empty.data).toEqual(fixture.responseBody); + + const { note, thumbFileId } = await seedCoverAndThumbnail(); + const { data } = await req(t.app, 'GET', `/records?referencesFileId=${thumbFileId}`, { + token: TEST_TOKEN, + }); + expect((data as { records: Array<{ id: string }> }).records.map((r) => r.id)).toEqual([ + note.id, + ]); + }); + test('coverage', () => { // query-empty-page-with-live-cursor and query-get-records-uses-the-same- // envelope pin narrower edge cases of the same two invariants (a @@ -572,6 +643,8 @@ describe('queryRecords fixtures', () => { 'query-sorts-by-a-content-field', 'query-content-sort-folds-case-and-accents', 'query-get-sorts-by-a-content-field', + 'query-attachment-label-and-file', + 'query-references-file', ]), new Set(), ); @@ -630,22 +703,22 @@ describe('patchContent fixtures', () => { NOTE_TYPE, { title: 'original' }, { - entityId: TEST_ENTITY_ID, + createdBy: { subjectId: TEST_ENTITY_ID }, permissions: [ { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }, { kind: 'permission', label: 'write', - grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token, body: fixture.requestBody, @@ -655,8 +728,8 @@ describe('patchContent fixtures', () => { expect(d.content).toEqual(fixture.responseBody!.content); // Authorship (entityId) is untouched by a non-author write; updatedBy // moves to the requester who made this edit. - expect(d.entityId).toBe(TEST_ENTITY_ID); - expect(d.updatedBy).toBe(CONTRIBUTOR_ID); + expect((d.createdBy as { subjectId: string }).subjectId).toBe(TEST_ENTITY_ID); + expect((d.updatedBy as { subjectId: string }).subjectId).toBe(CONTRIBUTOR_ID); }); test('coverage', () => { @@ -686,11 +759,11 @@ describe('deleteRecord fixtures', () => { expect(after.status).toBe(200); }); - test('delete-record-hard', async () => { - const fixture = deleteRecordFixtures.find((f) => f.name === 'delete-record-hard')!; + test('delete-record-purge', async () => { + const fixture = deleteRecordFixtures.find((f) => f.name === 'delete-record-purge')!; handled.add(fixture.name); const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); - const { status } = await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, { + const { status } = await req(t.app, 'DELETE', `/records/${record.id}?purge=true`, { token: TEST_TOKEN, }); expect(status).toBe(fixture.responseStatus); @@ -704,14 +777,20 @@ describe('deleteRecord fixtures', () => { // Both fileIds are real uploads rather than the fixture's literal ones — // an attachment association names a stored file, so the shape of the // claim travels and the hashes cannot. - test('hard-delete-under-concurrent-write', async () => { + test('purge-under-concurrent-write', async () => { const sequence = deleteRecordSequenceFixtures.find( - (f) => f.name === 'hard-delete-under-concurrent-write', + (f) => f.name === 'purge-under-concurrent-write', )!; handled.add(sequence.name); const [cover, late] = await Promise.all([ - t.ctx.stack.putAttachment(new Uint8Array([1, 2, 3]), 'image/png', 'cover.png'), - t.ctx.stack.putAttachment(new Uint8Array([4, 5, 6]), 'image/png', 'late.png'), + t.ctx.stack.putAttachment(new Uint8Array([1, 2, 3]), { + mimeType: 'image/png', + filename: 'cover.png', + }), + t.ctx.stack.putAttachment(new Uint8Array([4, 5, 6]), { + mimeType: 'image/png', + filename: 'late.png', + }), ]); const coverFileId = (cover.content as { fileId: string }).fileId; const lateFileId = (late.content as { fileId: string }).fileId; @@ -732,7 +811,7 @@ describe('deleteRecord fixtures', () => { }); expect(added.status).toBe(addStep!.responseStatus); - const purged = await req(t.app, purgeStep!.method, `/records/${record.id}?hard=true`, { + const purged = await req(t.app, purgeStep!.method, `/records/${record.id}?purge=true`, { token: TEST_TOKEN, }); expect(purged.status).toBe(purgeStep!.responseStatus); @@ -833,11 +912,10 @@ describe('associate fixtures', () => { fileId: string; attachmentRecordId: string; }; - const uploaded = await t.ctx.stack.putAttachment( - new Uint8Array([1, 2, 3]), - 'image/png', - 'embed.png', - ); + const uploaded = await t.ctx.stack.putAttachment(new Uint8Array([1, 2, 3]), { + mimeType: 'image/png', + filename: 'embed.png', + }); const fileId = (uploaded.content as { fileId: string }).fileId; const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); const { status } = await req(t.app, 'POST', `/records/${record.id}/associations`, { @@ -890,11 +968,10 @@ describe('dissociate fixtures', () => { const fixture = dissociateFixtures.find((f) => f.name === 'dissociate-attachment-by-identity')!; handled.add(fixture.name); const body = fixture.requestBody as DataAssociation & { kind: 'attachment'; fileId: string }; - const uploaded = await t.ctx.stack.putAttachment( - new Uint8Array([1, 2, 3]), - 'image/png', - 'embed.png', - ); + const uploaded = await t.ctx.stack.putAttachment(new Uint8Array([1, 2, 3]), { + mimeType: 'image/png', + filename: 'embed.png', + }); const fileId = (uploaded.content as { fileId: string }).fileId; // The stored association carries an attachmentRecordId, but identity // stays (kind, label, fileId) — dissociating without the pointer still @@ -1000,7 +1077,7 @@ describe('grantAccess / revokeAccess fixtures', () => { const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }); const element = { ...fixture.requestBody, - grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }; const { status, data } = await req(t.app, 'POST', `/records/${record.id}/permissions`, { @@ -1013,7 +1090,7 @@ describe('grantAccess / revokeAccess fixtures', () => { // 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 { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); const read = await req(t.app, 'GET', `/records/${record.id}`, { token }); expect(read.status).toBe(200); }); @@ -1025,7 +1102,7 @@ describe('grantAccess / revokeAccess fixtures', () => { const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }); const element = { ...fixture.requestBody, - grantee: { scope: 'group', groupId: group.id, role: 'admin' }, + grantee: { kind: 'group', groupId: group.id, role: 'admin' }, }; const { status, data } = await req(t.app, 'POST', `/records/${record.id}/permissions`, { @@ -1045,7 +1122,7 @@ describe('grantAccess / revokeAccess fixtures', () => { body: { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }, }); const { data } = await req(t.app, 'POST', `/records/${record.id}/permissions`, { @@ -1085,17 +1162,17 @@ describe('grantAccess / revokeAccess fixtures', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: WRITER_ID }, + grantee: { kind: 'entity', entityId: WRITER_ID }, }, { kind: 'permission', label: 'write', - grantee: { scope: 'entity', entityId: WRITER_ID }, + grantee: { kind: 'entity', entityId: WRITER_ID }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(WRITER_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: WRITER_ID }); const { status } = await req(t.app, 'POST', `/records/${record.id}/permissions`, { token, body: { kind: 'anyone', label: 'read' }, @@ -1158,20 +1235,20 @@ describe('journal fixtures', () => { body: { kind: 'tag', label: 'starred' }, }); - const page = await req(t.app, 'GET', `/records/${record.id}/journal?sinceSeq=0&limit=1`, { + const page = await req(t.app, 'GET', `/records/${record.id}/journal?afterSeq=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. + // back as afterSeq. expect(first.cursor).toBe(1); const resumed = await req( t.app, 'GET', - `/records/${record.id}/journal?sinceSeq=${first.cursor}`, + `/records/${record.id}/journal?afterSeq=${first.cursor}`, { token: TEST_TOKEN }, ); expect((resumed.data as WireJournalResponse).entries.map((e) => e.seq)).toEqual([2]); @@ -1182,11 +1259,10 @@ describe('journal fixtures', () => { (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 first = await t.ctx.stack.putAttachment(new Uint8Array([1, 2, 3]), { + mimeType: 'image/png', + filename: '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. @@ -1208,7 +1284,7 @@ describe('journal fixtures', () => { body: { ...association, attachmentRecordId: second.id }, }); - const { status, data } = await req(t.app, 'GET', `/records/${record.id}/journal?sinceSeq=2`, { + const { status, data } = await req(t.app, 'GET', `/records/${record.id}/journal?afterSeq=2`, { token: TEST_TOKEN, }); expect(status).toBe(fixture.responseStatus); @@ -1230,11 +1306,10 @@ describe('journal fixtures', () => { (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 uploaded = await t.ctx.stack.putAttachment(new Uint8Array([1, 2, 3]), { + mimeType: 'image/png', + filename: 'embed.png', + }); const association = { kind: 'attachment', label: 'embed', @@ -1250,7 +1325,7 @@ describe('journal fixtures', () => { body: association, }); - const { status, data } = await req(t.app, 'GET', `/records/${record.id}/journal?sinceSeq=2`, { + const { status, data } = await req(t.app, 'GET', `/records/${record.id}/journal?afterSeq=2`, { token: TEST_TOKEN, }); expect(status).toBe(fixture.responseStatus); @@ -1275,7 +1350,7 @@ describe('journal fixtures', () => { body: { parentId: container.id }, }); - const { status, data } = await req(t.app, 'GET', `/records/${record.id}/journal?sinceSeq=1`, { + const { status, data } = await req(t.app, 'GET', `/records/${record.id}/journal?afterSeq=1`, { token: TEST_TOKEN, }); expect(status).toBe(fixture.responseStatus); @@ -1298,12 +1373,12 @@ describe('journal fixtures', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: WRITER_ID }, + grantee: { kind: 'entity', entityId: WRITER_ID }, }, { kind: 'permission', label: 'write', - grantee: { scope: 'entity', entityId: WRITER_ID }, + grantee: { kind: 'entity', entityId: WRITER_ID }, }, ], }, @@ -1313,10 +1388,10 @@ describe('journal fixtures', () => { body: { kind: 'anyone', label: 'read' }, }); - const { token } = await t.ctx.adapter.createToken(WRITER_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: 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'))!; + const moved = entries.find((e) => e.ops.includes('reshare'))!; // 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(); @@ -1326,7 +1401,7 @@ describe('journal fixtures', () => { 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'), + e.ops.includes('reshare'), )!; expect(ownerEntry.associations).toEqual([ { op: 'add', association: { kind: 'anyone', label: 'read' } }, @@ -1368,8 +1443,8 @@ describe('version lifecycle fixtures', () => { { title: 'original title' }, { permissions: [ - { kind: 'permission', label: 'read', grantee: { scope: 'entity', entityId: WRITER_ID } }, - { kind: 'permission', label: 'write', grantee: { scope: 'entity', entityId: WRITER_ID } }, + { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: WRITER_ID } }, + { kind: 'permission', label: 'write', grantee: { kind: 'entity', entityId: WRITER_ID } }, ], }, ); @@ -1382,7 +1457,7 @@ describe('version lifecycle fixtures', () => { expect(owner.status).toBe(ownerFixture.responseStatus); expect((owner.data as unknown[]).length).toBe(1); - const { token: writerToken } = await t.ctx.adapter.createToken(WRITER_ID); + const { token: writerToken } = await t.ctx.adapter.createToken({ subjectId: WRITER_ID }); const writer = await req(t.app, 'GET', `/records/${record.id}/versions`, { token: writerToken, }); @@ -1621,12 +1696,12 @@ describe('error response fixtures', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); const { status, data } = await dispatch(fixture, token, `/records/${record.id}`); expectError(status, data, fixture); }); @@ -1634,7 +1709,7 @@ describe('error response fixtures', () => { test('error-not-found-record-the-requester-cannot-read — the anti-oracle rule', async () => { const fixture = find('error-not-found-record-the-requester-cannot-read'); const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); - const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); const { status, data } = await dispatch(fixture, token, `/records/${record.id}`); expectError(status, data, fixture); }); @@ -1677,29 +1752,30 @@ describe('error response fixtures', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); const { status, data } = await dispatch(fixture, token, `/records/${record.id}/versions`); expectError(status, data, fixture); }); test('error-permission-denied-attachment-non-owner-create', async () => { const fixture = find('error-permission-denied-attachment-non-owner-create'); - const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); const { status, data } = await dispatch(fixture, token); expectError(status, data, fixture); }); 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({ kind: 'entity', entityId: CONTRIBUTOR_ID }, [ - { actions: ['create'], typeId: ATTACHMENT_TYPE }, - ]); - const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + await t.ctx.stack.grantType(ATTACHMENT_TYPE, { + actions: ['create'], + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, + }); + const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); // The contributor uploads the bytes themselves, so they end up holding // an _attachment@1 record for this fileId — the "uploaded it themselves" // clause of the getAttachment() access rule. That clause is exactly what @@ -1729,15 +1805,18 @@ describe('error response fixtures', () => { // 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' }, + await t.ctx.stack.defineType({ + id: PHOTO_NOTE, + name: 'Photo note', + schema: { + title: { kind: 'string' }, + coverFileId: { kind: 'file-ref' }, + }, + }); + const uploaded = await t.ctx.stack.putAttachment(new Uint8Array([4, 5, 6]), { + mimeType: 'image/png', + filename: 'cover.png', }); - const uploaded = await t.ctx.stack.putAttachment( - new Uint8Array([4, 5, 6]), - 'image/png', - 'cover.png', - ); const fileId = uploaded.content.fileId; const record = await t.ctx.stack.create( PHOTO_NOTE, @@ -1747,12 +1826,12 @@ describe('error response fixtures', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: WRITER_ID }, + grantee: { kind: 'entity', entityId: WRITER_ID }, }, { kind: 'permission', label: 'write', - grantee: { scope: 'entity', entityId: WRITER_ID }, + grantee: { kind: 'entity', entityId: WRITER_ID }, }, ], }, @@ -1761,7 +1840,7 @@ describe('error response fixtures', () => { // 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 { token } = await t.ctx.adapter.createToken({ subjectId: WRITER_ID }); const { status, data } = await dispatch(fixture, token, `/records/${record.id}/restore/1`); expectError(status, data, fixture); @@ -1776,7 +1855,7 @@ describe('error response fixtures', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: WRITER_ID }, + grantee: { kind: 'entity', entityId: WRITER_ID }, }, ], associations: [{ kind: 'attachment', label: 'cover', fileId }], @@ -1831,7 +1910,7 @@ describe('error response fixtures', () => { test('error-permission-denied-includeUnlisted-non-owner', async () => { const fixture = find('error-permission-denied-includeUnlisted-non-owner'); - const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); const { status, data } = await dispatch(fixture, token); expectError(status, data, fixture); // Same refusal on GET /records — includeUnlisted is owner-only on every @@ -1856,25 +1935,25 @@ describe('error response fixtures', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: 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 + // Never created and purged 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 }); + await t.ctx.stack.delete(record.id, { purge: true }); const purged = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}/journal`); expectError(purged.status, purged.data, fixture); }); @@ -1925,7 +2004,7 @@ describe('error response fixtures', () => { test('error-permission-grant-on-an-ungrantable-family-confers-nothing', async () => { const fixture = find('error-permission-grant-on-an-ungrantable-family-confers-nothing'); - // Written directly, because stack.grant() refuses the family outright — + // Written directly, because stack.grantType() refuses the family outright — // which is the point: this pins the second half of the rule, where a // grant that reached storage some other way is read back as conferring // nothing rather than trusted. @@ -1934,7 +2013,7 @@ describe('error response fixtures', () => { actions: ['create', 'read-any'], grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }); - const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); const body = withFreshId(fixture.requestBody as WireRecord); const { status, data } = await dispatch(fixture, token, undefined, body); expectError(status, data, fixture); @@ -1968,7 +2047,7 @@ describe('error response fixtures', () => { grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }); const record = await t.ctx.stack.create(NOTE_TYPE, { title: "someone else's" }); - const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); const { status, data } = await dispatch(fixture, token, `/records/${record.id}`); expectError(status, data, fixture); @@ -2140,9 +2219,9 @@ describe('error response fixtures', () => { // connection sees, optionally across mutations made while it's open. // tests/changeFeedClient.ts dispatches those against the real GET /changes. // -// Same targeted-field discipline as every other block: a fixture's `seq` +// Same targeted-field discipline as every other block: a fixture's `cursor` // and record ids/timestamps are illustrative, not literal. This server -// mints real cursors (`resume: true`), so `ready.data.seq` and +// mints real cursors (`resume: true`), so `ready.data.cursor` and // every `record` frame's SSE `id:` are asserted structurally (base64url // shaped) below rather than deep-equated against a fixture's placeholder. // ------------------------------------------------------- @@ -2151,8 +2230,8 @@ function frameData(frame: DecodedFrame): Record { return frame.data as Record; } -/** base64url charset — the shape every minted cursor is held to (isValidSeq). */ -const SEQ_PATTERN = /^[A-Za-z0-9_-]+$/; +/** base64url charset — the shape every minted cursor is held to (isValidCursor). */ +const CURSOR_PATTERN = /^[A-Za-z0-9_-]+$/; describe('changeFeed fixtures', () => { const handled = new Set(); @@ -2177,9 +2256,9 @@ describe('changeFeed fixtures', () => { const [ready] = await conn.waitForFrames(1); expect(ready.event).toBe('ready'); // ready never carries an SSE `id:` line — the cursor it reports rides - // in the JSON body, as `data.seq`. + // in the JSON body, as `data.cursor`. expect(ready.id).toBeUndefined(); - expect(frameData(ready).seq).toMatch(SEQ_PATTERN); + expect(frameData(ready).cursor).toMatch(CURSOR_PATTERN); } finally { await conn.close(); } @@ -2199,15 +2278,15 @@ describe('changeFeed fixtures', () => { const [, frame] = await conn.waitForFrames(2); expect(frame.event).toBe('record'); // A record frame's SSE `id:` is its resume cursor. - expect(frame.id).toMatch(SEQ_PATTERN); - expect(frame.id).toBe((frameData(frame) as { seq?: string }).seq); + expect(frame.id).toMatch(CURSOR_PATTERN); + expect(frame.id).toBe((frameData(frame) as { cursor?: string }).cursor); const data = frameData(frame); expect(data.kind).toBe('created'); expect(data.ops).toEqual(['create']); expect(data.recordId).toBe(recordId); expect(data.typeId).toBe(NOTE_TYPE); expect(data.version).toBe(1); - expect((data.actor as { entityId: string }).entityId).toBe(TEST_ENTITY_ID); + expect((data.actor as { subjectId: string }).subjectId).toBe(TEST_ENTITY_ID); } finally { await conn.close(); } @@ -2226,17 +2305,17 @@ describe('changeFeed fixtures', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }, { kind: 'permission', label: 'write', - grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN }); try { await conn.waitForFrames(1); // ready @@ -2249,7 +2328,7 @@ describe('changeFeed fixtures', () => { expect(data.kind).toBe('changed'); expect(data.ops).toEqual(['patch']); expect(data.recordId).toBe(record.id); - expect((data.actor as { entityId: string }).entityId).toBe(CONTRIBUTOR_ID); + expect((data.actor as { subjectId: string }).subjectId).toBe(CONTRIBUTOR_ID); } finally { await conn.close(); } @@ -2288,11 +2367,10 @@ describe('changeFeed fixtures', () => { (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 uploaded = await t.ctx.stack.putAttachment(new Uint8Array([1, 2, 3]), { + mimeType: 'image/png', + filename: '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`, { @@ -2348,24 +2426,24 @@ describe('changeFeed fixtures', () => { const record = await t.ctx.stack.create( NOTE_TYPE, { title: 'to be purged' }, - { parentId: parent.id, entityId: CONTRIBUTOR_ID }, + { parentId: parent.id, createdBy: { subjectId: CONTRIBUTOR_ID } }, ); const conn = await openChangeFeed(t.app, '/changes?include=record', { token: TEST_TOKEN }); try { await conn.waitForFrames(1); // ready - await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, { token: TEST_TOKEN }); + await req(t.app, 'DELETE', `/records/${record.id}?purge=true`, { token: TEST_TOKEN }); const [, frame] = await conn.waitForFrames(2); const data = frameData(frame); expect(data.kind).toBe('purged'); - expect(data.ops).toEqual(['hard-delete']); + expect(data.ops).toEqual(['purge']); expect(data.recordId).toBe(record.id); expect(data.typeId).toBe(NOTE_TYPE); expect('record' in data).toBe(false); expect('parentId' in data).toBe(false); - // Owner-acting-alone is the only way to reach hard delete, and a + // Owner-acting-alone is the only way to reach purge, and a // purge stamps nothing on a record that no longer exists — the actor // is the requester, never the record's own author (CONTRIBUTOR_ID). - expect((data.actor as { entityId: string }).entityId).toBe(TEST_ENTITY_ID); + expect((data.actor as { subjectId: string }).subjectId).toBe(TEST_ENTITY_ID); } finally { await conn.close(); } @@ -2408,12 +2486,12 @@ describe('changeFeed fixtures', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); const conn = await openChangeFeed(t.app, '/changes', { token }); try { await conn.waitForFrames(1); // ready @@ -2435,12 +2513,16 @@ describe('changeFeed fixtures', () => { } }); - test('change-feed-typeid-filter-matches-by-baseid', async () => { + // typeId is an exact match: a record already at note@2 is invisible to a + // note@1 filter, but a migration *out of* note@1 is delivered — matched + // against the type it left — carrying the new typeId. + test('change-feed-typeid-filter-matches-exactly', async () => { const fixture = changeFeedFixtures.find( - (f) => f.name === 'change-feed-typeid-filter-matches-by-baseid', + (f) => f.name === 'change-feed-typeid-filter-matches-exactly', )!; handled.add(fixture.name); const note = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }); + const later = await t.ctx.stack.create(NOTE_TYPE_V2, { title: 'Already v2' }); const comment = await t.ctx.stack.create(COMMENT_TYPE, { body: 'unrelated' }); const conn = await openChangeFeed(t.app, `/changes?typeId=${encodeURIComponent(NOTE_TYPE)}`, { token: TEST_TOKEN, @@ -2451,12 +2533,17 @@ describe('changeFeed fixtures', () => { token: TEST_TOKEN, body: { contentPatch: { body: 'still unrelated' } }, }); + await req(t.app, 'PATCH', `/records/${later.id}`, { + token: TEST_TOKEN, + body: { contentPatch: { title: 'Later' } }, + }); await req(t.app, 'POST', `/records/${note.id}/migrate`, { token: TEST_TOKEN, body: { toTypeId: NOTE_TYPE_V2, content: { title: 'Hello', pinned: false } }, }); - // Exactly one more frame — the migration — proves the unrelated - // type's edit was filtered out rather than merely arriving later. + // Exactly one more frame — the migration — proves the other type's + // edit and the note@2 edit were filtered out rather than merely + // arriving later. const [, frame] = await conn.waitForFrames(2); const data = frameData(frame); expect(data.recordId).toBe(note.id); @@ -2467,6 +2554,37 @@ describe('changeFeed fixtures', () => { } }); + test('change-feed-baseid-filter-matches-every-version', async () => { + const fixture = changeFeedFixtures.find( + (f) => f.name === 'change-feed-baseid-filter-matches-every-version', + )!; + handled.add(fixture.name); + const baseId = NOTE_TYPE.slice(0, NOTE_TYPE.lastIndexOf('@')); + const comment = await t.ctx.stack.create(COMMENT_TYPE, { body: 'unrelated' }); + const later = await t.ctx.stack.create(NOTE_TYPE_V2, { title: 'Already v2' }); + const conn = await openChangeFeed(t.app, `/changes?baseId=${encodeURIComponent(baseId)}`, { + token: TEST_TOKEN, + }); + try { + await conn.waitForFrames(1); // ready + await req(t.app, 'PATCH', `/records/${comment.id}`, { + token: TEST_TOKEN, + body: { contentPatch: { body: 'still unrelated' } }, + }); + await req(t.app, 'PATCH', `/records/${later.id}`, { + token: TEST_TOKEN, + body: { contentPatch: { title: 'Later' } }, + }); + const [, frame] = await conn.waitForFrames(2); + const data = frameData(frame); + expect(data.recordId).toBe(later.id); + expect(data.typeId).toBe(NOTE_TYPE_V2); + expect(data.ops).toEqual(['patch']); + } finally { + await conn.close(); + } + }); + test('change-feed-reset-when-no-cursor-is-honored', async () => { const fixture = changeFeedFixtures.find( (f) => f.name === 'change-feed-reset-when-no-cursor-is-honored', @@ -2636,14 +2754,14 @@ describe('changeFeed sequence fixtures', () => { expect(first.status).toBe(fixture.steps[0]!.responseStatus); const [ready] = await first.waitForFrames(1); expect(ready.event).toBe('ready'); - expect(frameData(ready).seq).toMatch(SEQ_PATTERN); + expect(frameData(ready).cursor).toMatch(CURSOR_PATTERN); await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: { contentPatch: { title: 'first' } }, }); const [, changeFrame] = await first.waitForFrames(2); expect(changeFrame.event).toBe('record'); - expect(changeFrame.id).toMatch(SEQ_PATTERN); + expect(changeFrame.id).toMatch(CURSOR_PATTERN); lastEventId = changeFrame.id!; } finally { await first.close(); @@ -2667,9 +2785,9 @@ describe('changeFeed sequence fixtures', () => { expect(second.status).toBe(fixture.steps[1]!.responseStatus); const [ready, replayed] = await second.waitForFrames(2); expect(ready.event).toBe('ready'); - expect(frameData(ready).seq).toMatch(SEQ_PATTERN); + expect(frameData(ready).cursor).toMatch(CURSOR_PATTERN); expect(replayed.event).toBe('record'); - expect(replayed.id).toMatch(SEQ_PATTERN); + expect(replayed.id).toMatch(CURSOR_PATTERN); expect(replayed.id).not.toBe(lastEventId); const data = frameData(replayed); expect(data.recordId).toBe(record.id); @@ -2705,8 +2823,8 @@ describe('changeFeed sequence fixtures', () => { expect(first.status).toBe(fixture.steps[0]!.responseStatus); const [ready] = await first.waitForFrames(1); expect(ready.event).toBe('ready'); - headCursor = frameData(ready).seq as string; - expect(headCursor).toMatch(SEQ_PATTERN); + headCursor = frameData(ready).cursor as string; + expect(headCursor).toMatch(CURSOR_PATTERN); } finally { await first.close(); } @@ -2724,8 +2842,8 @@ describe('changeFeed sequence fixtures', () => { const [ready, reset] = await second.waitForFrames(2); expect(ready.event).toBe('ready'); // A fresh buffer, so a fresh (different) head cursor. - expect(frameData(ready).seq).toMatch(SEQ_PATTERN); - expect(frameData(ready).seq).not.toBe(headCursor); + expect(frameData(ready).cursor).toMatch(CURSOR_PATTERN); + expect(frameData(ready).cursor).not.toBe(headCursor); expect(reset.event).toBe('reset'); expect(frameData(reset).reason).toBe('cursor_expired'); } finally { @@ -3003,7 +3121,7 @@ describe('attachmentDownload fixtures', () => { async function uploadFile(mimeType: string): Promise { const record = await t.ctx.stack.putAttachment( new TextEncoder().encode(`conformance-fixture-bytes:${mimeType}`), - mimeType, + { mimeType: mimeType }, ); return (record.content as { fileId: string }).fileId; } @@ -3061,7 +3179,7 @@ describe('attachmentDownload fixtures', () => { // Raw bytes with no _attachment@1 record at all — bypasses // Stack.putAttachment (which creates the record atomically) by writing // straight through the adapter. - const fileId = await t.ctx.adapter.putAttachment(new TextEncoder().encode('orphan-bytes')); + const fileId = await t.ctx.adapter.putBlob(new TextEncoder().encode('orphan-bytes')); await dispatch(fixture, fileId); }); @@ -3136,7 +3254,7 @@ describe('attachmentUpload fixtures', () => { test('attachment-upload-non-owner-without-create-grant-forbidden', async () => { const fixture = find('attachment-upload-non-owner-without-create-grant-forbidden'); - const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); const { status, data } = await dispatch(fixture, { token }); expect(status).toBe(fixture.responseStatus); const expected = fixture.responseBody as { error: { code: string } }; diff --git a/tests/lib/changeFeedClient.test.ts b/tests/lib/changeFeedClient.test.ts index 4448aba..1135e07 100644 --- a/tests/lib/changeFeedClient.test.ts +++ b/tests/lib/changeFeedClient.test.ts @@ -15,10 +15,10 @@ describe('SSEDecoder', () => { test('decodes multiple frames from one chunk', () => { const decoder = new SSEDecoder(); const frames = decoder.push( - 'event: ready\ndata: {"seq":"AA3f1Q"}\n\nid: AA3f1R\nevent: record\ndata: {"kind":"created"}\n\n', + 'event: ready\ndata: {"cursor":"AA3f1Q"}\n\nid: AA3f1R\nevent: record\ndata: {"kind":"created"}\n\n', ); expect(frames).toEqual([ - { event: 'ready', data: { seq: 'AA3f1Q' } }, + { event: 'ready', data: { cursor: 'AA3f1Q' } }, { id: 'AA3f1R', event: 'record', data: { kind: 'created' } }, ]); }); @@ -26,9 +26,9 @@ describe('SSEDecoder', () => { test('decodes a frame split across chunks, mid-line', () => { const decoder = new SSEDecoder(); expect(decoder.push('event: rea')).toEqual([]); - expect(decoder.push('dy\ndata: {"se')).toEqual([]); - const frames = decoder.push('q":"AA3f1Q"}\n\n'); - expect(frames).toEqual([{ event: 'ready', data: { seq: 'AA3f1Q' } }]); + expect(decoder.push('dy\ndata: {"cur')).toEqual([]); + const frames = decoder.push('sor":"AA3f1Q"}\n\n'); + expect(frames).toEqual([{ event: 'ready', data: { cursor: 'AA3f1Q' } }]); }); test('ignores comment lines (keepalives)', () => { diff --git a/tests/lib/resumeBuffer.test.ts b/tests/lib/resumeBuffer.test.ts index 91d3943..d4b8509 100644 --- a/tests/lib/resumeBuffer.test.ts +++ b/tests/lib/resumeBuffer.test.ts @@ -25,12 +25,12 @@ describe('ResumeBuffer', () => { expect(decoded.n).toBe(0); }); - it("advances the head cursor and attaches it as each entry's seq", () => { + it("advances the head cursor and attaches it as each entry's cursor", () => { const buffer = new ResumeBuffer(10); const entry = buffer.append(change()); expect(entry.n).toBe(1); - expect(entry.frame.seq).toBe(buffer.headCursor()); - expect(decodeCursor(entry.frame.seq!)!.n).toBe(1); + expect(entry.frame.cursor).toBe(buffer.headCursor()); + expect(decodeCursor(entry.frame.cursor!)!.n).toBe(1); }); it('notifies every attached live listener of each append, and stops once detached', () => { @@ -108,7 +108,7 @@ describe('resumeBufferKey', () => { const filtered = resumeBufferKey({ principalId: null, subjectId: null, - filter: { entityId: 'e1' }, + filter: { createdBy: { subjectId: 'e1' } }, includeRecords: false, includeUnlisted: false, }); @@ -158,7 +158,7 @@ describe('resumeBufferKey', () => { includeUnlisted: false, }; expect(resumeBufferKey({ ...base, filter: {} })).not.toBe( - resumeBufferKey({ ...base, filter: { entityId: 'e1' } }), + resumeBufferKey({ ...base, filter: { createdBy: { subjectId: 'e1' } } }), ); expect(resumeBufferKey({ ...base, filter: {} })).not.toBe( resumeBufferKey({ ...base, filter: { typeId: [] } }), @@ -176,7 +176,13 @@ describe('resumeBufferKey', () => { includeUnlisted: false, }; expect(resumeBufferKey({ ...base, filter: { parentId: 'x' } })).not.toBe( - resumeBufferKey({ ...base, filter: { entityId: 'x' } }), + resumeBufferKey({ ...base, filter: { createdBy: { subjectId: 'x' } } }), + ); + expect(resumeBufferKey({ ...base, filter: { typeId: 'x@1' } })).not.toBe( + resumeBufferKey({ ...base, filter: { baseId: 'x@1' } }), + ); + expect(resumeBufferKey({ ...base, filter: { createdBy: { subjectId: 'x' } } })).not.toBe( + resumeBufferKey({ ...base, filter: { createdBy: { principalId: 'x' } } }), ); }); }); diff --git a/tests/lib/resumeCursor.test.ts b/tests/lib/resumeCursor.test.ts index c6c304a..329894e 100644 --- a/tests/lib/resumeCursor.test.ts +++ b/tests/lib/resumeCursor.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { isValidSeq } from '@haverstack/wire-types'; +import { isValidCursor } from '@haverstack/wire-types'; import { encodeCursor, decodeCursor } from '../../src/lib/resumeCursor.js'; describe('resume cursor codec', () => { @@ -10,7 +10,7 @@ describe('resume cursor codec', () => { it('mints only base64url-charset cursors, whatever the buffer id contains', () => { const seq = encodeCursor('weird:id/with+chars=', 0); - expect(isValidSeq(seq)).toBe(true); + expect(isValidCursor(seq)).toBe(true); }); it('distinguishes buffer ids and positions that would collide as plain strings', () => { diff --git a/tests/middleware/errors.test.ts b/tests/middleware/errors.test.ts index 5a59f74..c1008cf 100644 --- a/tests/middleware/errors.test.ts +++ b/tests/middleware/errors.test.ts @@ -42,7 +42,7 @@ describe('errorMiddleware — denied-but-verified logging', () => { }); it('logs the requester DID when a verified token is denied by permission', async () => { - const { token } = await ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const warn = spyLogger(); const app = createApp(ctx, testConfig(dbPath), warn); @@ -82,8 +82,12 @@ describe('errorMiddleware — refusal logging (StackNotFoundError)', () => { beforeEach(async () => { dbPath = tempDbPath(); ctx = await createTestContext(dbPath); - await ctx.stack.defineType(NOTE_TYPE_ID, 'Note', { - body: { kind: 'text' as const, required: true as const }, + await ctx.stack.defineType({ + id: NOTE_TYPE_ID, + name: 'Note', + schema: { + body: { kind: 'text' as const, required: true as const }, + }, }); }); @@ -96,7 +100,7 @@ describe('errorMiddleware — refusal logging (StackNotFoundError)', () => { it('logs existed: true for a verified requester denied read on an existing record', async () => { const record = await ctx.stack.create(NOTE_TYPE_ID, { body: 'private' }); - const { token } = await ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const debug = spyLogger(); const app = createApp(ctx, testConfig(dbPath), debug); @@ -115,7 +119,7 @@ describe('errorMiddleware — refusal logging (StackNotFoundError)', () => { }); it('logs existed: false for a verified requester on a genuinely missing record', async () => { - const { token } = await ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const debug = spyLogger(); const app = createApp(ctx, testConfig(dbPath), debug); diff --git a/tests/routes/associations.test.ts b/tests/routes/associations.test.ts index 776e7ef..a928919 100644 --- a/tests/routes/associations.test.ts +++ b/tests/routes/associations.test.ts @@ -7,8 +7,12 @@ describe('Associations', () => { let t: TestApp; beforeEach(async () => { t = await buildTestApp(); - await t.ctx.stack.defineType(TYPE_ID, 'Post', { - text: { kind: 'text' as const, required: true as const }, + await t.ctx.stack.defineType({ + id: TYPE_ID, + name: 'Post', + schema: { + text: { kind: 'text' as const, required: true as const }, + }, }); }); afterEach(async () => { @@ -31,10 +35,10 @@ describe('Associations', () => { ]); }); - it('POST adds a relationship association with a record-scope target and answers with the updated record', async () => { + it('POST adds a relationship association with a record target and answers with the updated record', async () => { const record = await seedRecord(); const other = await seedRecord(); - const target = { scope: 'record' as const, recordId: other.id }; + const target = { kind: 'record' as const, recordId: other.id }; const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations`, { token: TEST_TOKEN, body: { kind: 'relationship', label: 'reply-to', target }, @@ -46,10 +50,10 @@ describe('Associations', () => { }); it.each([ - ['entity', { scope: 'entity', entityId: 'did:key:z6MkAlice' }], - ['external', { scope: 'external', ns: 'atproto', id: 'at://did:plc:abc/app.bsky.feed.post/1' }], + ['entity', { kind: 'entity', entityId: 'did:key:z6MkAlice' }], + ['external', { kind: 'external', ns: 'atproto', id: 'at://did:plc:abc/app.bsky.feed.post/1' }], ] as const)( - 'POST adds a relationship association with a %s-scope target and answers with the updated record', + 'POST adds a relationship association with a %s target and answers with the updated record', async (_scope, target) => { const record = await seedRecord(); const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations`, { @@ -101,9 +105,9 @@ describe('Associations', () => { expect(after?.associations?.some((a) => a.label === 'starred')).toBeFalsy(); }); - it('POST .../associations/delete removes a relationship association regardless of target scope', async () => { + it('POST .../associations/delete removes a relationship association regardless of target kind', async () => { const record = await seedRecord(); - const target = { scope: 'entity' as const, entityId: 'did:key:z6MkAlice' }; + const target = { kind: 'entity' as const, entityId: 'did:key:z6MkAlice' }; await t.ctx.adapter.associate(record.id, { kind: 'relationship', label: 'author', target }); const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations/delete`, { token: TEST_TOKEN, @@ -169,7 +173,7 @@ describe('Associations', () => { body: { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: 'entity-other' }, + grantee: { kind: 'entity', entityId: 'entity-other' }, }, }); expect(status).toBe(400); diff --git a/tests/routes/attachments.test.ts b/tests/routes/attachments.test.ts index 1722d30..241d990 100644 --- a/tests/routes/attachments.test.ts +++ b/tests/routes/attachments.test.ts @@ -15,14 +15,20 @@ import { createApp } from '../../src/app.js'; const NOTE_TYPE_ID = 'com.example.test/note@1'; async function seedType(ctx: TestApp['ctx']) { - return ctx.stack.defineType(NOTE_TYPE_ID, 'Note', { - body: { kind: 'text' as const, required: true as const }, + return ctx.stack.defineType({ + id: NOTE_TYPE_ID, + name: 'Note', + schema: { + body: { kind: 'text' as const, required: true as const }, + }, }); } /** Seeds bytes via the unscoped Stack (bypasses the wire route entirely). */ async function putFile(ctx: TestApp['ctx'], content = 'hello') { - const record = await ctx.stack.putAttachment(new TextEncoder().encode(content), 'text/plain'); + const record = await ctx.stack.putAttachment(new TextEncoder().encode(content), { + mimeType: 'text/plain', + }); return (record.content as { fileId: string }).fileId; } @@ -57,7 +63,7 @@ describe('GET /attachments/:fileId', () => { it('rejects a non-owner authenticated request for an unattached file', async () => { const fileId = await putFile(t.ctx); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'GET', `/attachments/${fileId}`, { token }); expect(status).toBe(403); }); @@ -125,13 +131,13 @@ describe('GET /attachments/:fileId', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], associations: [{ kind: 'attachment', label: 'file', fileId }], }, ); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'GET', `/attachments/${fileId}`, { token }); expect(status).toBe(200); @@ -152,13 +158,13 @@ describe('GET /attachments/:fileId', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], associations: [{ kind: 'attachment', label: 'file', fileId }], }, ); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'GET', `/attachments/${fileId}`, { token }); expect(status).toBe(200); @@ -173,7 +179,7 @@ describe('GET /attachments/:fileId', () => { associations: [{ kind: 'attachment', label: 'file', fileId }], }, ); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'GET', `/attachments/${fileId}`, { token }); expect(status).toBe(403); @@ -188,7 +194,7 @@ describe('GET /attachments/:fileId', () => { associations: [{ kind: 'attachment', label: 'file', fileId }], }, ); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const inaccessible = await req(t.app, 'GET', `/attachments/${fileId}`, { token }); const missing = await req( @@ -269,8 +275,11 @@ describe('GET /attachments/:fileId', () => { it('falls back to the first-recorded filename when the requester has no own record', async () => { const bytes = new TextEncoder().encode('shared content'); - await t.ctx.stack.putAttachment(bytes, 'text/plain', 'first.txt'); - const second = await t.ctx.stack.putAttachment(bytes, 'text/plain', 'second.txt'); + await t.ctx.stack.putAttachment(bytes, { mimeType: 'text/plain', filename: 'first.txt' }); + const second = await t.ctx.stack.putAttachment(bytes, { + mimeType: 'text/plain', + filename: 'second.txt', + }); const fileId = (second.content as { fileId: string }).fileId; const res = await t.app.request(`/attachments/${fileId}`, { @@ -282,16 +291,17 @@ 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({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ - { actions: ['create'], typeId: '_attachment@1' }, - ]); + await t.ctx.stack.grantType('_attachment@1', { + actions: ['create'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }); const first = await t.ctx.stack - .forSession({ principalId: OTHER_ENTITY_ID, subjectId: OTHER_ENTITY_ID }) - .putAttachment(bytes, 'text/plain', 'first.txt'); + .asActor({ principalId: OTHER_ENTITY_ID, subjectId: OTHER_ENTITY_ID }) + .putAttachment(bytes, { mimeType: 'text/plain', filename: 'first.txt' }); const fileId = (first.content as { fileId: string }).fileId; await t.ctx.stack - .forSession({ principalId: TEST_ENTITY_ID, subjectId: TEST_ENTITY_ID }) - .putAttachment(bytes, 'text/plain', 'mine.txt'); + .asActor({ principalId: TEST_ENTITY_ID, subjectId: TEST_ENTITY_ID }) + .putAttachment(bytes, { mimeType: 'text/plain', filename: 'mine.txt' }); const res = await t.app.request(`/attachments/${fileId}`, { headers: { Authorization: `Bearer ${TEST_TOKEN}` }, @@ -304,8 +314,7 @@ describe('GET /attachments/:fileId', () => { it('forces a dangerous stored mimeType to application/octet-stream', async () => { const record = await t.ctx.stack.putAttachment( new TextEncoder().encode(''), - 'text/html', - 'evil.html', + { mimeType: 'text/html', filename: 'evil.html' }, ); const fileId = (record.content as { fileId: string }).fileId; @@ -398,7 +407,7 @@ describe('POST /attachments', () => { }); it('rejects a token holder with no create grant on _attachment@1 with 403', async () => { - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const res = await t.app.request('/attachments', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'text/plain' }, @@ -408,10 +417,11 @@ describe('POST /attachments', () => { }); it('allows an entity with a create grant on _attachment@1 to upload', async () => { - 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); + await t.ctx.stack.grantType('_attachment@1', { + actions: ['create'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const res = await t.app.request('/attachments', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'text/plain' }, @@ -419,7 +429,7 @@ describe('POST /attachments', () => { }); expect(res.status).toBe(200); const record = (await res.json()) as Record; - expect(record.entityId).toBe(OTHER_ENTITY_ID); + expect((record.createdBy as { subjectId: string }).subjectId).toBe(OTHER_ENTITY_ID); }); it('identical bytes uploaded twice produce the same fileId but two distinct records', async () => { @@ -547,18 +557,18 @@ describe('DELETE /attachments/:fileId', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, { kind: 'permission', label: 'write', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], associations: [{ kind: 'attachment', label: 'file', fileId }], }, ); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'DELETE', `/attachments/${fileId}`, { token }); expect(status).toBe(403); @@ -581,7 +591,7 @@ describe('POST /attachments/gc', () => { }); it('rejects a non-owner request with 403', async () => { - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'POST', '/attachments/gc', { token }); expect(status).toBe(403); }); @@ -598,8 +608,8 @@ describe('POST /attachments/gc', () => { body: { graceMs: 0, dryRun: true }, }); expect(status).toBe(200); - const result = data as { deleted: string[]; reclaimedBytes: number }; - expect(result.deleted).toContain(fileId); + const result = data as { deletedFileIds: string[]; reclaimedBytes: number }; + expect(result.deletedFileIds).toContain(fileId); const after = await req(t.app, 'GET', `/attachments/${fileId}`, { token: TEST_TOKEN }); expect(after.status).toBe(200); @@ -609,8 +619,8 @@ describe('POST /attachments/gc', () => { const fileId = await putFile(t.ctx); const { status, data } = await req(t.app, 'POST', '/attachments/gc', { token: TEST_TOKEN }); expect(status).toBe(200); - const result = data as { deleted: string[] }; - expect(result.deleted).not.toContain(fileId); + const result = data as { deletedFileIds: string[] }; + expect(result.deletedFileIds).not.toContain(fileId); }); it('deletes unreferenced files past the grace period and reclaims their bytes', async () => { @@ -620,8 +630,8 @@ describe('POST /attachments/gc', () => { body: { graceMs: 0 }, }); expect(status).toBe(200); - const result = data as { deleted: string[]; reclaimedBytes: number }; - expect(result.deleted).toContain(fileId); + const result = data as { deletedFileIds: string[]; reclaimedBytes: number }; + expect(result.deletedFileIds).toContain(fileId); expect(result.reclaimedBytes).toBeGreaterThan(0); const after = await req(t.app, 'GET', `/attachments/${fileId}`, { token: TEST_TOKEN }); @@ -642,7 +652,7 @@ describe('POST /attachments/gc', () => { body: { graceMs: 0 }, }); expect(status).toBe(200); - const result = data as { deleted: string[] }; - expect(result.deleted).not.toContain(fileId); + const result = data as { deletedFileIds: string[] }; + expect(result.deletedFileIds).not.toContain(fileId); }); }); diff --git a/tests/routes/auth.test.ts b/tests/routes/auth.test.ts index 1eb7c3e..12ca706 100644 --- a/tests/routes/auth.test.ts +++ b/tests/routes/auth.test.ts @@ -343,7 +343,10 @@ describe('handshake token bookkeeping', () => { // nothing else does either — so an unauthenticated route that mints one // per call grows the table without bound. it('reclaims expired token rows rather than letting them accumulate', async () => { - await t.ctx.tokens.createToken(OTHER_ENTITY_ID, { expiresAt: new Date(Date.now() - 1000) }); + await t.ctx.tokens.createToken( + { subjectId: OTHER_ENTITY_ID }, + { expiresAt: new Date(Date.now() - 1000) }, + ); expect((await t.ctx.tokens.listTokens()).length).toBe(1); const keypair = await generateDidKeypair(); @@ -392,8 +395,12 @@ describe('invalid bearer credentials', () => { let t: TestApp; beforeEach(async () => { t = await buildTestApp(); - await t.ctx.stack.defineType(NOTE_TYPE_ID, 'Note', { - body: { kind: 'text' as const, required: true as const }, + await t.ctx.stack.defineType({ + id: NOTE_TYPE_ID, + name: 'Note', + schema: { + body: { kind: 'text' as const, required: true as const }, + }, }); await t.ctx.stack.create( NOTE_TYPE_ID, @@ -412,15 +419,18 @@ describe('invalid bearer credentials', () => { }); it('rejects an expired token on an optional-auth route with 401', async () => { - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID, { - expiresAt: new Date(Date.now() - 1000), - }); + const { token } = await t.ctx.adapter.createToken( + { subjectId: OTHER_ENTITY_ID }, + { + expiresAt: new Date(Date.now() - 1000), + }, + ); const { status } = await req(t.app, 'GET', '/records', { token }); expect(status).toBe(401); }); it('rejects a revoked token on an optional-auth route with 401', async () => { - const { id, token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { id, token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); await t.ctx.tokens.revokeToken(id); const { status } = await req(t.app, 'GET', '/records', { token }); expect(status).toBe(401); diff --git a/tests/routes/changes.test.ts b/tests/routes/changes.test.ts index 7a71e5d..9048bfe 100644 --- a/tests/routes/changes.test.ts +++ b/tests/routes/changes.test.ts @@ -39,7 +39,11 @@ describe('GET /changes', () => { let t: TestApp; beforeEach(async () => { t = await buildTestApp(); - await t.ctx.stack.defineType(NOTE_TYPE, 'Note', { title: { kind: 'string' } }); + await t.ctx.stack.defineType({ + id: NOTE_TYPE, + name: 'Note', + schema: { title: { kind: 'string' } }, + }); }); afterEach(async () => { await t.cleanup(); @@ -62,7 +66,7 @@ describe('GET /changes', () => { }); it('rejects a charset-invalid cursor locally, as a 400, rather than as a reset frame', async () => { - // isValidSeq() only ever allows base64url — a space is never in that + // isValidCursor() only ever allows base64url — a space is never in that // alphabet. Refused before the SSE stream even opens, not treated as // a resumable-but-unrecognized cursor. const { status, data } = await req(t.app, 'GET', '/changes', { @@ -107,7 +111,11 @@ describe('GET /changes', () => { it('sends periodic keepalive comments on an otherwise-idle connection', async () => { const dbPath = tempDbPath(); const ctx = await createTestContext(dbPath); - await ctx.stack.defineType(NOTE_TYPE, 'Note', { title: { kind: 'string' } }); + await ctx.stack.defineType({ + id: NOTE_TYPE, + name: 'Note', + schema: { title: { kind: 'string' } }, + }); const config = testConfig(dbPath); const app = testChangesApp(ctx, config, { keepaliveMs: 20, sessionCheckMs: 60_000 }); try { @@ -137,10 +145,14 @@ describe('GET /changes', () => { it('closes the connection once a revoked token is re-checked', async () => { const dbPath = tempDbPath(); const ctx = await createTestContext(dbPath); - await ctx.stack.defineType(NOTE_TYPE, 'Note', { title: { kind: 'string' } }); + await ctx.stack.defineType({ + id: NOTE_TYPE, + name: 'Note', + schema: { title: { kind: 'string' } }, + }); const config = testConfig(dbPath); const app = testChangesApp(ctx, config, { sessionCheckMs: 20, keepaliveMs: 60_000 }); - const { id, token } = await ctx.adapter.createToken(CONTRIBUTOR_ID); + const { id, token } = await ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); try { const res = await app.request('/', { headers: { Accept: 'text/event-stream', Authorization: `Bearer ${token}` }, @@ -193,7 +205,7 @@ describe('GET /changes', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }, ], }, @@ -206,18 +218,18 @@ describe('GET /changes', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); const conn = await openChangeFeed(t.app, '/changes', { token }); let cursor: string; try { const [ready] = await conn.waitForFrames(1); - cursor = (ready.data as { seq: string }).seq; + cursor = (ready.data as { cursor: string }).cursor; } finally { await conn.close(); } @@ -253,12 +265,12 @@ describe('GET /changes', () => { let cursor: string; try { const [ready] = await conn.waitForFrames(1); - cursor = (ready.data as { seq: string }).seq; + cursor = (ready.data as { cursor: string }).cursor; } finally { await conn.close(); } - await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, { token: TEST_TOKEN }); + await req(t.app, 'DELETE', `/records/${record.id}?purge=true`, { token: TEST_TOKEN }); const resumed = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN, @@ -282,7 +294,7 @@ describe('GET /changes', () => { let cursor: string; try { const [ready] = await conn.waitForFrames(1); - cursor = (ready.data as { seq: string }).seq; + cursor = (ready.data as { cursor: string }).cursor; } finally { await conn.close(); } @@ -317,7 +329,7 @@ describe('GET /changes', () => { let cursor: string; try { const [ready] = await conn.waitForFrames(1); - cursor = (ready.data as { seq: string }).seq; + cursor = (ready.data as { cursor: string }).cursor; } finally { await conn.close(); } @@ -406,9 +418,13 @@ describe('GET /changes', () => { it('closes the connection when the token store cannot answer a session re-check', async () => { const dbPath = tempDbPath(); const ctx = await createTestContext(dbPath); - await ctx.stack.defineType(NOTE_TYPE, 'Note', { title: { kind: 'string' } }); + await ctx.stack.defineType({ + id: NOTE_TYPE, + name: 'Note', + schema: { title: { kind: 'string' } }, + }); const config = testConfig(dbPath); - const { token } = await ctx.adapter.createToken(CONTRIBUTOR_ID); + const { token } = await ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); const app = testChangesApp(ctx, config, { sessionCheckMs: 20, keepaliveMs: 60_000 }); // The store is reachable at connect (auth succeeds) and unreachable by @@ -451,7 +467,7 @@ describe('GET /changes', () => { describe('includeUnlisted', () => { it('refuses a non-owner with 403 before the SSE stream opens', async () => { - const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: CONTRIBUTOR_ID }); const { status } = await req(t.app, 'GET', '/changes?includeUnlisted=true', { token }); expect(status).toBe(403); }); @@ -566,12 +582,12 @@ describe('GET /changes', () => { } }); - it('hard delete while unlisted emits nothing', async () => { + it('purge while unlisted emits nothing', async () => { const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }, { unlisted: true }); const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN }); try { await conn.waitForFrames(1); // ready - await t.ctx.stack.delete(record.id, { hard: true }); + await t.ctx.stack.delete(record.id, { purge: true }); const proof = await t.ctx.stack.create(NOTE_TYPE, { title: 'public' }); const [, frame] = await conn.waitForFrames(2); expect((frame.data as { recordId: string }).recordId).toBe(proof.id); @@ -594,10 +610,11 @@ describe('GET /changes', () => { describe('permission scoping on a live connection', () => { it('stops delivering once a type-level grant is withdrawn mid-stream', async () => { const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'covered by a grant' }); - const [grantRecord] = await t.ctx.stack.grant({ kind: 'entity', entityId: CONTRIBUTOR_ID }, [ - { typeId: NOTE_TYPE, actions: ['read-any'] }, - ]); - const { token } = await t.ctx.tokens.createToken(CONTRIBUTOR_ID); + const grantRecord = await t.ctx.stack.grantType(NOTE_TYPE, { + actions: ['read-any'], + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, + }); + const { token } = await t.ctx.tokens.createToken({ subjectId: CONTRIBUTOR_ID }); const conn = await openChangeFeed(t.app, '/changes', { token }); try { @@ -622,7 +639,7 @@ describe('GET /changes', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }, ], }, @@ -636,7 +653,7 @@ describe('GET /changes', () => { it('starts delivering once a record is shared mid-stream', async () => { const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'private' }); - const { token } = await t.ctx.tokens.createToken(CONTRIBUTOR_ID); + const { token } = await t.ctx.tokens.createToken({ subjectId: CONTRIBUTOR_ID }); const conn = await openChangeFeed(t.app, '/changes', { token }); try { @@ -647,7 +664,7 @@ describe('GET /changes', () => { await t.ctx.stack.grantAccess(record.id, { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }); await t.ctx.stack.patchContent(record.id, { title: 'now shared' }); @@ -667,12 +684,12 @@ describe('GET /changes', () => { { kind: 'relationship', label: 'admin', - target: { scope: 'entity', entityId: t.ctx.stack.ownerEntityId }, + target: { kind: 'entity', entityId: t.ctx.stack.ownerEntityId }, }, { kind: 'relationship', label: 'member', - target: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + target: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }, ], }, @@ -685,12 +702,12 @@ describe('GET /changes', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'group', groupId: group.id, role: 'member' }, + grantee: { kind: 'group', groupId: group.id, role: 'member' }, }, ], }, ); - const { token } = await t.ctx.tokens.createToken(CONTRIBUTOR_ID); + const { token } = await t.ctx.tokens.createToken({ subjectId: CONTRIBUTOR_ID }); const conn = await openChangeFeed(t.app, '/changes', { token }); try { @@ -704,7 +721,7 @@ describe('GET /changes', () => { await t.ctx.stack.dissociate(group.id, { kind: 'relationship', label: 'member', - target: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + target: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }); await t.ctx.stack.patchContent(record.id, { title: 'off the roster' }); @@ -716,7 +733,7 @@ describe('GET /changes', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, }, ], }, diff --git a/tests/routes/contentQuery.test.ts b/tests/routes/contentQuery.test.ts index 53e019a..38fb35a 100644 --- a/tests/routes/contentQuery.test.ts +++ b/tests/routes/contentQuery.test.ts @@ -13,8 +13,12 @@ describe('nested content query', () => { let t: TestApp; beforeEach(async () => { t = await buildTestApp(); - await t.ctx.stack.defineType(CONTACT_TYPE, 'Contact', { - profile: { kind: 'object', properties: { email: { kind: 'string' } } }, + await t.ctx.stack.defineType({ + id: CONTACT_TYPE, + name: 'Contact', + schema: { + profile: { kind: 'object', properties: { email: { kind: 'string' } } }, + }, }); }); afterEach(async () => { @@ -42,9 +46,13 @@ describe('full-text search', () => { let t: TestApp; beforeEach(async () => { t = await buildTestApp(); - await t.ctx.stack.defineType(NOTE_TYPE, 'Note', { - title: { kind: 'string' }, - body: { kind: 'text' }, + await t.ctx.stack.defineType({ + id: NOTE_TYPE, + name: 'Note', + schema: { + title: { kind: 'string' }, + body: { kind: 'text' }, + }, }); }); afterEach(async () => { diff --git a/tests/routes/entity.test.ts b/tests/routes/entity.test.ts index 6d54e68..7f37bf1 100644 --- a/tests/routes/entity.test.ts +++ b/tests/routes/entity.test.ts @@ -17,12 +17,12 @@ async function seedEntityRecord(ctx: TestApp['ctx']): Promise { createdAt: new Date(), updatedAt: new Date(), version: 1, - entityId: TEST_ENTITY_ID, + createdBy: { subjectId: TEST_ENTITY_ID }, }); } // _entity@1 records get an auto-generated id — even the owner's own card -// (see Stack.create()'s ownerProfile bootstrap) — with the binding held in +// (see Stack.open()'s ownerProfile bootstrap) — with the binding held in // content.did rather than the record id. This is the realistic shape. async function seedEntityRecordWithGeneratedId(ctx: TestApp['ctx']): Promise { return ctx.stack.create('_entity@1', { did: TEST_ENTITY_ID, name: 'Test Entity' }); @@ -63,7 +63,7 @@ describe('GET /entity', () => { it('returns 404, not 403, for a non-owner authenticated entity (anti-oracle rule)', async () => { await seedEntityRecord(t.ctx); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'GET', '/entity', { token }); expect(status).toBe(404); }); @@ -79,7 +79,7 @@ describe('GET /entity', () => { const populate = await req(t.app, 'GET', '/entity', { token: TEST_TOKEN }); expect(populate.status).toBe(200); - await t.ctx.adapter.deleteRecord(first.id, { hard: true }); + await t.ctx.adapter.deleteRecord(first.id, { purge: true }); const afterDelete = await req(t.app, 'GET', '/entity', { token: TEST_TOKEN }); expect(afterDelete.status).toBe(404); @@ -134,7 +134,7 @@ describe('PATCH /entity', () => { it('returns 403 for a non-owner authenticated entity', async () => { await seedEntityRecord(t.ctx); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'PATCH', '/entity', { token, body: { content: { name: 'Hacked' } }, @@ -153,7 +153,7 @@ describe('PATCH /entity', () => { const record = await seedEntityRecordWithGeneratedId(t.ctx); await req(t.app, 'GET', '/entity', { token: TEST_TOKEN }); - await t.ctx.adapter.deleteRecord(record.id, { hard: true }); + await t.ctx.adapter.deleteRecord(record.id, { purge: true }); const stale = await req(t.app, 'PATCH', '/entity', { token: TEST_TOKEN, body: { content: { name: 'X' } }, diff --git a/tests/routes/groups.test.ts b/tests/routes/groups.test.ts index 342ad64..6001e6d 100644 --- a/tests/routes/groups.test.ts +++ b/tests/routes/groups.test.ts @@ -14,7 +14,11 @@ describe('_group ACL', () => { let t: TestApp; beforeEach(async () => { t = await buildTestApp(); - await t.ctx.stack.defineType(NOTE_TYPE, 'Note', { title: { kind: 'string' } }); + await t.ctx.stack.defineType({ + id: NOTE_TYPE, + name: 'Note', + schema: { title: { kind: 'string' } }, + }); }); afterEach(async () => { await t.cleanup(); @@ -25,7 +29,7 @@ describe('_group ACL', () => { await t.ctx.stack.associate(group.id, { kind: 'relationship', label: 'member', - target: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + target: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }); const record = await t.ctx.stack.create( NOTE_TYPE, @@ -35,12 +39,12 @@ describe('_group ACL', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'group', groupId: group.id, role: 'member' }, + grantee: { kind: 'group', groupId: group.id, role: 'member' }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'GET', `/records/${record.id}`, { token }); expect(status).toBe(200); }); @@ -58,7 +62,7 @@ describe('_group ACL', () => { { kind: 'relationship', label: 'member', - target: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + target: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, @@ -71,12 +75,12 @@ describe('_group ACL', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'group', groupId: notAGroup.id, role: 'member' }, + grantee: { kind: 'group', groupId: notAGroup.id, role: 'member' }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'GET', `/records/${record.id}`, { token }); // Anti-oracle: unreadable is 404, same as any other record this entity // can't reach — never a 403 that would confirm the record exists. diff --git a/tests/routes/queryScoping.test.ts b/tests/routes/queryScoping.test.ts index dbcaa12..98905fd 100644 --- a/tests/routes/queryScoping.test.ts +++ b/tests/routes/queryScoping.test.ts @@ -37,8 +37,12 @@ const THIRD_ENTITY_ID = 'did:key:third-entity-id-00000003'; let t: TestApp; beforeEach(async () => { t = await buildTestApp(); - await t.ctx.stack.defineType(NOTE_TYPE, 'Note', { - title: { kind: 'string', required: true }, + await t.ctx.stack.defineType({ + id: NOTE_TYPE, + name: 'Note', + schema: { + title: { kind: 'string', required: true }, + }, }); }); afterEach(async () => { @@ -47,7 +51,7 @@ afterEach(async () => { /** A token the auth middleware will resolve to an undelegated session for `did`. */ async function tokenFor(did: string): Promise { - const { token } = await t.ctx.tokens.createToken(did); + const { token } = await t.ctx.tokens.createToken({ subjectId: did }); return token; } @@ -79,17 +83,17 @@ async function seedNotes() { const owner = await t.ctx.stack.create( NOTE_TYPE, { title: 'owner' }, - { entityId: TEST_ENTITY_ID }, + { createdBy: { subjectId: TEST_ENTITY_ID } }, ); const other = await t.ctx.stack.create( NOTE_TYPE, { title: 'other' }, - { entityId: OTHER_ENTITY_ID }, + { createdBy: { subjectId: OTHER_ENTITY_ID } }, ); const third = await t.ctx.stack.create( NOTE_TYPE, { title: 'third' }, - { entityId: THIRD_ENTITY_ID }, + { createdBy: { subjectId: THIRD_ENTITY_ID } }, ); return { owner, other, third }; } @@ -97,9 +101,10 @@ async function seedNotes() { describe('query scoping across the worker boundary', () => { it('answers a read-own grantee with their own records and nobody else’s', async () => { const { owner, other, third } = await seedNotes(); - await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ - { typeId: NOTE_TYPE, actions: ['read-own'] }, - ]); + await t.ctx.stack.grantType(NOTE_TYPE, { + actions: ['read-own'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }); const token = await tokenFor(OTHER_ENTITY_ID); expect(await queryIds(token)).toEqual([other.id]); @@ -109,9 +114,10 @@ describe('query scoping across the worker boundary', () => { it('widens to every record of the type under read-any', async () => { const { owner, other, third } = await seedNotes(); - await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ - { typeId: NOTE_TYPE, actions: ['read-any'] }, - ]); + await t.ctx.stack.grantType(NOTE_TYPE, { + actions: ['read-any'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }); const token = await tokenFor(OTHER_ENTITY_ID); expect(await queryIds(token)).toEqual([owner.id, other.id, third.id].sort()); @@ -119,9 +125,10 @@ describe('query scoping across the worker boundary', () => { it('stops answering on the next query once the grant is withdrawn', async () => { const { owner } = await seedNotes(); - const [grantRecord] = await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ - { typeId: NOTE_TYPE, actions: ['read-any'] }, - ]); + const grantRecord = await t.ctx.stack.grantType(NOTE_TYPE, { + actions: ['read-any'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }); const token = await tokenFor(OTHER_ENTITY_ID); expect(await queryIds(token)).toContain(owner.id); @@ -133,9 +140,10 @@ describe('query scoping across the worker boundary', () => { it('confers again once the withdrawn grant is undeleted', async () => { const { owner } = await seedNotes(); - const [grantRecord] = await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ - { typeId: NOTE_TYPE, actions: ['read-any'] }, - ]); + const grantRecord = await t.ctx.stack.grantType(NOTE_TYPE, { + actions: ['read-any'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }); const token = await tokenFor(OTHER_ENTITY_ID); await req(t.app, 'DELETE', `/records/${grantRecord!.id}`, { token: TEST_TOKEN }); expect(await queryIds(token)).not.toContain(owner.id); @@ -154,19 +162,20 @@ describe('query scoping across the worker boundary', () => { { kind: 'relationship', label: 'admin', - target: { scope: 'entity', entityId: TEST_ENTITY_ID }, + target: { kind: 'entity', entityId: TEST_ENTITY_ID }, }, { kind: 'relationship', label: 'member', - target: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + target: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, ); - await t.ctx.stack.grant({ kind: 'group', groupId: group.id, role: 'member' }, [ - { typeId: NOTE_TYPE, actions: ['read-any'] }, - ]); + await t.ctx.stack.grantType(NOTE_TYPE, { + actions: ['read-any'], + grantee: { kind: 'group', groupId: group.id, role: 'member' }, + }); const token = await tokenFor(OTHER_ENTITY_ID); expect(await queryIds(token)).toContain(owner.id); @@ -187,19 +196,20 @@ describe('query scoping across the worker boundary', () => { { kind: 'relationship', label: 'admin', - target: { scope: 'entity', entityId: TEST_ENTITY_ID }, + target: { kind: 'entity', entityId: TEST_ENTITY_ID }, }, { kind: 'relationship', label: 'member', - target: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + target: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, ); - await t.ctx.stack.grant({ kind: 'group', groupId: group.id, role: 'member' }, [ - { typeId: NOTE_TYPE, actions: ['read-any'] }, - ]); + await t.ctx.stack.grantType(NOTE_TYPE, { + actions: ['read-any'], + grantee: { kind: 'group', groupId: group.id, role: 'member' }, + }); const token = await tokenFor(OTHER_ENTITY_ID); expect(await queryIds(token)).toContain(owner.id); @@ -208,7 +218,7 @@ describe('query scoping across the worker boundary', () => { body: { kind: 'relationship', label: 'member', - target: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + target: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, }); expect(await queryIds(token)).not.toContain(owner.id); @@ -216,9 +226,10 @@ describe('query scoping across the worker boundary', () => { it('never enumerates a _grant record for a grantee', async () => { await seedNotes(); - await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ - { typeId: NOTE_TYPE, actions: ['read-any'] }, - ]); + await t.ctx.stack.grantType(NOTE_TYPE, { + actions: ['read-any'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }); const token = await tokenFor(OTHER_ENTITY_ID); // A _grant carries no entityId and no permissions, so no grant on @@ -231,9 +242,10 @@ describe('query scoping across the worker boundary', () => { it('answers both query encodings with the same set', async () => { await seedNotes(); - await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ - { typeId: NOTE_TYPE, actions: ['read-own'] }, - ]); + await t.ctx.stack.grantType(NOTE_TYPE, { + actions: ['read-own'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }); const token = await tokenFor(OTHER_ENTITY_ID); // One scoping rule, two parsers: a content filter must not widen what @@ -253,9 +265,10 @@ describe('query scoping across the worker boundary', () => { await seedNotes(); // `{ kind: 'authenticated' }` is every entity that turned up with a DID, // which is never the anonymous view. - await t.ctx.stack.grant({ kind: 'authenticated' }, [ - { typeId: NOTE_TYPE, actions: ['read-any'] }, - ]); + await t.ctx.stack.grantType(NOTE_TYPE, { + actions: ['read-any'], + grantee: { kind: 'authenticated' }, + }); expect(await queryIds(undefined)).toEqual([]); expect((await queryIds(await tokenFor(THIRD_ENTITY_ID))).length).toBe(3); diff --git a/tests/routes/records.test.ts b/tests/routes/records.test.ts index 5e727c9..2fd3574 100644 --- a/tests/routes/records.test.ts +++ b/tests/routes/records.test.ts @@ -13,9 +13,13 @@ const NOTE_TYPE_ID = 'com.example.test/note@1'; const GRANT_TYPE_ID = `${SYSTEM_TYPES.GRANT}@1`; async function seedType(ctx: TestApp['ctx']) { - return ctx.stack.defineType(NOTE_TYPE_ID, 'Note', { - title: { kind: 'string' as const }, - body: { kind: 'text' as const, required: true as const }, + return ctx.stack.defineType({ + id: NOTE_TYPE_ID, + name: 'Note', + schema: { + title: { kind: 'string' as const }, + body: { kind: 'text' as const, required: true as const }, + }, }); } @@ -136,19 +140,19 @@ describe('Records', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'GET', `/records/${record.id}`, { token }); expect(status).toBe(200); }); it('entity without a grant gets 404, not 403, and no WWW-Authenticate (already authenticated)', async () => { const record = await seedRecord(t.ctx); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const res = await t.app.request(`/records/${record.id}`, { headers: { Authorization: `Bearer ${token}` }, }); @@ -218,7 +222,7 @@ describe('Records', () => { { kind: 'relationship', label: 'child', - target: { scope: 'record', recordId: target.id }, + target: { kind: 'record', recordId: target.id }, }, ], }, @@ -231,7 +235,7 @@ describe('Records', () => { { kind: 'relationship', label: 'sibling', - target: { scope: 'record', recordId: target.id }, + target: { kind: 'record', recordId: target.id }, }, ], }, @@ -265,7 +269,7 @@ describe('Records', () => { { kind: 'relationship', label: 'author', - target: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + target: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, @@ -293,7 +297,7 @@ describe('Records', () => { kind: 'relationship', label: 'syndicated-to', target: { - scope: 'external', + kind: 'external', ns: 'atproto', id: 'at://did:plc:abc/app.bsky.feed.post/1', }, @@ -309,7 +313,7 @@ describe('Records', () => { { kind: 'relationship', label: 'syndicated-to', - target: { scope: 'external', ns: 'activitypub', id: 'https://example.social/1' }, + target: { kind: 'external', ns: 'activitypub', id: 'https://example.social/1' }, }, ], }, @@ -342,7 +346,7 @@ describe('Records', () => { { kind: 'relationship', label: 'reply-to', - target: { scope: 'record', recordId: (await seedRecord(t.ctx)).id }, + target: { kind: 'record', recordId: (await seedRecord(t.ctx)).id }, }, ], }, @@ -355,7 +359,7 @@ describe('Records', () => { { kind: 'relationship', label: 'reply-to', - target: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + target: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, @@ -379,7 +383,7 @@ describe('Records', () => { { kind: 'relationship', label: 'ref', - target: { scope: 'record', recordId: target.id }, + target: { kind: 'record', recordId: target.id }, }, ], }, @@ -393,7 +397,7 @@ describe('Records', () => { kind: 'relationship', label: 'ref', target: { - scope: 'record', + kind: 'record', recordId: target.id, stackUrl: 'https://other.example/stack', }, @@ -537,12 +541,12 @@ describe('Records', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { token, body: { contentPatch: { body: 'hacked' } }, @@ -563,9 +567,9 @@ describe('Records', () => { expect(after?.deletedAt).toBeDefined(); }); - it('hard-deletes with ?hard=true (owner), answering with the record it destroyed', async () => { + it('purges with ?purge=true (owner), answering with the record it destroyed', async () => { const record = await seedRecord(t.ctx); - const { status, data } = await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, { + const { status, data } = await req(t.app, 'DELETE', `/records/${record.id}?purge=true`, { token: TEST_TOKEN, }); expect(status).toBe(200); @@ -574,11 +578,10 @@ describe('Records', () => { }); 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 uploaded = await t.ctx.stack.putAttachment(new Uint8Array([1, 2, 3]), { + mimeType: 'image/png', + filename: 'cover.png', + }); const record = await t.ctx.stack.create(NOTE_TYPE_ID, { body: 'with a cover' }); await t.ctx.stack.associate(record.id, { kind: 'attachment', @@ -589,7 +592,7 @@ describe('Records', () => { // 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`, { + const { status, data } = await req(t.app, 'DELETE', `/records/${record.id}?purge=true`, { token: TEST_TOKEN, }); expect(status).toBe(200); @@ -597,8 +600,8 @@ describe('Records', () => { 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', { + it('answers 404 for a purge of a record that is not there', async () => { + const { status } = await req(t.app, 'DELETE', '/records/1hk153x00099?purge=true', { token: TEST_TOKEN, }); expect(status).toBe(404); @@ -613,24 +616,24 @@ describe('Records', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, { kind: 'permission', label: 'write', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'DELETE', `/records/${record.id}`, { token }); expect(status).toBe(200); const after = await t.ctx.adapter.getRecord(record.id); expect(after?.deletedAt).toBeDefined(); }); - it('non-owner gets 403 on hard delete even with write access', async () => { + it('non-owner gets 403 on purge even with write access', async () => { const record = await t.ctx.stack.create( NOTE_TYPE_ID, { body: 'shared' }, @@ -639,18 +642,18 @@ describe('Records', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, { kind: 'permission', label: 'write', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); - const { status } = await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, { token }); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); + const { status } = await req(t.app, 'DELETE', `/records/${record.id}?purge=true`, { token }); expect(status).toBe(403); expect(await t.ctx.adapter.getRecord(record.id)).not.toBeNull(); }); @@ -692,11 +695,10 @@ describe('Records', () => { // record type) — putAttachment() itself returns the created record, so // no separate query is needed to find it. async function seedAttachment(ctx: TestApp['ctx']) { - const record = await ctx.stack.putAttachment( - new TextEncoder().encode('hello'), - 'text/plain', - 'hello.txt', - ); + const record = await ctx.stack.putAttachment(new TextEncoder().encode('hello'), { + mimeType: 'text/plain', + filename: 'hello.txt', + }); return { fileId: (record.content as { fileId: string }).fileId, record }; } @@ -764,20 +766,21 @@ describe('Records', () => { describe('_grant@1 write protection', () => { it('refuses to grant privileges on _grant/_config/_app (privilege-escalation guard)', async () => { - // stack.grant() forecloses the escalation at the source: a grant + // stack.grantType() forecloses the escalation at the source: a grant // targeting _grant cannot be handed out at all, so a non-owner has // nothing to escalate with. await expect( - t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ - { actions: ['update-own'], typeId: GRANT_TYPE_ID }, - ]), + t.ctx.stack.grantType(GRANT_TYPE_ID, { + actions: ['update-own'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }), ).rejects.toThrow(/privilege escalation/); }); it('returns 403 when non-owner tries to PATCH a grant record, even with direct write permission on it', async () => { // ScopedStack refuses writes to any _grant record unconditionally, // "whatever its own permissions say" — set an explicit write grant on - // this one record (bypassing stack.grant()'s own refusal by creating + // this one record (bypassing stack.grantType()'s own refusal by creating // it directly) to prove the fence holds regardless. const grantRecord = await t.ctx.stack.create( GRANT_TYPE_ID, @@ -791,17 +794,17 @@ describe('Records', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, { kind: 'permission', label: 'write', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'PATCH', `/records/${grantRecord.id}`, { token, @@ -811,7 +814,7 @@ describe('Records', () => { }); it('returns 403 when non-owner tries to soft-DELETE a grant record, even with direct write permission on it', async () => { - // stack.grant() doesn't give the grantee read access to the grant + // stack.grantType() doesn't give the grantee read access to the grant // record itself, only to what it grants — which 404s under the // disclosure rule (docs/spec/disclosure.md) rather than exercise the // write-protection fence this test targets. Set an explicit read+write @@ -831,17 +834,17 @@ describe('Records', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, { kind: 'permission', label: 'write', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'DELETE', `/records/${grantRecord.id}`, { token }); expect(status).toBe(403); @@ -849,9 +852,10 @@ describe('Records', () => { }); it('allows the owner to PATCH a grant record', async () => { - const [grantRecord] = await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ - { actions: ['read-own'], typeId: NOTE_TYPE_ID }, - ]); + const grantRecord = await t.ctx.stack.grantType(NOTE_TYPE_ID, { + actions: ['read-own'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }); const { status, data } = await req(t.app, 'PATCH', `/records/${grantRecord.id}`, { token: TEST_TOKEN, @@ -863,9 +867,10 @@ describe('Records', () => { }); it('allows the owner to soft-DELETE a grant record', async () => { - const [grantRecord] = await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ - { actions: ['read-own'], typeId: NOTE_TYPE_ID }, - ]); + const grantRecord = await t.ctx.stack.grantType(NOTE_TYPE_ID, { + actions: ['read-own'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }); const { status } = await req(t.app, 'DELETE', `/records/${grantRecord.id}`, { token: TEST_TOKEN, @@ -876,7 +881,7 @@ describe('Records', () => { }); it('blocks escalation: a write-holder on a grant record cannot expand its actions', async () => { - // stack.grant() refuses to grant update-own on _grant (see the first + // stack.grantType() refuses to grant update-own on _grant (see the first // test in this block), so the escalation vector this test guards // against is reached the only way open to it: a grant record with an // explicit record-level write permission on itself. @@ -892,17 +897,17 @@ describe('Records', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, { kind: 'permission', label: 'write', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'PATCH', `/records/${readGrant.id}`, { token, @@ -920,16 +925,16 @@ describe('Records', () => { // All Stack-level invariants (docs/spec.md § The _config record) — // inherited for free, so the server's job is only to test them. describe('_config protection', () => { - it('refuses to delete _config, soft or hard', async () => { + it('refuses to delete _config, soft or purge', async () => { const { status: soft } = await req(t.app, 'DELETE', '/records/_config', { token: TEST_TOKEN, }); expect(soft).toBe(409); - const { status: hard } = await req(t.app, 'DELETE', '/records/_config?hard=true', { + const { status: purged } = await req(t.app, 'DELETE', '/records/_config?purge=true', { token: TEST_TOKEN, }); - expect(hard).toBe(409); + expect(purged).toBe(409); }); it('refuses to change _config.entityId', async () => { @@ -962,7 +967,7 @@ describe('Records', () => { const APP_TYPE_ID = `${SYSTEM_TYPES.APP}@1`; it('refuses a non-owner create attempt (owner-only to set)', async () => { - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'POST', '/records', { token, body: { @@ -975,9 +980,10 @@ describe('Records', () => { it('refuses to grant create on _app (privilege-escalation guard, same as _grant/_config)', async () => { await expect( - t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ - { actions: ['create'], typeId: APP_TYPE_ID }, - ]), + t.ctx.stack.grantType(APP_TYPE_ID, { + actions: ['create'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }), ).rejects.toThrow(/privilege escalation/); }); @@ -1052,12 +1058,13 @@ describe('Records', () => { it('refuses a create-grant holder with no readable reference to the fileId', async () => { const bytes = new TextEncoder().encode('unreferenced'); - const record = await t.ctx.stack.putAttachment(bytes, 'text/plain'); + const record = await t.ctx.stack.putAttachment(bytes, { mimeType: 'text/plain' }); const fileId = (record.content as { fileId: string }).fileId; - 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); + await t.ctx.stack.grantType(ATTACHMENT_TYPE_ID, { + actions: ['create'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'POST', '/records', { token, @@ -1071,7 +1078,7 @@ describe('Records', () => { it('allows a create-grant holder who can already read a record referencing the fileId', async () => { const bytes = new TextEncoder().encode('referenced'); - const record = await t.ctx.stack.putAttachment(bytes, 'text/plain'); + const record = await t.ctx.stack.putAttachment(bytes, { mimeType: 'text/plain' }); const fileId = (record.content as { fileId: string }).fileId; await t.ctx.stack.create( NOTE_TYPE_ID, @@ -1081,16 +1088,17 @@ describe('Records', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], associations: [{ kind: 'attachment', label: 'file', fileId }], }, ); - 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); + await t.ctx.stack.grantType(ATTACHMENT_TYPE_ID, { + actions: ['create'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'POST', '/records', { token, @@ -1107,32 +1115,40 @@ describe('Records', () => { const APP_ID = 'app-entity-id-00000004'; const SUBJECT_ID = 'subject-entity-id-00000005'; - it('a delegated session stamps principalId and attributes entityId to the subject', async () => { + it('a delegated session stamps createdBy with the subject and the principal', async () => { // Delegated authority is the intersection of both parties' grants. - await t.ctx.stack.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 t.ctx.stack.grantType(NOTE_TYPE_ID, { + actions: ['create'], + grantee: { kind: 'entity', entityId: SUBJECT_ID }, + }); + await t.ctx.stack.grantType(NOTE_TYPE_ID, { + actions: ['create'], + grantee: { kind: 'entity', entityId: APP_ID }, + }); + const { token } = await t.ctx.adapter.createToken({ + subjectId: SUBJECT_ID, + principalId: APP_ID, + }); const { status, data } = await req(t.app, 'POST', '/records', { token, body: { typeId: NOTE_TYPE_ID, content: { body: 'delegated note' } }, }); expect(status).toBe(200); - const d = data as Record; - expect(d.entityId).toBe(SUBJECT_ID); - expect(d.principalId).toBe(APP_ID); + const d = data as { createdBy: { subjectId: string; principalId?: string } }; + expect(d.createdBy).toEqual({ subjectId: SUBJECT_ID, principalId: APP_ID }); }); it('a delegated create is refused when only one party holds the create grant', async () => { - await t.ctx.stack.grant({ kind: 'entity', entityId: SUBJECT_ID }, [ - { actions: ['create'], typeId: NOTE_TYPE_ID }, - ]); + await t.ctx.stack.grantType(NOTE_TYPE_ID, { + actions: ['create'], + grantee: { kind: 'entity', entityId: SUBJECT_ID }, + }); // APP_ID (the principal) has no grant of its own. - const { token } = await t.ctx.adapter.createToken(APP_ID, { onBehalfOf: SUBJECT_ID }); + const { token } = await t.ctx.adapter.createToken({ + subjectId: SUBJECT_ID, + principalId: APP_ID, + }); const { status } = await req(t.app, 'POST', '/records', { token, @@ -1141,14 +1157,19 @@ describe('Records', () => { expect(status).toBe(403); }); - it('filters by ?principalId=', async () => { - 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 }); + it('filters by ?createdByPrincipal=', async () => { + await t.ctx.stack.grantType(NOTE_TYPE_ID, { + actions: ['create'], + grantee: { kind: 'entity', entityId: SUBJECT_ID }, + }); + await t.ctx.stack.grantType(NOTE_TYPE_ID, { + actions: ['create'], + grantee: { kind: 'entity', entityId: APP_ID }, + }); + const { token } = await t.ctx.adapter.createToken({ + subjectId: SUBJECT_ID, + principalId: APP_ID, + }); await req(t.app, 'POST', '/records', { token, body: { typeId: NOTE_TYPE_ID, content: { body: 'delegated note' } }, @@ -1158,12 +1179,12 @@ describe('Records', () => { const { data } = await req( t.app, 'GET', - `/records?principalId=${encodeURIComponent(APP_ID)}`, + `/records?createdByPrincipal=${encodeURIComponent(APP_ID)}`, { token: TEST_TOKEN }, ); - const records = (data as { records: Array<{ principalId?: string }> }).records; + const records = (data as { records: Array<{ createdBy: { principalId?: string } }> }).records; expect(records).toHaveLength(1); - expect(records[0].principalId).toBe(APP_ID); + expect(records[0].createdBy.principalId).toBe(APP_ID); }); }); @@ -1301,10 +1322,11 @@ describe('Records', () => { }); it('drops createdAt/updatedAt from a grantee create instead of forwarding them into a 403', async () => { - 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); + await t.ctx.stack.grantType(NOTE_TYPE_ID, { + actions: ['create'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const createdAt = new Date('2020-01-01T00:00:00.000Z'); const before = Date.now(); @@ -1319,15 +1341,18 @@ describe('Records', () => { }); it('drops createdAt/updatedAt from a delegated session with the owner as principal', async () => { - const { token } = await t.ctx.adapter.createToken(TEST_ENTITY_ID, { - onBehalfOf: OTHER_ENTITY_ID, + const { token } = await t.ctx.adapter.createToken({ + subjectId: OTHER_ENTITY_ID, + principalId: TEST_ENTITY_ID, + }); + await t.ctx.stack.grantType(NOTE_TYPE_ID, { + actions: ['create'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }); + await t.ctx.stack.grantType(NOTE_TYPE_ID, { + actions: ['create'], + grantee: { kind: 'entity', entityId: TEST_ENTITY_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(); @@ -1379,10 +1404,11 @@ describe('Records', () => { }); it('a non-owner grantee with a plain create grant may create unlisted — not owner-only', async () => { - 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); + await t.ctx.stack.grantType(NOTE_TYPE_ID, { + actions: ['create'], + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, + }); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status, data } = await req(t.app, 'POST', '/records', { token, body: { @@ -1402,13 +1428,18 @@ 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({ 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 }); + await t.ctx.stack.grantType(NOTE_TYPE_ID, { + actions: ['create'], + grantee: { kind: 'entity', entityId: subjectId }, + }); + await t.ctx.stack.grantType(NOTE_TYPE_ID, { + actions: ['create'], + grantee: { kind: 'entity', entityId: appId }, + }); + const { token } = await t.ctx.adapter.createToken({ + subjectId: subjectId, + principalId: appId, + }); const { status, data } = await req(t.app, 'POST', '/records', { token, @@ -1494,13 +1525,13 @@ describe('Records', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, ); await t.ctx.stack.delete(record.id); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'POST', `/records/${record.id}/undelete`, { token }); expect(status).toBe(403); @@ -1526,7 +1557,7 @@ describe('Records', () => { const body = (await res.json()) as { permissions: unknown[] }; expect(body.permissions).toEqual([{ kind: 'anyone', label: 'read' }]); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status: anonStatus } = await req(t.app, 'GET', `/records/${record.id}`); expect(anonStatus).toBe(200); const { status: otherStatus } = await req(t.app, 'GET', `/records/${record.id}`, { token }); @@ -1575,12 +1606,12 @@ describe('Records', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { token, body: { permissions: [{ kind: 'anyone', label: 'read' }] }, @@ -1595,9 +1626,9 @@ describe('Records', () => { const record = await t.ctx.stack.create( NOTE_TYPE_ID, { body: 'x' }, - { entityId: OTHER_ENTITY_ID }, + { createdBy: { subjectId: OTHER_ENTITY_ID } }, ); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token, body: { permissions: [] }, @@ -1742,12 +1773,12 @@ describe('Records', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { token, body: { unlisted: true }, @@ -1827,7 +1858,7 @@ describe('Records', () => { }); it('is refused with 403 for a non-owner, on both query endpoints', async () => { - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const getRes = await req(t.app, 'GET', '/records?includeUnlisted=true', { token }); expect(getRes.status).toBe(403); @@ -2046,15 +2077,15 @@ describe('Records', () => { const NOTE_V2_TYPE_ID = 'com.example.test/note@2'; async function seedV2Type(ctx: TestApp['ctx']) { - return ctx.stack.defineType( - NOTE_V2_TYPE_ID, - 'Note', - { + return ctx.stack.defineType({ + id: NOTE_V2_TYPE_ID, + name: 'Note', + schema: { title: { kind: 'string' as const }, body: { kind: 'text' as const, required: true as const }, }, - { migratesFrom: NOTE_TYPE_ID }, - ); + migratesFrom: NOTE_TYPE_ID, + }); } it('changes typeId, bumps version, and writes the given content (owner)', async () => { @@ -2082,17 +2113,17 @@ describe('Records', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, { kind: 'permission', label: 'write', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, ); - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'POST', `/records/${record.id}/migrate`, { token, diff --git a/tests/routes/relatedToQuery.test.ts b/tests/routes/relatedToQuery.test.ts index c0353ac..66d2a64 100644 --- a/tests/routes/relatedToQuery.test.ts +++ b/tests/routes/relatedToQuery.test.ts @@ -10,8 +10,12 @@ import { buildTestApp, req, TEST_TOKEN, OTHER_ENTITY_ID, type TestApp } from '.. const NOTE_TYPE_ID = 'com.example.test/note@1'; async function seedType(ctx: TestApp['ctx']) { - return ctx.stack.defineType(NOTE_TYPE_ID, 'Note', { - body: { kind: 'text' as const, required: true as const }, + return ctx.stack.defineType({ + id: NOTE_TYPE_ID, + name: 'Note', + schema: { + body: { kind: 'text' as const, required: true as const }, + }, }); } @@ -39,7 +43,7 @@ describe('POST /records/query filter.relatedTo', () => { { kind: 'relationship', label: 'child', - target: { scope: 'record', recordId: target.id }, + target: { kind: 'record', recordId: target.id }, }, ], }, @@ -47,7 +51,7 @@ describe('POST /records/query filter.relatedTo', () => { const { status, data } = await req(t.app, 'POST', '/records/query', { token: TEST_TOKEN, - body: { filter: { relatedTo: { target: { scope: 'record', recordId: target.id } } } }, + body: { filter: { relatedTo: { target: { kind: 'record', recordId: target.id } } } }, }); expect(status).toBe(200); expect((data as { records: Array<{ id: string }> }).records.map((r) => r.id)).toEqual([ @@ -64,7 +68,7 @@ describe('POST /records/query filter.relatedTo', () => { { kind: 'relationship', label: 'author', - target: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + target: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, @@ -73,7 +77,7 @@ describe('POST /records/query filter.relatedTo', () => { const { status, data } = await req(t.app, 'POST', '/records/query', { token: TEST_TOKEN, body: { - filter: { relatedTo: { target: { scope: 'entity', entityId: OTHER_ENTITY_ID } } }, + filter: { relatedTo: { target: { kind: 'entity', entityId: OTHER_ENTITY_ID } } }, }, }); expect(status).toBe(200); @@ -92,7 +96,7 @@ describe('POST /records/query filter.relatedTo', () => { kind: 'relationship', label: 'syndicated-to', target: { - scope: 'external', + kind: 'external', ns: 'atproto', id: 'at://did:plc:abc/app.bsky.feed.post/1', }, @@ -103,7 +107,7 @@ describe('POST /records/query filter.relatedTo', () => { const { status, data } = await req(t.app, 'POST', '/records/query', { token: TEST_TOKEN, - body: { filter: { relatedTo: { target: { scope: 'external', ns: 'atproto' } } } }, + body: { filter: { relatedTo: { target: { kind: 'external', ns: 'atproto' } } } }, }); expect(status).toBe(200); expect((data as { records: Array<{ id: string }> }).records.map((r) => r.id)).toEqual([ @@ -121,7 +125,7 @@ describe('POST /records/query filter.relatedTo', () => { { kind: 'relationship', label: 'reply-to', - target: { scope: 'record', recordId: target.id }, + target: { kind: 'record', recordId: target.id }, }, ], }, @@ -153,7 +157,7 @@ describe('POST /records/query filter.relatedTo', () => { it('rejects a record target with an empty recordId with 400', async () => { const { status, data } = await req(t.app, 'POST', '/records/query', { token: TEST_TOKEN, - body: { filter: { relatedTo: { target: { scope: 'record', recordId: '' } } } }, + body: { filter: { relatedTo: { target: { kind: 'record', recordId: '' } } } }, }); expect(status).toBe(400); expect((data as { error: { code: string } }).error.code).toBe('bad_request'); @@ -164,7 +168,7 @@ describe('POST /records/query filter.relatedTo', () => { const { status, data } = await req(t.app, 'POST', '/records/query', { token: TEST_TOKEN, body: { - filter: { relatedTo: { target: { scope: 'record', recordId: target.id, stackUrl: '' } } }, + filter: { relatedTo: { target: { kind: 'record', recordId: target.id, stackUrl: '' } } }, }, }); expect(status).toBe(400); @@ -174,7 +178,7 @@ describe('POST /records/query filter.relatedTo', () => { it('rejects an external target with an empty id with 400', async () => { const { status, data } = await req(t.app, 'POST', '/records/query', { token: TEST_TOKEN, - body: { filter: { relatedTo: { target: { scope: 'external', ns: 'atproto', id: '' } } } }, + body: { filter: { relatedTo: { target: { kind: 'external', ns: 'atproto', id: '' } } } }, }); expect(status).toBe(400); expect((data as { error: { code: string } }).error.code).toBe('bad_request'); diff --git a/tests/routes/tokens.test.ts b/tests/routes/tokens.test.ts index 984d6f5..c9f8b85 100644 --- a/tests/routes/tokens.test.ts +++ b/tests/routes/tokens.test.ts @@ -13,7 +13,7 @@ describe('POST /tokens', () => { it('owner can create a token and receives the token value', async () => { const { status, data } = await req(t.app, 'POST', '/tokens', { token: TEST_TOKEN, - body: { entityId: OTHER_ENTITY_ID, label: 'test-label' }, + body: { principalId: OTHER_ENTITY_ID, label: 'test-label' }, }); expect(status).toBe(201); const d = data as Record; @@ -24,11 +24,11 @@ describe('POST /tokens', () => { expect(d.label).toBe('test-label'); }); - it('owner can assert a delegation via onBehalfOf', async () => { + it('owner can assert a delegation via subjectId', async () => { const subjectId = 'did:key:subject-entity-id-00000003'; const { status, data } = await req(t.app, 'POST', '/tokens', { token: TEST_TOKEN, - body: { entityId: OTHER_ENTITY_ID, onBehalfOf: subjectId }, + body: { principalId: OTHER_ENTITY_ID, subjectId }, }); expect(status).toBe(201); const d = data as Record; @@ -40,7 +40,7 @@ describe('POST /tokens', () => { }); it('returns 403 for a non-owner authenticated entity', async () => { - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'POST', '/tokens', { token, body: {}, @@ -53,32 +53,32 @@ describe('POST /tokens', () => { expect(status).toBe(401); }); - it('rejects a non-DID entityId with 422', async () => { + it('rejects a non-DID principalId with 422', async () => { const { status, data } = await req(t.app, 'POST', '/tokens', { token: TEST_TOKEN, - body: { entityId: 'not-a-did' }, + body: { principalId: 'not-a-did' }, }); expect(status).toBe(422); const body = data as { error: { code: string; details: Array<{ path: string }> } }; expect(body.error.code).toBe('validation'); - expect(body.error.details.some((d) => d.path === 'entityId')).toBe(true); + expect(body.error.details.some((d) => d.path === 'principalId')).toBe(true); }); - it('rejects a non-DID onBehalfOf with 422', async () => { + it('rejects a non-DID subjectId with 422', async () => { const { status, data } = await req(t.app, 'POST', '/tokens', { token: TEST_TOKEN, - body: { entityId: OTHER_ENTITY_ID, onBehalfOf: 'not-a-did' }, + body: { principalId: OTHER_ENTITY_ID, subjectId: 'not-a-did' }, }); expect(status).toBe(422); const body = data as { error: { code: string; details: Array<{ path: string }> } }; expect(body.error.code).toBe('validation'); - expect(body.error.details.some((d) => d.path === 'onBehalfOf')).toBe(true); + expect(body.error.details.some((d) => d.path === 'subjectId')).toBe(true); }); it('reports the stored createdAt, matching what GET /tokens later reports', async () => { const { data } = await req(t.app, 'POST', '/tokens', { token: TEST_TOKEN, - body: { entityId: OTHER_ENTITY_ID }, + body: { principalId: OTHER_ENTITY_ID }, }); const created = data as { id: string; createdAt: string }; @@ -100,8 +100,8 @@ describe('GET /tokens', () => { }); it('owner can list tokens without token values exposed', async () => { - await t.ctx.adapter.createToken(OTHER_ENTITY_ID, { label: 'tok-a' }); - await t.ctx.adapter.createToken(OTHER_ENTITY_ID, { label: 'tok-b' }); + await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }, { label: 'tok-a' }); + await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }, { label: 'tok-b' }); const { status, data } = await req(t.app, 'GET', '/tokens', { token: TEST_TOKEN }); expect(status).toBe(200); @@ -114,7 +114,7 @@ describe('GET /tokens', () => { }); it('returns 403 for a non-owner authenticated entity', async () => { - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'GET', '/tokens', { token }); expect(status).toBe(403); }); @@ -135,7 +135,7 @@ describe('DELETE /tokens/:id', () => { }); it('owner can revoke a token', async () => { - const { id } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { id } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'DELETE', `/tokens/${id}`, { token: TEST_TOKEN }); expect(status).toBe(204); @@ -144,13 +144,15 @@ describe('DELETE /tokens/:id', () => { }); it('returns 403 for a non-owner authenticated entity', async () => { - const { id, token: otherToken } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { id, token: otherToken } = await t.ctx.adapter.createToken({ + subjectId: OTHER_ENTITY_ID, + }); const { status } = await req(t.app, 'DELETE', `/tokens/${id}`, { token: otherToken }); expect(status).toBe(403); }); it('returns 401 for an unauthenticated request', async () => { - const { id } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { id } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'DELETE', `/tokens/${id}`); expect(status).toBe(401); }); diff --git a/tests/routes/tombstones.test.ts b/tests/routes/tombstones.test.ts index 0ad2ef0..c8bcbde 100644 --- a/tests/routes/tombstones.test.ts +++ b/tests/routes/tombstones.test.ts @@ -10,9 +10,13 @@ import { openChangeFeed } from '../changeFeedClient.js'; const NOTE_TYPE = 'com.example.test/note@1'; async function seedNoteType(ctx: TestApp['ctx']) { - await ctx.stack.defineType(NOTE_TYPE, 'Note', { - title: { kind: 'string' }, - body: { kind: 'text' }, + await ctx.stack.defineType({ + id: NOTE_TYPE, + name: 'Note', + schema: { + title: { kind: 'string' }, + body: { kind: 'text' }, + }, }); } diff --git a/tests/routes/types.test.ts b/tests/routes/types.test.ts index e945d28..bdb0126 100644 --- a/tests/routes/types.test.ts +++ b/tests/routes/types.test.ts @@ -41,7 +41,7 @@ describe('Types', () => { }); it('returns 403 for a non-owner entity', async () => { - const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); const { status } = await req(t.app, 'POST', '/types', { token, body: { id: typeId, baseId: 'x', version: 1, name: 'x', schema: {}, schemaHash: 'x' }, @@ -106,7 +106,7 @@ describe('Types', () => { describe('GET /types', () => { it('returns all registered types (no auth required)', async () => { - await t.ctx.stack.defineType(typeId, 'Item', schema); + await t.ctx.stack.defineType({ id: typeId, name: 'Item', schema: schema }); const { status, data } = await req(t.app, 'GET', '/types'); expect(status).toBe(200); expect((data as unknown[]).length).toBeGreaterThanOrEqual(1); @@ -115,7 +115,7 @@ describe('Types', () => { describe('GET /types/:id', () => { it('returns one type (URL-encoded, no auth required)', async () => { - await t.ctx.stack.defineType(typeId, 'Item', schema); + await t.ctx.stack.defineType({ id: typeId, name: 'Item', schema: schema }); const { status, data } = await req(t.app, 'GET', `/types/${encodeURIComponent(typeId)}`); expect(status).toBe(200); expect((data as Record).id).toBe(typeId); diff --git a/tests/routes/versions.test.ts b/tests/routes/versions.test.ts index b4a1bee..ba23e94 100644 --- a/tests/routes/versions.test.ts +++ b/tests/routes/versions.test.ts @@ -7,8 +7,12 @@ describe('Versions', () => { let t: TestApp; beforeEach(async () => { t = await buildTestApp(); - await t.ctx.stack.defineType(TYPE_ID, 'Doc', { - body: { kind: 'text' as const, required: true as const }, + await t.ctx.stack.defineType({ + id: TYPE_ID, + name: 'Doc', + schema: { + body: { kind: 'text' as const, required: true as const }, + }, }); }); afterEach(async () => { @@ -111,25 +115,25 @@ describe('Versions', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, { kind: 'permission', label: 'write', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ] : [ { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: '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 { token } = await t.ctx.adapter.createToken({ subjectId: OTHER_ENTITY_ID }); return { record, token }; } @@ -167,7 +171,7 @@ describe('Versions', () => { { kind: 'permission', label: 'read', - grantee: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + grantee: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, From 556ceb769639c86f8617dfed204efe536779ad04 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:08:40 +0000 Subject: [PATCH 2/5] fix: refuse renamed wire names instead of ignoring them A stale client's old names were silently dropped, widening its request: POST /tokens with `entityId` minted an owner token, `?hard=true` did a soft delete, and old filter names returned unfiltered results. Each now answers 400 naming the replacement. Also key an empty `createdBy` change filter the same as none. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018rAh3A4jeexocb6UcZRAwa --- docs/api.md | 2 ++ src/lib/renamed.ts | 39 +++++++++++++++++++++++++++++++++ src/lib/resumeBuffer.ts | 11 ++++++---- src/routes/changes.ts | 2 ++ src/routes/records.ts | 18 ++++++++++++--- src/routes/tokens.ts | 4 ++++ tests/lib/resumeBuffer.test.ts | 12 ++++++++++ tests/routes/renamed.test.ts | 40 ++++++++++++++++++++++++++++++++++ 8 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 src/lib/renamed.ts create mode 100644 tests/routes/renamed.test.ts diff --git a/docs/api.md b/docs/api.md index f81fbbb..0f0cb28 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2,6 +2,8 @@ All routes are prefixed by the base URL. Requests are authenticated with a `Bearer` token in the `Authorization` header. +Names renamed in 0.11.0 are refused with `400`, naming the replacement, rather than ignored: `?hard=` (now `?purge=`), `?sinceSeq=` (now `?afterSeq=`), `GET /records`' `?entityId=` / `?principalId=` / `?hasAttachment=` (now `?createdBySubject=` / `?createdByPrincipal=` / `?attachmentLabel=`), `GET /changes`' `?entityId=` (now `?createdBySubject=`), and `POST /tokens`' `entityId` / `onBehalfOf` (now `principalId` / `subjectId`). + ## Discovery | Method | Path | Auth | Description | diff --git a/src/lib/renamed.ts b/src/lib/renamed.ts new file mode 100644 index 0000000..4464f5a --- /dev/null +++ b/src/lib/renamed.ts @@ -0,0 +1,39 @@ +import { StackBadRequestError } from '@haverstack/core'; + +/** + * Wire names core 0.38 renamed, refused rather than ignored. Unknown names + * are otherwise dropped silently, which turns a stale client's request into + * a different, wider one: `?hard=true` becomes a soft delete, a token body's + * `entityId` falls back to minting an owner token, a feed or query filter + * quietly disappears. A 400 naming the replacement makes the break visible. + */ +export const RENAMED_RECORD_QUERY_PARAMS = { + entityId: 'createdBySubject', + principalId: 'createdByPrincipal', + hasAttachment: 'attachmentLabel', +} as const; +export const RENAMED_CHANGE_PARAMS = { entityId: 'createdBySubject' } as const; +export const RENAMED_JOURNAL_PARAMS = { sinceSeq: 'afterSeq' } as const; +export const RENAMED_DELETE_PARAMS = { hard: 'purge' } as const; +export const RENAMED_TOKEN_FIELDS = { entityId: 'principalId', onBehalfOf: 'subjectId' } as const; + +/** Throws if `url` carries any query param named in `renamed`. */ +export function rejectRenamedParams(url: URL, renamed: Readonly>): void { + for (const [old, current] of Object.entries(renamed)) { + if (url.searchParams.has(old)) { + throw new StackBadRequestError(`Query param "${old}" was renamed to "${current}"`); + } + } +} + +/** Throws if `body` carries any field named in `renamed`. */ +export function rejectRenamedFields( + body: Record, + renamed: Readonly>, +): void { + for (const [old, current] of Object.entries(renamed)) { + if (Object.hasOwn(body, old)) { + throw new StackBadRequestError(`Field "${old}" was renamed to "${current}"`); + } + } +} diff --git a/src/lib/resumeBuffer.ts b/src/lib/resumeBuffer.ts index fce5a6f..45f7ae9 100644 --- a/src/lib/resumeBuffer.ts +++ b/src/lib/resumeBuffer.ts @@ -133,10 +133,13 @@ export function resumeBufferKey(parts: ResumeBufferKeyParts): string { const { filter } = parts; const typeId = asSortedSet(filter.typeId); const baseId = asSortedSet(filter.baseId); - const createdBy = filter.createdBy && { - subjectId: asSortedSet(filter.createdBy.subjectId), - principalId: asSortedSet(filter.createdBy.principalId), - }; + const createdBySubject = asSortedSet(filter.createdBy?.subjectId); + const createdByPrincipal = asSortedSet(filter.createdBy?.principalId); + // `createdBy: {}` constrains nothing, so it keys the same as no createdBy. + const createdBy = + createdBySubject || createdByPrincipal + ? { subjectId: createdBySubject, principalId: createdByPrincipal } + : undefined; const kinds = asSortedSet(filter.kinds); return JSON.stringify({ principalId: parts.principalId, diff --git a/src/routes/changes.ts b/src/routes/changes.ts index d54587c..74a49b3 100644 --- a/src/routes/changes.ts +++ b/src/routes/changes.ts @@ -12,6 +12,7 @@ import { serializeChange, isValidCursor } from '@haverstack/wire-types'; import type { ChangeResetReason } from '@haverstack/wire-types'; import type { Logger } from 'pino'; import { safeCompare, isOwnerActingAlone } from '../middleware/auth.js'; +import { rejectRenamedParams, RENAMED_CHANGE_PARAMS } from '../lib/renamed.js'; import { FrameGate } from '../lib/frameGate.js'; import { decodeCursor } from '../lib/resumeCursor.js'; import { ResumeBufferRegistry, resumeBufferKey, type ResumeEntry } from '../lib/resumeBuffer.js'; @@ -88,6 +89,7 @@ export function changeRoutes( app.get('/', async (c) => { const url = new URL(c.req.url); const auth = c.get('auth'); + rejectRenamedParams(url, RENAMED_CHANGE_PARAMS); const { filter, includeRecords, includeUnlisted } = parseChangeParams(url); const presentedRaw = presentedCursorRaw(c, url); diff --git a/src/routes/records.ts b/src/routes/records.ts index d3be309..2ebc250 100644 --- a/src/routes/records.ts +++ b/src/routes/records.ts @@ -5,6 +5,12 @@ import type { ScopedStack } from '@haverstack/core'; import type { TokenSession } from '@haverstack/core/wire'; import { requireAuth, requireOwner } from '../middleware/auth.js'; import { readJson } from '../lib/json.js'; +import { + rejectRenamedParams, + RENAMED_RECORD_QUERY_PARAMS, + RENAMED_JOURNAL_PARAMS, + RENAMED_DELETE_PARAMS, +} from '../lib/renamed.js'; import { parseQueryBody, parseQueryParams, @@ -59,7 +65,9 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { const auth = c.get('auth'); - const query = clampLimit(parseQueryParams(new URL(c.req.url))); + const url = new URL(c.req.url); + rejectRenamedParams(url, RENAMED_RECORD_QUERY_PARAMS); + const query = clampLimit(parseQueryParams(url)); const result = await queryWorker.query(auth, query, queryTimeoutMs); const body: WireQueryResponse = { records: result.records.map(serializeRecord), @@ -133,7 +141,9 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { const id = c.req.param('id'); const auth = c.get('auth')!; - const purge = new URL(c.req.url).searchParams.get('purge') === 'true'; + const url = new URL(c.req.url); + rejectRenamedParams(url, RENAMED_DELETE_PARAMS); + const purge = url.searchParams.get('purge') === 'true'; const session = stack.asActor(auth); const { record } = await session.deleteAndReturn(id, { @@ -251,7 +261,9 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { const id = c.req.param('id'); const auth = c.get('auth'); - const query = parseJournalParams(new URL(c.req.url)); + const url = new URL(c.req.url); + rejectRenamedParams(url, RENAMED_JOURNAL_PARAMS); + const query = parseJournalParams(url); const limit = clampJournalLimit(query.limit); const entries = await scopeFor(auth).getJournal(id, { ...query, limit }); diff --git a/src/routes/tokens.ts b/src/routes/tokens.ts index a3ff200..286edff 100644 --- a/src/routes/tokens.ts +++ b/src/routes/tokens.ts @@ -3,6 +3,7 @@ import type { AppEnv } from '../types.js'; import type { StackContext } from '../stack.js'; import { requireOwner } from '../middleware/auth.js'; import { readJson } from '../lib/json.js'; +import { rejectRenamedFields, RENAMED_TOKEN_FIELDS } from '../lib/renamed.js'; import { parseDate } from '@haverstack/wire-types'; import { StackValidationError } from '@haverstack/core'; import { isValidDid } from '@haverstack/core/did'; @@ -25,6 +26,9 @@ export function tokenRoutes(ctx: StackContext): Hono { label?: string; expiresAt?: string; }>(c); + // A stale client's `entityId` would otherwise be ignored and the token + // minted for the owner — a full owner token handed to whoever it was for. + rejectRenamedFields(body, RENAMED_TOKEN_FIELDS); if (body.principalId !== undefined && !isValidDid(body.principalId)) throw new StackValidationError([{ path: 'principalId', message: 'Must be a DID' }]); if (body.subjectId !== undefined && !isValidDid(body.subjectId)) diff --git a/tests/lib/resumeBuffer.test.ts b/tests/lib/resumeBuffer.test.ts index d4b8509..5b84978 100644 --- a/tests/lib/resumeBuffer.test.ts +++ b/tests/lib/resumeBuffer.test.ts @@ -168,6 +168,18 @@ describe('resumeBufferKey', () => { ); }); + it('keys an empty createdBy the same as no createdBy', () => { + const base = { + principalId: null, + subjectId: null, + includeRecords: false, + includeUnlisted: false, + }; + expect(resumeBufferKey({ ...base, filter: { createdBy: {} } })).toBe( + resumeBufferKey({ ...base, filter: {} }), + ); + }); + it("does not let one field's value imitate another's", () => { const base = { principalId: null, diff --git a/tests/routes/renamed.test.ts b/tests/routes/renamed.test.ts new file mode 100644 index 0000000..3fe6133 --- /dev/null +++ b/tests/routes/renamed.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { buildTestApp, req, TEST_TOKEN, OTHER_ENTITY_ID, type TestApp } from '../setup.js'; + +// Names core 0.38 renamed are refused with a 400 naming the replacement, +// never ignored — ignoring them turns a stale client's request into a +// different, wider one. See src/lib/renamed.ts. +describe('renamed wire names', () => { + let t: TestApp; + beforeEach(async () => { + t = await buildTestApp(); + }); + afterEach(async () => { + await t.cleanup(); + }); + + it.each([ + ['entityId', 'principalId'], + ['onBehalfOf', 'subjectId'], + ])('POST /tokens refuses %s rather than minting an owner token', async (old, current) => { + const { status, data } = await req(t.app, 'POST', '/tokens', { + token: TEST_TOKEN, + body: { [old]: OTHER_ENTITY_ID }, + }); + expect(status).toBe(400); + expect(JSON.stringify(data)).toContain(current); + }); + + it.each([ + ['GET', `/records?entityId=${OTHER_ENTITY_ID}`, 'createdBySubject'], + ['GET', `/records?principalId=${OTHER_ENTITY_ID}`, 'createdByPrincipal'], + ['GET', '/records?hasAttachment=photo', 'attachmentLabel'], + ['GET', '/records/1hk153x00001/journal?sinceSeq=0', 'afterSeq'], + ['DELETE', '/records/1hk153x00001?hard=true', 'purge'], + ['GET', `/changes?entityId=${OTHER_ENTITY_ID}`, 'createdBySubject'], + ] as const)('%s %s is refused, naming %s', async (method, path, current) => { + const { status, data } = await req(t.app, method, path, { token: TEST_TOKEN }); + expect(status).toBe(400); + expect(JSON.stringify(data)).toContain(current); + }); +}); From 253c1c9328075296c77d370458858103bd92820f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:18:41 +0000 Subject: [PATCH 3/5] fix: refuse unrecognized query params and body keys Replaces the list of renamed names with a general rule: every route the server parses itself refuses a query param or body key it doesn't define (400), boolean params take only true/false, and body values of the wrong type are refused rather than dropped. Routes whose input core's wire parsers read are left to those parsers. Keeps the empty-createdBy resume buffer normalization. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018rAh3A4jeexocb6UcZRAwa --- docs/api.md | 2 +- src/lib/json.ts | 21 ++++++++++- src/lib/renamed.ts | 39 ------------------- src/middleware/params.ts | 32 ++++++++++++++++ src/routes/attachments.ts | 32 ++++++++++++---- src/routes/auth.ts | 9 +++-- src/routes/changes.ts | 2 - src/routes/entity.ts | 14 ++++--- src/routes/health.ts | 3 +- src/routes/records.ts | 53 +++++++++++--------------- src/routes/tokens.ts | 24 +++++++----- src/routes/types.ts | 25 +++++++++--- src/routes/wellknown.ts | 3 +- tests/routes/auth.test.ts | 17 ++------- tests/routes/renamed.test.ts | 40 -------------------- tests/routes/strictInput.test.ts | 65 ++++++++++++++++++++++++++++++++ 16 files changed, 219 insertions(+), 162 deletions(-) delete mode 100644 src/lib/renamed.ts create mode 100644 src/middleware/params.ts delete mode 100644 tests/routes/renamed.test.ts create mode 100644 tests/routes/strictInput.test.ts diff --git a/docs/api.md b/docs/api.md index 0f0cb28..a0b1f0e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2,7 +2,7 @@ All routes are prefixed by the base URL. Requests are authenticated with a `Bearer` token in the `Authorization` header. -Names renamed in 0.11.0 are refused with `400`, naming the replacement, rather than ignored: `?hard=` (now `?purge=`), `?sinceSeq=` (now `?afterSeq=`), `GET /records`' `?entityId=` / `?principalId=` / `?hasAttachment=` (now `?createdBySubject=` / `?createdByPrincipal=` / `?attachmentLabel=`), `GET /changes`' `?entityId=` (now `?createdBySubject=`), and `POST /tokens`' `entityId` / `onBehalfOf` (now `principalId` / `subjectId`). +A query param or JSON body key an endpoint doesn't define is refused with `400` rather than ignored, and a boolean param takes only `true` or `false`. ## Discovery diff --git a/src/lib/json.ts b/src/lib/json.ts index 2e73042..aefabfb 100644 --- a/src/lib/json.ts +++ b/src/lib/json.ts @@ -8,7 +8,10 @@ import type { AppEnv } from '../types.js'; * catch-all as an unlabeled 500 instead of the 400 `bad_request` every other * structurally-invalid request gets (docs/spec/wire-format.md § Error responses). */ -export async function readJson(c: Context): Promise { +export async function readJson( + c: Context, + keys?: readonly string[], +): Promise { let parsed: unknown; try { parsed = await c.req.json(); @@ -23,5 +26,21 @@ export async function readJson(c: Context): Promise { if (parsed === null || typeof parsed !== 'object') { throw new StackBadRequestError('Request body must be a JSON object'); } + if (keys) rejectUnknownKeys(parsed as Record, keys); return parsed as T; } + +/** + * A key the endpoint doesn't define is refused rather than ignored: an + * ignored field turns a mistaken request into a different one that + * succeeds, and the caller never learns it asked for something else. + */ +export function rejectUnknownKeys(body: Record, keys: readonly string[]): void { + const unknown = Object.keys(body).filter((key) => !keys.includes(key)); + if (unknown.length > 0) { + throw new StackBadRequestError( + `Unknown body key${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. ` + + `This endpoint takes: ${keys.join(', ') || 'no keys'}.`, + ); + } +} diff --git a/src/lib/renamed.ts b/src/lib/renamed.ts deleted file mode 100644 index 4464f5a..0000000 --- a/src/lib/renamed.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { StackBadRequestError } from '@haverstack/core'; - -/** - * Wire names core 0.38 renamed, refused rather than ignored. Unknown names - * are otherwise dropped silently, which turns a stale client's request into - * a different, wider one: `?hard=true` becomes a soft delete, a token body's - * `entityId` falls back to minting an owner token, a feed or query filter - * quietly disappears. A 400 naming the replacement makes the break visible. - */ -export const RENAMED_RECORD_QUERY_PARAMS = { - entityId: 'createdBySubject', - principalId: 'createdByPrincipal', - hasAttachment: 'attachmentLabel', -} as const; -export const RENAMED_CHANGE_PARAMS = { entityId: 'createdBySubject' } as const; -export const RENAMED_JOURNAL_PARAMS = { sinceSeq: 'afterSeq' } as const; -export const RENAMED_DELETE_PARAMS = { hard: 'purge' } as const; -export const RENAMED_TOKEN_FIELDS = { entityId: 'principalId', onBehalfOf: 'subjectId' } as const; - -/** Throws if `url` carries any query param named in `renamed`. */ -export function rejectRenamedParams(url: URL, renamed: Readonly>): void { - for (const [old, current] of Object.entries(renamed)) { - if (url.searchParams.has(old)) { - throw new StackBadRequestError(`Query param "${old}" was renamed to "${current}"`); - } - } -} - -/** Throws if `body` carries any field named in `renamed`. */ -export function rejectRenamedFields( - body: Record, - renamed: Readonly>, -): void { - for (const [old, current] of Object.entries(renamed)) { - if (Object.hasOwn(body, old)) { - throw new StackBadRequestError(`Field "${old}" was renamed to "${current}"`); - } - } -} diff --git a/src/middleware/params.ts b/src/middleware/params.ts new file mode 100644 index 0000000..499891a --- /dev/null +++ b/src/middleware/params.ts @@ -0,0 +1,32 @@ +import type { MiddlewareHandler } from 'hono'; +import { StackBadRequestError } from '@haverstack/core'; +import type { AppEnv } from '../types.js'; + +/** + * Refuse any query param the route doesn't define, for the same reason + * `rejectUnknownKeys()` refuses body keys. Routes whose params core's wire + * parsers read (`GET /records`, `GET /changes`, the journal) leave the + * check to those parsers. + */ +export function knownParams(...names: string[]): MiddlewareHandler { + return async (c, next) => { + const unknown = [...new Set(new URL(c.req.url).searchParams.keys())].filter( + (name) => !names.includes(name), + ); + if (unknown.length > 0) { + throw new StackBadRequestError( + `Unknown query param${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. ` + + `This endpoint takes: ${names.join(', ') || 'none'}.`, + ); + } + await next(); + }; +} + +/** A boolean query param: absent is false, and anything but `true`/`false` is refused. */ +export function booleanParam(url: URL, name: string): boolean { + const value = url.searchParams.get(name); + if (value === null || value === 'false') return false; + if (value === 'true') return true; + throw new StackBadRequestError(`Invalid ${name}: expected true or false, got "${value}"`); +} diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts index 28c71a6..c3736c7 100644 --- a/src/routes/attachments.ts +++ b/src/routes/attachments.ts @@ -1,6 +1,10 @@ import { Hono } from 'hono'; import { bodyLimit } from 'hono/body-limit'; -import { StackPermissionError, StackPayloadTooLargeError } from '@haverstack/core'; +import { + StackPermissionError, + StackPayloadTooLargeError, + StackValidationError, +} from '@haverstack/core'; import { resolveAttachmentDownloadContentType, resolveReferencedAttachment, @@ -10,8 +14,10 @@ import { } from '@haverstack/core/wire'; import { serializeRecord } from '@haverstack/wire-types'; import type { AppEnv } from '../types.js'; +import { knownParams } from '../middleware/params.js'; import type { StackContext } from '../stack.js'; import { requireAuth, requireOwner } from '../middleware/auth.js'; +import { readJson } from '../lib/json.js'; export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): Hono { const app = new Hono(); @@ -35,7 +41,7 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): // _attachment@1 runs before a single byte is written: an authenticated // requester with no grant is refused, not merely denied a metadata // record afterward. See docs/spec/wire-format.md § Upload. - app.post('/', attachmentBodyLimit, requireAuth(), async (c) => { + app.post('/', knownParams('appId'), attachmentBodyLimit, requireAuth(), async (c) => { const auth = c.get('auth')!; const mimeType = c.req.header('Content-Type') || 'application/octet-stream'; const filename = parseUploadFilename(c.req.header('Content-Disposition')); @@ -47,7 +53,7 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): }); // GET /attachments/:fileId — download - app.get('/:fileId', async (c) => { + app.get('/:fileId', knownParams('contentType', 'filename'), async (c) => { const fileId = c.req.param('fileId'); const auth = c.get('auth'); @@ -105,7 +111,7 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): }); // DELETE /attachments/:fileId - app.delete('/:fileId', requireOwner(ownerEntityId), async (c) => { + app.delete('/:fileId', knownParams(), requireOwner(ownerEntityId), async (c) => { const fileId = c.req.param('fileId'); await stack.deleteAttachment(fileId); return c.body(null, 204); @@ -115,12 +121,22 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): // Owner-only, invoke-only (no built-in scheduling): dryRun makes a // cron-from-outside workflow safe. Body is entirely optional — every // field defaults inside ScopedStack.collectAttachmentGarbage(). - app.post('/gc', requireOwner(ownerEntityId), async (c) => { + app.post('/gc', knownParams(), requireOwner(ownerEntityId), async (c) => { const auth = c.get('auth')!; - const raw = await c.req.text(); - const body = raw ? (JSON.parse(raw) as { graceMs?: number; dryRun?: boolean }) : {}; + const body = (await c.req.text()) + ? await readJson<{ graceMs?: unknown; dryRun?: unknown }>(c, ['graceMs', 'dryRun']) + : {}; + if ( + body.graceMs !== undefined && + (typeof body.graceMs !== 'number' || !Number.isInteger(body.graceMs) || body.graceMs < 0) + ) + throw new StackValidationError([ + { path: 'graceMs', message: 'Must be a non-negative integer' }, + ]); + if (body.dryRun !== undefined && typeof body.dryRun !== 'boolean') + throw new StackValidationError([{ path: 'dryRun', message: 'Must be a boolean' }]); const result = await stack.asActor(auth).collectAttachmentGarbage({ - ...(typeof body.graceMs === 'number' && { graceMs: body.graceMs }), + ...(body.graceMs !== undefined && { graceMs: body.graceMs }), ...(body.dryRun === true && { dryRun: true }), }); return c.json(result, 200); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index c0137dd..5a1b6cc 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -13,6 +13,7 @@ import type { import type { Context } from 'hono'; import type { Logger } from 'pino'; import type { AppEnv } from '../types.js'; +import { knownParams } from '../middleware/params.js'; import type { StackContext } from '../stack.js'; import { readJson } from '../lib/json.js'; import { createExpiredTokenSweeper } from '../lib/tokenSweep.js'; @@ -45,8 +46,8 @@ export function authRoutes(ctx: StackContext, authOrigin: string, logger: Logger // POST /auth/challenge — issues a nonce bound to the requested DID. // Unauthenticated: this is how a token is earned in the first place. - app.post('/challenge', async (c) => { - const body = await readJson>(c); + app.post('/challenge', knownParams(), async (c) => { + const body = await readJson>(c, ['did']); if (typeof body.did !== 'string' || !isValidDidKey(body.did)) { return authError(c, 'invalid_did', 'Not a valid did:key'); } @@ -57,8 +58,8 @@ export function authRoutes(ctx: StackContext, authOrigin: string, logger: Logger // POST /auth/token — redeems a signed nonce for a bearer token. // Unauthenticated for the same reason. - app.post('/token', async (c) => { - const body = await readJson>(c); + app.post('/token', knownParams(), async (c) => { + const body = await readJson>(c, ['did', 'nonce', 'signature']); if (typeof body.did !== 'string' || !isValidDidKey(body.did)) { return authError(c, 'invalid_did', 'Not a valid did:key'); } diff --git a/src/routes/changes.ts b/src/routes/changes.ts index 74a49b3..d54587c 100644 --- a/src/routes/changes.ts +++ b/src/routes/changes.ts @@ -12,7 +12,6 @@ import { serializeChange, isValidCursor } from '@haverstack/wire-types'; import type { ChangeResetReason } from '@haverstack/wire-types'; import type { Logger } from 'pino'; import { safeCompare, isOwnerActingAlone } from '../middleware/auth.js'; -import { rejectRenamedParams, RENAMED_CHANGE_PARAMS } from '../lib/renamed.js'; import { FrameGate } from '../lib/frameGate.js'; import { decodeCursor } from '../lib/resumeCursor.js'; import { ResumeBufferRegistry, resumeBufferKey, type ResumeEntry } from '../lib/resumeBuffer.js'; @@ -89,7 +88,6 @@ export function changeRoutes( app.get('/', async (c) => { const url = new URL(c.req.url); const auth = c.get('auth'); - rejectRenamedParams(url, RENAMED_CHANGE_PARAMS); const { filter, includeRecords, includeUnlisted } = parseChangeParams(url); const presentedRaw = presentedCursorRaw(c, url); diff --git a/src/routes/entity.ts b/src/routes/entity.ts index 7949d55..f37798e 100644 --- a/src/routes/entity.ts +++ b/src/routes/entity.ts @@ -1,31 +1,35 @@ import { Hono } from 'hono'; import type { AppEnv } from '../types.js'; +import { knownParams } from '../middleware/params.js'; import type { StackContext } from '../stack.js'; import { requireAuth, requireOwner } from '../middleware/auth.js'; import { readJson } from '../lib/json.js'; import { serializeRecord } from '@haverstack/wire-types'; -import { StackNotFoundError } from '@haverstack/core'; +import { StackBadRequestError, StackNotFoundError } from '@haverstack/core'; export function entityRoutes(ctx: StackContext): Hono { const app = new Hono(); const { stack } = ctx; const ownerEntityId = stack.ownerEntityId; - app.get('/', requireAuth(), async (c) => { + app.get('/', knownParams(), requireAuth(), async (c) => { const auth = c.get('auth')!; const record = await stack.asActor(auth).getOwnerEntity(); if (!record) throw new StackNotFoundError('Entity record not found'); return c.json(serializeRecord(record)); }); - app.patch('/', requireOwner(ownerEntityId), async (c) => { + app.patch('/', knownParams(), requireOwner(ownerEntityId), async (c) => { const auth = c.get('auth')!; + const body = await readJson>(c, ['content']); + const content = body.content; + if (typeof content !== 'object' || content === null || Array.isArray(content)) + throw new StackBadRequestError('content is required and must be an object'); const record = await stack.asActor(auth).getOwnerEntity(); if (!record) throw new StackNotFoundError('Entity record not found'); - const body = await readJson>(c); const updated = await stack .asActor(auth) - .patchContent(record.id, (body.content ?? {}) as Record); + .patchContent(record.id, content as Record); return c.json(serializeRecord(updated)); }); diff --git a/src/routes/health.ts b/src/routes/health.ts index 218f216..a6b3126 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -1,8 +1,9 @@ import { Hono } from 'hono'; import type { AppEnv } from '../types.js'; +import { knownParams } from '../middleware/params.js'; export function healthRoutes(): Hono { const app = new Hono(); - app.get('/', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() })); + app.get('/', knownParams(), (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() })); return app; } diff --git a/src/routes/records.ts b/src/routes/records.ts index 2ebc250..142bcd4 100644 --- a/src/routes/records.ts +++ b/src/routes/records.ts @@ -1,16 +1,11 @@ import { Hono } from 'hono'; import type { AppEnv } from '../types.js'; +import { knownParams, booleanParam } from '../middleware/params.js'; import type { StackContext } from '../stack.js'; import type { ScopedStack } from '@haverstack/core'; import type { TokenSession } from '@haverstack/core/wire'; import { requireAuth, requireOwner } from '../middleware/auth.js'; import { readJson } from '../lib/json.js'; -import { - rejectRenamedParams, - RENAMED_RECORD_QUERY_PARAMS, - RENAMED_JOURNAL_PARAMS, - RENAMED_DELETE_PARAMS, -} from '../lib/renamed.js'; import { parseQueryBody, parseQueryParams, @@ -51,7 +46,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { + app.post('/query', knownParams(), async (c) => { const auth = c.get('auth'); const query = clampLimit(parseQueryBody(await readJson(c))); const result = await queryWorker.query(auth, query, queryTimeoutMs); @@ -65,9 +60,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { const auth = c.get('auth'); - const url = new URL(c.req.url); - rejectRenamedParams(url, RENAMED_RECORD_QUERY_PARAMS); - const query = clampLimit(parseQueryParams(url)); + const query = clampLimit(parseQueryParams(new URL(c.req.url))); const result = await queryWorker.query(auth, query, queryTimeoutMs); const body: WireQueryResponse = { records: result.records.map(serializeRecord), @@ -83,7 +76,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { + app.post('/', knownParams(), requireAuth(), async (c) => { const auth = c.get('auth')!; const body = await readJson(c); const { typeId, content, options } = createOptionsFromWireRecord(body, auth, ownerEntityId); @@ -92,7 +85,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { + app.get('/:id', knownParams(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth'); const record = await scopeFor(auth).get(id); @@ -118,7 +111,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { + app.patch('/:id', knownParams(), requireAuth(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth')!; const changes = changesFromWireBody(await readJson(c)); @@ -138,12 +131,10 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { + app.delete('/:id', knownParams('purge'), requireAuth(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth')!; - const url = new URL(c.req.url); - rejectRenamedParams(url, RENAMED_DELETE_PARAMS); - const purge = url.searchParams.get('purge') === 'true'; + const purge = booleanParam(new URL(c.req.url), 'purge'); const session = stack.asActor(auth); const { record } = await session.deleteAndReturn(id, { @@ -155,7 +146,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { + app.post('/:id/undelete', knownParams(), requireAuth(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth')!; const restored = await stack @@ -168,7 +159,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { + app.get('/:id/permissions', knownParams(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth'); const record = await scopeFor(auth).get(id); @@ -183,7 +174,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { + app.post('/:id/permissions', knownParams(), requireAuth(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth')!; const body = await readJson(c); @@ -193,7 +184,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { + app.post('/:id/permissions/delete', knownParams(), requireAuth(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth')!; const body = await readJson(c); @@ -205,7 +196,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { + app.get('/:id/associations', knownParams('kind', 'label'), async (c) => { const id = c.req.param('id'); const auth = c.get('auth'); const record = await scopeFor(auth).get(id); @@ -223,7 +214,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { + app.post('/:id/associations', knownParams(), requireAuth(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth')!; const body = await readJson(c); @@ -235,7 +226,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { + app.post('/:id/associations/delete', knownParams(), requireAuth(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth')!; const body = await readJson(c); @@ -261,9 +252,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { const id = c.req.param('id'); const auth = c.get('auth'); - const url = new URL(c.req.url); - rejectRenamedParams(url, RENAMED_JOURNAL_PARAMS); - const query = parseJournalParams(url); + const query = parseJournalParams(new URL(c.req.url)); const limit = clampJournalLimit(query.limit); const entries = await scopeFor(auth).getJournal(id, { ...query, limit }); @@ -279,14 +268,14 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { + app.get('/:id/versions', knownParams(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth'); const versions = await scopeFor(auth).getVersions(id); return c.json(versions.map(serializeVersion)); }); - app.get('/:id/versions/:version', async (c) => { + app.get('/:id/versions/:version', knownParams(), async (c) => { const id = c.req.param('id'); const vNum = parsePositiveInt(c.req.param('version'), 'version number'); const auth = c.get('auth'); @@ -296,7 +285,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { + app.post('/:id/restore/:version', knownParams(), requireAuth(), async (c) => { const id = c.req.param('id'); const vNum = parsePositiveInt(c.req.param('version'), 'version number'); const auth = c.get('auth')!; @@ -312,10 +301,10 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { + app.post('/:id/migrate', knownParams(), requireOwner(ownerEntityId), async (c) => { const id = c.req.param('id'); const auth = c.get('auth')!; - const body = await readJson>(c); + const body = await readJson>(c, ['toTypeId', 'content']); if (!body.toTypeId || typeof body.toTypeId !== 'string') throw new StackBadRequestError('toTypeId is required'); if (!body.content || typeof body.content !== 'object') diff --git a/src/routes/tokens.ts b/src/routes/tokens.ts index 286edff..be54203 100644 --- a/src/routes/tokens.ts +++ b/src/routes/tokens.ts @@ -1,9 +1,9 @@ import { Hono } from 'hono'; import type { AppEnv } from '../types.js'; +import { knownParams } from '../middleware/params.js'; import type { StackContext } from '../stack.js'; import { requireOwner } from '../middleware/auth.js'; import { readJson } from '../lib/json.js'; -import { rejectRenamedFields, RENAMED_TOKEN_FIELDS } from '../lib/renamed.js'; import { parseDate } from '@haverstack/wire-types'; import { StackValidationError } from '@haverstack/core'; import { isValidDid } from '@haverstack/core/did'; @@ -19,16 +19,13 @@ export function tokenRoutes(ctx: StackContext): Hono { // owner), and `subjectId` asserts a delegation out of band — the subject // that principal acts for (default: the principal itself), per // docs/spec/wire-format.md § The session a token names. - app.post('/', requireOwner(ownerEntityId), async (c) => { + app.post('/', knownParams(), requireOwner(ownerEntityId), async (c) => { const body = await readJson<{ principalId?: string; subjectId?: string; label?: string; expiresAt?: string; - }>(c); - // A stale client's `entityId` would otherwise be ignored and the token - // minted for the owner — a full owner token handed to whoever it was for. - rejectRenamedFields(body, RENAMED_TOKEN_FIELDS); + }>(c, ['principalId', 'subjectId', 'label', 'expiresAt']); if (body.principalId !== undefined && !isValidDid(body.principalId)) throw new StackValidationError([{ path: 'principalId', message: 'Must be a DID' }]); if (body.subjectId !== undefined && !isValidDid(body.subjectId)) @@ -36,8 +33,15 @@ export function tokenRoutes(ctx: StackContext): Hono { const principalId = body.principalId ?? ownerEntityId; const subjectId = body.subjectId ?? principalId; - const expiresAt = body.expiresAt ? parseDate(body.expiresAt) : undefined; - if (body.expiresAt && !expiresAt) + if (body.label !== undefined && typeof body.label !== 'string') + throw new StackValidationError([{ path: 'label', message: 'Must be a string' }]); + const expiresAt = + body.expiresAt !== undefined + ? typeof body.expiresAt === 'string' + ? parseDate(body.expiresAt) + : undefined + : undefined; + if (body.expiresAt !== undefined && !expiresAt) throw new StackValidationError([{ path: 'expiresAt', message: 'Invalid date' }]); const { id, token } = await tokens.createToken( @@ -67,13 +71,13 @@ export function tokenRoutes(ctx: StackContext): Hono { }); // GET /tokens — list all DB-managed tokens; never returns token values - app.get('/', requireOwner(ownerEntityId), async (c) => { + app.get('/', knownParams(), requireOwner(ownerEntityId), async (c) => { const list = await tokens.listTokens(); return c.json({ tokens: list.map(serializeToken) }); }); // DELETE /tokens/:id — revoke a token by its ID - app.delete('/:id', requireOwner(ownerEntityId), async (c) => { + app.delete('/:id', knownParams(), requireOwner(ownerEntityId), async (c) => { await tokens.revokeToken(c.req.param('id')); return c.body(null, 204); }); diff --git a/src/routes/types.ts b/src/routes/types.ts index eded0e5..16695a2 100644 --- a/src/routes/types.ts +++ b/src/routes/types.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono'; import type { AppEnv } from '../types.js'; +import { knownParams } from '../middleware/params.js'; import type { StackContext } from '../stack.js'; import { requireOwner } from '../middleware/auth.js'; import { readJson } from '../lib/json.js'; @@ -16,20 +17,32 @@ export function typeRoutes(ctx: StackContext): Hono { const app = new Hono(); const { adapter, stack } = ctx; - app.get('/', async (c) => { + app.get('/', knownParams(), async (c) => { const types = await adapter.listTypes(); return c.json(types.map(serializeType)); }); - app.get('/:id', async (c) => { + app.get('/:id', knownParams(), async (c) => { const id = decodeURIComponent(c.req.param('id')); const type = await adapter.getType(id); if (!type) throw new StackNotFoundError('Type not found'); return c.json(serializeType(type)); }); - app.post('/', requireOwner(stack.ownerEntityId), async (c) => { - const body = await readJson>(c); + app.post('/', knownParams(), requireOwner(stack.ownerEntityId), async (c) => { + // The full StackType a client holds is accepted as-is: baseId, version + // and createdAt are derived or stamped here, like a record's stamped + // fields, so sending them back is not a mistake. + const body = await readJson>(c, [ + 'id', + 'baseId', + 'version', + 'name', + 'schema', + 'schemaHash', + 'migratesFrom', + 'createdAt', + ]); if (!body.id || typeof body.id !== 'string') throw new StackBadRequestError('id is required'); if (!body.name || typeof body.name !== 'string') throw new StackBadRequestError('name is required'); @@ -37,6 +50,8 @@ export function typeRoutes(ctx: StackContext): Hono { throw new StackBadRequestError('schema is required'); if (!body.schemaHash || typeof body.schemaHash !== 'string') throw new StackBadRequestError('schemaHash is required'); + if (body.migratesFrom !== undefined && typeof body.migratesFrom !== 'string') + throw new StackBadRequestError('migratesFrom must be a string'); const computedHash = await hashSchema(body.schema as TypeSchema); if (body.schemaHash !== computedHash) @@ -52,7 +67,7 @@ export function typeRoutes(ctx: StackContext): Hono { id: body.id, name: body.name, schema: body.schema as TypeSchema, - ...(body.migratesFrom ? { migratesFrom: body.migratesFrom as string } : {}), + ...(body.migratesFrom !== undefined && { migratesFrom: body.migratesFrom }), }); return c.json(serializeType(type), 201); }); diff --git a/src/routes/wellknown.ts b/src/routes/wellknown.ts index a1da2a5..9d3cf19 100644 --- a/src/routes/wellknown.ts +++ b/src/routes/wellknown.ts @@ -6,13 +6,14 @@ import { } from '@haverstack/wire-types'; import type { DiscoveryResponse } from '@haverstack/wire-types'; import type { AppEnv } from '../types.js'; +import { knownParams } from '../middleware/params.js'; import type { StackContext } from '../stack.js'; import type { Config } from '../config.js'; export function wellknownRoutes(ctx: StackContext, config: Config): Hono { const app = new Hono(); - app.get('/stack', (c) => { + app.get('/stack', knownParams(), (c) => { const body: DiscoveryResponse = { version: WIRE_PROTOCOL_VERSION, entityId: ctx.stack.ownerEntityId, diff --git a/tests/routes/auth.test.ts b/tests/routes/auth.test.ts index 12ca706..128949a 100644 --- a/tests/routes/auth.test.ts +++ b/tests/routes/auth.test.ts @@ -107,23 +107,14 @@ describe('POST /auth/token', () => { expect(session).toEqual({ principalId: keypair.did, subjectId: keypair.did }); }); - it('never lets the client name its own subject, even if it tries', async () => { + it('refuses a client naming its own subject', async () => { const keypair = await generateDidKeypair(); const { nonce, signature } = await challengeAndSign(t, keypair); - const { status, data } = await req(t.app, 'POST', '/auth/token', { - body: { - did: keypair.did, - nonce, - signature, - onBehalfOf: 'did:key:someone-else', - subjectId: 'did:key:someone-else', - }, + const { status } = await req(t.app, 'POST', '/auth/token', { + body: { did: keypair.did, nonce, signature, subjectId: 'did:key:someone-else' }, }); - expect(status).toBe(200); - const d = data as { principalId: string; subjectId: string }; - expect(d.principalId).toBe(keypair.did); - expect(d.subjectId).toBe(keypair.did); + expect(status).toBe(400); }); it('is single-use: redeeming the same nonce twice fails the second time', async () => { diff --git a/tests/routes/renamed.test.ts b/tests/routes/renamed.test.ts deleted file mode 100644 index 3fe6133..0000000 --- a/tests/routes/renamed.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { buildTestApp, req, TEST_TOKEN, OTHER_ENTITY_ID, type TestApp } from '../setup.js'; - -// Names core 0.38 renamed are refused with a 400 naming the replacement, -// never ignored — ignoring them turns a stale client's request into a -// different, wider one. See src/lib/renamed.ts. -describe('renamed wire names', () => { - let t: TestApp; - beforeEach(async () => { - t = await buildTestApp(); - }); - afterEach(async () => { - await t.cleanup(); - }); - - it.each([ - ['entityId', 'principalId'], - ['onBehalfOf', 'subjectId'], - ])('POST /tokens refuses %s rather than minting an owner token', async (old, current) => { - const { status, data } = await req(t.app, 'POST', '/tokens', { - token: TEST_TOKEN, - body: { [old]: OTHER_ENTITY_ID }, - }); - expect(status).toBe(400); - expect(JSON.stringify(data)).toContain(current); - }); - - it.each([ - ['GET', `/records?entityId=${OTHER_ENTITY_ID}`, 'createdBySubject'], - ['GET', `/records?principalId=${OTHER_ENTITY_ID}`, 'createdByPrincipal'], - ['GET', '/records?hasAttachment=photo', 'attachmentLabel'], - ['GET', '/records/1hk153x00001/journal?sinceSeq=0', 'afterSeq'], - ['DELETE', '/records/1hk153x00001?hard=true', 'purge'], - ['GET', `/changes?entityId=${OTHER_ENTITY_ID}`, 'createdBySubject'], - ] as const)('%s %s is refused, naming %s', async (method, path, current) => { - const { status, data } = await req(t.app, method, path, { token: TEST_TOKEN }); - expect(status).toBe(400); - expect(JSON.stringify(data)).toContain(current); - }); -}); diff --git a/tests/routes/strictInput.test.ts b/tests/routes/strictInput.test.ts new file mode 100644 index 0000000..a2bed29 --- /dev/null +++ b/tests/routes/strictInput.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { buildTestApp, req, TEST_TOKEN, OTHER_ENTITY_ID, type TestApp } from '../setup.js'; + +// A name an endpoint doesn't define is refused, never ignored: ignoring it +// turns a mistaken request into a different one that succeeds. +describe('unrecognized request input', () => { + let t: TestApp; + beforeEach(async () => { + t = await buildTestApp(); + }); + afterEach(async () => { + await t.cleanup(); + }); + + it.each([ + ['GET', '/health?x=1'], + ['GET', '/.well-known/stack?x=1'], + ['GET', '/entity?x=1'], + ['GET', '/tokens?x=1'], + ['GET', '/types?x=1'], + ['GET', '/records/1hk153x00001?x=1'], + ['DELETE', '/records/1hk153x00001?x=1'], + ['GET', '/records/1hk153x00001/associations?x=1'], + ['GET', '/attachments/somefile?x=1'], + ] as const)('%s %s refuses an unknown query param', async (method, path) => { + const { status, data } = await req(t.app, method, path, { token: TEST_TOKEN }); + expect(status).toBe(400); + expect(JSON.stringify(data)).toContain('x'); + }); + + it('refuses a purge value other than true or false', async () => { + const { status } = await req(t.app, 'DELETE', '/records/1hk153x00001?purge=yes', { + token: TEST_TOKEN, + }); + expect(status).toBe(400); + }); + + it.each([ + ['/tokens', { principalId: OTHER_ENTITY_ID, extra: true }], + ['/entity', { content: {}, extra: true }], + ['/attachments/gc', { dryRun: true, extra: true }], + ['/auth/challenge', { did: OTHER_ENTITY_ID, extra: true }], + ['/records/1hk153x00001/migrate', { toTypeId: 'x', content: {}, extra: true }], + ] as const)('POST/PATCH %s refuses an unknown body key', async (path, body) => { + const method = path === '/entity' ? 'PATCH' : 'POST'; + const { status, data } = await req(t.app, method, path, { token: TEST_TOKEN, body }); + expect(status).toBe(400); + expect(JSON.stringify(data)).toContain('extra'); + }); + + it.each([ + ['/tokens', { label: 5 }], + ['/tokens', { expiresAt: 5 }], + ['/attachments/gc', { graceMs: '10' }], + ['/attachments/gc', { dryRun: 'true' }], + ] as const)('POST %s refuses a wrong-typed value with 422', async (path, body) => { + const { status } = await req(t.app, 'POST', path, { token: TEST_TOKEN, body }); + expect(status).toBe(422); + }); + + it('PATCH /entity requires content', async () => { + const { status } = await req(t.app, 'PATCH', '/entity', { token: TEST_TOKEN, body: {} }); + expect(status).toBe(400); + }); +}); From 560abceba01e36a54ca3132ede57f4132fd2f1e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:25:44 +0000 Subject: [PATCH 4/5] test: drop a key no wire record carries from a create body Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018rAh3A4jeexocb6UcZRAwa --- tests/routes/records.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/routes/records.test.ts b/tests/routes/records.test.ts index 2fd3574..55ff630 100644 --- a/tests/routes/records.test.ts +++ b/tests/routes/records.test.ts @@ -44,7 +44,6 @@ describe('Records', () => { body: { typeId: NOTE_TYPE_ID, content: { body: 'Test note' }, - entityId: TEST_ENTITY_ID, }, }); expect(status).toBe(200); From 570c7edb46f1b18ea411b757ece97136759b4040 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 20:07:03 +0000 Subject: [PATCH 5/5] fix: answer a wrong-typed body value with 422, as the other routes do A missing required key stays 400; a key present with the wrong shape is 422, per docs/api.md. Also flattens the expiresAt parse in POST /tokens. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xa2sc4PJtmLYiBT1QpTRyv --- src/routes/entity.ts | 5 +++-- src/routes/tokens.ts | 7 +------ src/routes/types.ts | 2 +- tests/routes/strictInput.test.ts | 16 ++++++++++++++++ 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/routes/entity.ts b/src/routes/entity.ts index f37798e..5dbbfb9 100644 --- a/src/routes/entity.ts +++ b/src/routes/entity.ts @@ -5,7 +5,7 @@ import type { StackContext } from '../stack.js'; import { requireAuth, requireOwner } from '../middleware/auth.js'; import { readJson } from '../lib/json.js'; import { serializeRecord } from '@haverstack/wire-types'; -import { StackBadRequestError, StackNotFoundError } from '@haverstack/core'; +import { StackBadRequestError, StackNotFoundError, StackValidationError } from '@haverstack/core'; export function entityRoutes(ctx: StackContext): Hono { const app = new Hono(); @@ -23,8 +23,9 @@ export function entityRoutes(ctx: StackContext): Hono { const auth = c.get('auth')!; const body = await readJson>(c, ['content']); const content = body.content; + if (content === undefined) throw new StackBadRequestError('content is required'); if (typeof content !== 'object' || content === null || Array.isArray(content)) - throw new StackBadRequestError('content is required and must be an object'); + throw new StackValidationError([{ path: 'content', message: 'Must be an object' }]); const record = await stack.asActor(auth).getOwnerEntity(); if (!record) throw new StackNotFoundError('Entity record not found'); const updated = await stack diff --git a/src/routes/tokens.ts b/src/routes/tokens.ts index be54203..d94eb70 100644 --- a/src/routes/tokens.ts +++ b/src/routes/tokens.ts @@ -35,12 +35,7 @@ export function tokenRoutes(ctx: StackContext): Hono { const subjectId = body.subjectId ?? principalId; if (body.label !== undefined && typeof body.label !== 'string') throw new StackValidationError([{ path: 'label', message: 'Must be a string' }]); - const expiresAt = - body.expiresAt !== undefined - ? typeof body.expiresAt === 'string' - ? parseDate(body.expiresAt) - : undefined - : undefined; + const expiresAt = typeof body.expiresAt === 'string' ? parseDate(body.expiresAt) : undefined; if (body.expiresAt !== undefined && !expiresAt) throw new StackValidationError([{ path: 'expiresAt', message: 'Invalid date' }]); diff --git a/src/routes/types.ts b/src/routes/types.ts index 16695a2..c973d74 100644 --- a/src/routes/types.ts +++ b/src/routes/types.ts @@ -51,7 +51,7 @@ export function typeRoutes(ctx: StackContext): Hono { if (!body.schemaHash || typeof body.schemaHash !== 'string') throw new StackBadRequestError('schemaHash is required'); if (body.migratesFrom !== undefined && typeof body.migratesFrom !== 'string') - throw new StackBadRequestError('migratesFrom must be a string'); + throw new StackValidationError([{ path: 'migratesFrom', message: 'Must be a string' }]); const computedHash = await hashSchema(body.schema as TypeSchema); if (body.schemaHash !== computedHash) diff --git a/tests/routes/strictInput.test.ts b/tests/routes/strictInput.test.ts index a2bed29..0fd09b1 100644 --- a/tests/routes/strictInput.test.ts +++ b/tests/routes/strictInput.test.ts @@ -58,6 +58,22 @@ describe('unrecognized request input', () => { expect(status).toBe(422); }); + it('POST /types refuses a non-string migratesFrom with 422', async () => { + const { status } = await req(t.app, 'POST', '/types', { + token: TEST_TOKEN, + body: { id: 'x', name: 'x', schema: {}, schemaHash: 'x', migratesFrom: 5 }, + }); + expect(status).toBe(422); + }); + + it('PATCH /entity refuses non-object content with 422', async () => { + const { status } = await req(t.app, 'PATCH', '/entity', { + token: TEST_TOKEN, + body: { content: [] }, + }); + expect(status).toBe(422); + }); + it('PATCH /entity requires content', async () => { const { status } = await req(t.app, 'PATCH', '/entity', { token: TEST_TOKEN, body: {} }); expect(status).toBe(400);