diff --git a/docs/api.md b/docs/api.md index 22d3ef1..a0b1f0e 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. +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 | Method | Path | Auth | Description | @@ -51,7 +53,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 +76,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 +136,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 +147,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 +172,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 +180,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 +192,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 +206,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 +234,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 +276,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 +313,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 +329,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 +391,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..aefabfb 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'; /** @@ -8,12 +8,15 @@ 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(); } 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 +24,23 @@ 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'); } + 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/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..45f7ae9 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,35 @@ 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 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, 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/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 d36ec5d..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,25 +41,25 @@ 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')); 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); }); // 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'); 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 @@ -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 result = await stack.forSession(auth).collectAttachmentGarbage({ - ...(typeof body.graceMs === 'number' && { graceMs: body.graceMs }), + 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({ + ...(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 6da3a6d..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'); } @@ -105,10 +106,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..5dbbfb9 100644 --- a/src/routes/entity.ts +++ b/src/routes/entity.ts @@ -1,31 +1,36 @@ 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, StackValidationError } 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.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) => { + app.patch('/', knownParams(), requireOwner(ownerEntityId), async (c) => { const auth = c.get('auth')!; - const record = await stack.forSession(auth).getOwnerEntity(); + const body = await readJson>(c, ['content']); + const content = body.content; + if (content === undefined) throw new StackBadRequestError('content is required'); + if (typeof content !== 'object' || content === null || Array.isArray(content)) + throw new StackValidationError([{ path: 'content', message: 'Must be an object' }]); + const 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) - .patchContent(record.id, (body.content ?? {}) as Record); + .asActor(auth) + .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 4894921..142bcd4 100644 --- a/src/routes/records.ts +++ b/src/routes/records.ts @@ -1,7 +1,9 @@ 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, 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 +18,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 +38,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); @@ -67,23 +69,23 @@ 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); - const created = await stack.forSession(auth).create(typeId, content, options); + const created = await stack.asActor(auth).create(typeId, content, options); return c.json(serializeRecord(created), 200); }); // GET /records/:id - app.get('/:id', async (c) => { + app.get('/:id', knownParams(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth'); const record = await scopeFor(auth).get(id); @@ -109,19 +111,19 @@ 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)); const updated = await stack - .forSession(auth) + .asActor(auth) .mutate(id, changes, { ifVersion: parseIfMatch(c.req.header('If-Match')) }); return c.json(serializeRecord(updated)); }); - // DELETE /records/:id (?hard=true for permanent). Both answer 200 with - // a record: a soft delete with the tombstone it produced, a hard delete + // DELETE /records/:id (?purge=true for permanent). Both answer 200 with + // a record: a soft delete with the tombstone it produced, a purge // with the record as it stood immediately before destruction. That body // is the requester's only report of the files the purge stranded; every // other row naming them is gone by the time it lands. deleteAndReturn() @@ -129,14 +131,14 @@ 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 hard = new URL(c.req.url).searchParams.get('hard') === 'true'; - const session = stack.forSession(auth); + const purge = booleanParam(new URL(c.req.url), 'purge'); + 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'); @@ -144,11 +146,11 @@ 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 - .forSession(auth) + .asActor(auth) .undelete(id, { ifVersion: parseIfMatch(c.req.header('If-Match')) }); return c.json(serializeRecord(restored)); }); @@ -157,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); @@ -172,21 +174,21 @@ 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); - const updated = await stack.forSession(auth).grantAccess(id, body); + const updated = await stack.asActor(auth).grantAccess(id, body); return c.json(serializeRecord(updated)); }); // POST to a /delete sub-path for the reason the association endpoints // use one, below. - app.post('/:id/permissions/delete', requireAuth(), async (c) => { + app.post('/:id/permissions/delete', knownParams(), requireAuth(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth')!; const body = await readJson(c); - const updated = await stack.forSession(auth).revokeAccess(id, body); + const updated = await stack.asActor(auth).revokeAccess(id, body); return c.json(serializeRecord(updated)); }); @@ -194,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); @@ -212,23 +214,23 @@ 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); - 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)); }); // POST, not DELETE — a DELETE request body has no defined semantics // (RFC 9110 §9.3.5) and is a portability landmine for proxies/gateways // that drop or reject it. - app.post('/:id/associations/delete', requireAuth(), async (c) => { + app.post('/:id/associations/delete', knownParams(), requireAuth(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth')!; const body = await readJson(c); - const updated = await stack.forSession(auth).dissociate(id, body); + const updated = await stack.asActor(auth).dissociate(id, body); return c.json(serializeRecord(updated)); }); @@ -266,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'); @@ -283,12 +285,12 @@ 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')!; const restored = await stack - .forSession(auth) + .asActor(auth) .restoreVersion(id, vNum, { ifVersion: parseIfMatch(c.req.header('If-Match')) }); return c.json(serializeRecord(restored)); }); @@ -299,21 +301,21 @@ 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 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..d94eb70 100644 --- a/src/routes/tokens.ts +++ b/src/routes/tokens.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'; @@ -13,32 +14,38 @@ 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. - app.post('/', requireOwner(ownerEntityId), async (c) => { + // 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('/', knownParams(), 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' }]); + }>(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)) + throw new StackValidationError([{ path: 'subjectId', message: 'Must be a DID' }]); - const principalId = body.entityId ?? ownerEntityId; - const expiresAt = body.expiresAt ? parseDate(body.expiresAt) : undefined; - if (body.expiresAt && !expiresAt) + const principalId = body.principalId ?? ownerEntityId; + 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 = typeof body.expiresAt === 'string' ? parseDate(body.expiresAt) : undefined; + if (body.expiresAt !== undefined && !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 +56,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, @@ -59,13 +66,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 c9e7631..c973d74 100644 --- a/src/routes/types.ts +++ b/src/routes/types.ts @@ -1,12 +1,13 @@ 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 { serializeType } from '@haverstack/wire-types'; import { hashSchema, - StackQueryError, + StackBadRequestError, StackNotFoundError, StackValidationError, } from '@haverstack/core'; @@ -16,26 +17,41 @@ 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); - 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'); + app.post('/', knownParams(), requireOwner(stack.ownerEntityId), async (c) => { + // The full StackType a client holds is accepted as-is: baseId, version + // and createdAt are derived or stamped here, like a record's stamped + // fields, so sending them back is not a mistake. + const body = await readJson>(c, [ + 'id', + 'baseId', + 'version', + 'name', + 'schema', + 'schemaHash', + 'migratesFrom', + 'createdAt', + ]); + if (!body.id || typeof body.id !== 'string') throw new StackBadRequestError('id is required'); + if (!body.name || typeof body.name !== 'string') + throw new StackBadRequestError('name is required'); if (!body.schema || typeof body.schema !== 'object') - throw new 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'); + if (body.migratesFrom !== undefined && typeof body.migratesFrom !== 'string') + throw new StackValidationError([{ path: 'migratesFrom', message: 'Must be a string' }]); const computedHash = await hashSchema(body.schema as TypeSchema); if (body.schemaHash !== computedHash) @@ -47,8 +63,11 @@ 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, { - ...(body.migratesFrom ? { migratesFrom: body.migratesFrom as string } : {}), + const type = await stack.defineType({ + id: body.id, + name: body.name, + schema: body.schema as TypeSchema, + ...(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 d94f0b8..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, @@ -22,7 +23,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 +35,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..5b84978 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: [] } }), @@ -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, @@ -176,7 +188,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..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 () => { @@ -343,7 +334,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 +386,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 +410,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..55ff630 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 }, + }, }); } @@ -40,7 +44,6 @@ describe('Records', () => { body: { typeId: NOTE_TYPE_ID, content: { body: 'Test note' }, - entityId: TEST_ENTITY_ID, }, }); expect(status).toBe(200); @@ -136,19 +139,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 +221,7 @@ describe('Records', () => { { kind: 'relationship', label: 'child', - target: { scope: 'record', recordId: target.id }, + target: { kind: 'record', recordId: target.id }, }, ], }, @@ -231,7 +234,7 @@ describe('Records', () => { { kind: 'relationship', label: 'sibling', - target: { scope: 'record', recordId: target.id }, + target: { kind: 'record', recordId: target.id }, }, ], }, @@ -265,7 +268,7 @@ describe('Records', () => { { kind: 'relationship', label: 'author', - target: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + target: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, @@ -293,7 +296,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 +312,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 +345,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 +358,7 @@ describe('Records', () => { { kind: 'relationship', label: 'reply-to', - target: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + target: { kind: 'entity', entityId: OTHER_ENTITY_ID }, }, ], }, @@ -379,7 +382,7 @@ describe('Records', () => { { kind: 'relationship', label: 'ref', - target: { scope: 'record', recordId: target.id }, + target: { kind: 'record', recordId: target.id }, }, ], }, @@ -393,7 +396,7 @@ describe('Records', () => { kind: 'relationship', label: 'ref', target: { - scope: 'record', + kind: 'record', recordId: target.id, stackUrl: 'https://other.example/stack', }, @@ -537,12 +540,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 +566,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 +577,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 +591,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 +599,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 +615,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 +641,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 +694,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 +765,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 +793,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 +813,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 +833,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 +851,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 +866,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 +880,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 +896,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 +924,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 +966,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 +979,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 +1057,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 +1077,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 +1087,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 +1114,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 +1156,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 +1178,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 +1321,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 +1340,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 +1403,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 +1427,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 +1524,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 +1556,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 +1605,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 +1625,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 +1772,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 +1857,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 +2076,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 +2112,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/strictInput.test.ts b/tests/routes/strictInput.test.ts new file mode 100644 index 0000000..0fd09b1 --- /dev/null +++ b/tests/routes/strictInput.test.ts @@ -0,0 +1,81 @@ +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('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); + }); +}); 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 }, }, ], },