Skip to content

Latest commit

 

History

History
394 lines (271 loc) · 48.8 KB

api.md

File metadata and controls

394 lines (271 loc) · 48.8 KB

API Reference

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
GET /.well-known/stack None Stack metadata and capabilities
GET /health None Liveness check

GET /.well-known/stack returns:

{
  "version": "1.0",
  "entityId": "did:key:z6Mk...",
  "timezone": "America/New_York",
  "capabilities": {
    "filter": { "content": "path", "contentPresent": true, "search": true },
    "sort": { "fields": ["createdAt", "updatedAt", "version"], "contentField": true },
    "limits": { "attachmentBytes": 52428800, "contentBytes": 1048576 }
  },
  "auth": { "methods": ["did-challenge"] },
  "changes": { "transports": ["sse"], "resume": true, "records": true }
}

version is the wire protocol's own MAJOR.MINOR version (from @haverstack/wire-types' WIRE_PROTOCOL_VERSION), not this server's software version — a client refuses to open() a server whose major differs from its own; a minor difference is never a refusal in either direction. timezone is present only when the stack was configured with one — there is no default. capabilities.limits.attachmentBytes and contentBytes are this server's own enforced ceilings (413 past either), letting a client pre-check and get a typed error instead of burning a round trip. auth: { methods: ["did-challenge"] } is always present — this server always implements the DID challenge-response handshake described below.

changes is a top-level field, not part of capabilities — a client checks it and fails locally at open() rather than discovering a missing feed as a 404 partway through a connection. transports lists what it speaks (sse is the only one this version defines); resume is true — GET /changes mints and honors resume cursors; records is true because GET /changes already honors ?include=record unconditionally. Both resume and records false is also a fully conformant response for a server that doesn't implement either — see Change feed below.

capabilities.filter.content is "path", spread through from the underlying sqlite adapter: a content filter key may be a dot-separated path ("profile.email"), not just a top-level field — see Content filter keys below. capabilities and its shape are defined by @haverstack/core's AdapterCapabilities; see core's wire-format.md § Discovery for the normative reach ladder and field-by-field meaning.

Authentication

Method Path Auth Description
POST /auth/challenge None Request a nonce to prove control of a DID
POST /auth/token None Redeem a signed nonce for a bearer token

Both endpoints are unauthenticated — they're how a token is earned in the first place. This is the DID challenge-response handshake: a client proves it holds the private key behind a did:key DID, and the server issues a bearer token for that DID without any out-of-band secret handoff. POST /tokens (owner-only, see Entity & Tokens) remains as the owner's separate escape hatch for admin-issued and delegated tokens.

POST /auth/challenge   { "did": "did:key:z6Mk..." }
                     → { "nonce": "k7Qm2ZxRt9vLbNc4Hy8Wf3", "expiresAt": "2024-06-15T12:05:00.000Z" }

POST /auth/token       { "did": "...", "nonce": "...", "signature": "..." }
                     → { "token": "...", "expiresAt": "...", "principalId": "...", "subjectId": "..." }

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<origin>\n<did>\n<nonce> — where <origin> 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' 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.

Both endpoints require a did:key. Other DID methods are well-formed DIDs but are refused with invalid_did: verification decodes the Ed25519 public key straight out of the DID, so no other method could produce a signature this server can check.

Handshake-issued tokens carry the label did-challenge and a 7-day expiry. The label is what makes them distinguishable in GET /tokens from tokens the owner minted deliberately — the two are otherwise identical rows. Expired tokens are reclaimed periodically rather than accumulating.

Auth errors

Auth failures use their own vocabulary, deliberately outside the WireErrorCode taxonomy used elsewhere in this document — no Stack operation has begun yet, so none of these is a StackError:

Code Status Meaning Retryable
invalid_did 400 Not a well-formed did:key No
unknown_nonce 401 Never issued, already spent, or issued to another DID Yes
expired_nonce 401 Past expiresAt Yes
invalid_signature 401 Does not verify against the payload No

The retryable column is why these carry a code at all rather than being bodyless 401s like other auth failures on this server: a stale or unknown nonce means re-run the handshake once from POST /auth/challenge; a rejected signature will be rejected identically forever, so a client that retried it would loop. unknown_nonce deliberately does not distinguish "never issued" from "already spent" from "issued to a different DID" — the three differ only in what an attacker would learn.

Records

Method Path Auth Description
GET /records Optional Query records via URL params
POST /records/query Optional Query records with content filters
POST /records Required Create a record
GET /records/:id Optional Get a record by ID
PATCH /records/:id Required Apply a change set to a record
DELETE /records/:id Required Soft-delete (or purge with ?purge=true)
POST /records/:id/undelete Required Reverse a soft delete
GET /records/:id/permissions Optional Get permissions
POST /records/:id/permissions Required Grant one permission element
POST /records/:id/permissions/delete Required Revoke one permission element
GET /records/:id/associations Optional List associations
POST /records/:id/associations Required Add an association
POST /records/:id/associations/delete Required Remove an association
GET /records/:id/journal Optional Read the change journal
GET /records/:id/versions Optional List version history
GET /records/:id/versions/:version Optional Get a specific version
POST /records/:id/restore/:version Required Restore a previous version
POST /records/:id/migrate Owner only Change a record's typeId

The change set

PATCH /records/:id takes a change set: an envelope naming any combination of a record's native aspects, applied as one atomic write that produces exactly one version.

{
  "contentPatch": { "title": "Q3 plan" },
  "parentId": "1hk153x0000f",
  "permissions": [{ "kind": "anyone", "label": "read" }],
  "associations": [{ "kind": "tag", "label": "planning" }],
  "unlisted": false
}

Keys are read for presence, not truthiness — unlisted: false and parentId: null name aspects and are applied, while an omitted key is left alone. A change set naming no key at all is 400: it addresses nothing, so there is nothing it could have failed to satisfy. One already satisfied in every key writes nothing and answers with the record unchanged.

contentPatch is the one key that merges; every other key replaces the aspect it names. The merge is one level deep — a nested object in a patch replaces rather than merges — and a null field value removes the field. Because content is a key rather than the whole body, a parentId naming a container is unambiguously distinct from a content field of that name.

Only contentPatch bumps version. parentId, permissions, associations and unlisted are no-bump aspects: each one's inverse is the same shape as the forward operation, so none of them needs a snapshot to be recoverable from, and a change set naming only those leaves version and updatedAt exactly where they stand and writes no version row. The journal carries what each of them moved. An If-Match sent beside such a change set is therefore not checked — there is no bump for it to fence — while one sent beside a set that also names contentPatch fences the whole call as usual.

Authority resolves per key, against the record as it stands: contentPatch and associations are reachable by a write-holder or an update-* grantee; parentId needs that same write access and a destination the requester can read; permissions and unlisted need the owner or the record's own creator. A requester who may perform every key may perform the set; a requester who may not perform one of them may not perform any of it. Every gate reads the record's pre-change state, so a widened permissions in a change set never satisfies the read check on a parentId named in the same call. A refused key refuses the whole call — nothing is partially applied and no key is silently dropped.

An unrecognized top-level key is 400; a key whose value is the wrong shape is 422, as is a key the type does not declare inside contentPatch. A parentId a caller names must be well-formed (400 otherwise — the empty string is not a spelling of the root, null is) and must name a record that exists (409 otherwise).

Which of those two a requester sees depends on who they are. Both answers are the owner's: for anyone else, a destination they cannot read and one that is not there are a single indistinguishable 403, which is what stops the gate being an existence oracle. So a non-owner write-holder naming a missing container gets 403, not 409 — they learn they may not name it, never whether it exists.

associate() / dissociate() keep their own endpoints: they amend the association set where the associations key replaces it, so two clients tagging one record both succeed through POST .../associations and race through the key.

Version history and the journal require the same access PATCH/DELETE require — a write-holder, or the owner — not plain read. A read-only requester gets 403.

A snapshot carries content and the typeId it was read under, and nothing else. No version has ever captured parentId, unlistedAt, permissions or associations, since none of those bumps a version, so POST /records/:id/restore/:version settles content and typeId alone: the record comes back in whatever container it is in now, listed however it is listed now, reachable by whoever reaches it now. Undoing a move is a change set's parentId — the journal entry for the move is what names where it came from.

POST /records/:id/migrate is the only way a record's typeId changes after creation, and is owner-acting-alone only — a non-owner gets 403 regardless of any write grant or record-level permission they hold. Body is { toTypeId, content }: content is the full post-migration content, computed client-side by the type's owning app; the server validates it against toTypeId's schema before writing and leaves a pre-migration snapshot in version history. Accepts the same If-Match header as PATCH.

Removing an association is POST /records/:id/associations/delete, not a body-bearing DELETE — a DELETE request body has no defined semantics (RFC 9110 §9.3.5) and is a portability landmine for proxies/gateways that drop or reject it.

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:

{ "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 and associations are separate domains over one store. A permission element named in the associations key — or an association element in permissions, or either through the wrong pair of endpoints — is 400 with code bad_request: the refusal says the caller named the wrong surface rather than sent a malformed value. Each key replaces only within its own domain, so an app editing tags is never handed the ACL and cannot drop it.

POST /records/:id/undelete reverses a soft delete and returns the record as it now stands (deletedAt absent). Idempotent — a second call on an already-active record returns the same result.

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 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: "<version>" 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, 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 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 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.

Both query endpoints' response envelope is { records, cursor }. It carries no count of the whole match — every response has passed a permission boundary, so a count that ignores pagination would report how many records exist beyond what the requester may read. An empty records array with a non-null cursor is a valid response and does not mean the result set is exhausted — a low-visibility requester can see several empty pages before results appear, so cursor: null is the only end-of-results signal.

Relationship target filter (relatedTo family)

A relationship association's target names one of three scopes — a Record, an identity, or something outside the Stack — and the filter parameters mirror that:

Parameter Scope Pairs with
relatedTo record relatedToStack (that Record's Stack URL, if not this one)
relatedToEntity entity —
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 { kind: 'record' | 'entity' | 'external', ... }.

Content filter keys

POST /records/query's filter.content matches content fields by exact value, keyed by field name. A key may be a dot-separated path ("profile.email") to match a field nested inside an object-typed field — this server declares capabilities.filter.content: "path" in Discovery, so a multi-segment key is accepted rather than refused with 400. An array anywhere along the path is matched element-wise. filter.contentPresent lists paths that must hold a value, the question an exact-match filter value can't ask.

Soft delete and tombstones

DELETE /records/:id without ?purge=true leaves a tombstone: the record continues to exist and remains reachable by id, but its content is projected as {} and deletedAt is set. This applies uniformly everywhere the record is served with content attached — GET /records/:id, GET/POST /records/query with filter.includeDeleted: true, and a deleted-kind change feed frame with ?include=record — so a client sees the same emptied shape regardless of which route it came through. GET /records/:id/versions and GET /records/:id/versions/:version are the deliberate exception: version history is never tombstoned and continues to serve full content.

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 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

Every write that emits a change event also appends one entry to the record's journal — a second durable tier beside version history, holding what the change moved, who moved it, and the association deltas nothing else retains. A snapshot answers what could be put back; an entry answers what happened.

GET /records/:id/journal             — the log, oldest first
GET /records/:id/journal?afterSeq=4  — entries after seq 4, exclusive
GET /records/:id/journal?limit=50    — at most 50 entries
{
  "entries": [
    {
      "seq": 2,
      "at": "2026-08-13T12:00:00.000Z",
      "kind": "changed",
      "ops": ["associate"],
      "version": 1,
      "typeId": "com.example/note@1",
      "actor": { "subjectId": "did:key:z6Mk..." },
      "associations": [
        {
          "op": "repoint",
          "association": {
            "kind": "attachment",
            "label": "avatar",
            "fileId": "abc123",
            "attachmentRecordId": "1hk153x00002"
          },
          "previous": {
            "kind": "attachment",
            "label": "avatar",
            "fileId": "abc123",
            "attachmentRecordId": "1hk153x00001"
          }
        }
      ]
    }
  ],
  "cursor": null
}

associations is the field a client can get nowhere else. Each element is a tagged edit — add, repoint or remove — and the two that displace something carry previous beside the thing that displaced it, so an inverse is read off one element rather than joined across two lists. It keeps what an add overwrote in place and what a removal took away, annotation included. The change feed reports only what is current, across two flat lists, and no snapshot carries associations at all.

seq is the entry's own — a dense integer from 1 per record, never the feed's opaque whole-stack cursor. No value crosses between them.

cursor is the only end-of-log signal. This server caps a page at 500 entries and reports the seq to send back as 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 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 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

unlistedAt is orthogonal to permissions: it says nothing about who may read a record, only whether it is enumerable. A record with unlistedAt set is still reachable by GET /records/:id for anyone who may already read it — unlisted withholds a record from enumeration, never from access.

PATCH /records/:id     body: { "unlisted": boolean }

Answers 200 with the updated record, carrying unlistedAt when true and absent when false. A listing transition is its own inverse, so it bumps no version and takes no snapshot — the journal is where it is recorded — and an If-Match beside a change set naming only this key is not checked. Returns 409 on a soft-deleted record like the other mutating endpoints above. Gated exactly like the permissions key: owner or creator, asked of both identities under delegation. A non-boolean value is 422, the shape error every change-set key answers with.

includeUnlisted is owner-only. GET /records, POST /records/query, and GET /changes all accept it (excluded by default, like includeDeleted), and all three refuse it with 403 for any requester but the owner acting alone — enumeration standing rests on nothing but ownership, so no grant or delegation carries it. On GET /changes the refusal happens before the SSE stream opens.

A POST /records body may carry unlistedAt to create the record already unlisted, so there is no window where it exists and is enumerable before a later change set catches up — presence of the field is what matters; its value is not honored (unlistedAt on the response is stamped to the real create time, like version). This is not owner-only, unlike createdAt/updatedAt: it's gated the same way the permissions field is at create time — refused with 403 for a delegated app acting for anyone but the owner, but available to any other creator holding a create grant for the type.

The feed transitions

unlist and list are new change-feed ops (unlist maps to kind: "deleted", list to kind: "changed" — see Change feed). Only the unlist transition needs special-casing; every other row falls out of checking the record's current unlistedAt against the subscriber's includeUnlisted:

Transition Emits to a default subscriber?
Created unlisted No
Listed → unlisted (unlist) Yes
Any change while unlisted No
Unlisted → listed (list) Yes
Hard delete while unlisted No

Change feed

Method Path Auth Description
GET /changes Optional Live stream of record changes (text/event-stream)

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 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, 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). 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 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 is where an overwritten or removed annotation survives.

A purged frame — from a purge — carries none of record, parentId, or the record's own author, whatever the connection asked for: purge is the erasure primitive, and a frame naming what was destroyed is deliberately all that survives it.

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.

: keepalive comment lines go out on an otherwise-idle connection periodically, same as any SSE comment — a conforming client ignores them. A connection whose bearer token is revoked or expires is closed by the server rather than left delivering on stale authority; a reconnect gets the same 401 every other endpoint already answers with. A connection that falls too far behind (more in-flight frames than it's draining) is also closed rather than queued without bound — a client that can't tell it missed something can't repair it either, so the server disconnects instead of silently dropping a frame.

This server is single-process: GET /changes reports only writes made through this same process's Stack. A second process pointed at the same underlying storage would see nothing from this endpoint — worth knowing before running more than one instance against one stack.

Types

Method Path Auth Description
GET /types None List all registered types
GET /types/:id None Get a type by ID
POST /types Owner only Register a new type

Attachments

Method Path Auth Description
POST /attachments Required Store bytes and create the _attachment@1 record in one request
GET /attachments/:fileId Optional Download a file
DELETE /attachments/:fileId Owner only Delete a file
POST /attachments/gc Owner only Sweep and delete orphaned attachment bytes

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 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 { 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.

Entity & Tokens

Method Path Auth Description
GET /entity Required Get the owner entity record
PATCH /entity Owner only Update the owner entity record
GET /tokens Owner only List API tokens
POST /tokens Owner only Create an API token
DELETE /tokens/:id Owner only Revoke an API token

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:

{ "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" }

A group grantee always names a role — member is the wider set, admin the narrower, matching the roster labels where admin implies member. There is no absent-role spelling to fall back on. anyone spells reach to the world affirmatively and carries read or nothing, so no dropped field on a permission element can produce it.

GET  /records/:id/permissions         — read the set
POST /records/:id/permissions         — grant one element
POST /records/:id/permissions/delete  — revoke one element (by body)

The two POSTs each take one element as their body, amend the set, and answer 200 with the updated record — where a change set's permissions key replaces the set wholesale. Amending is what survives two admins sharing one record at once. They carry the reshare gate rather than the write bit: a write-holder who is neither owner nor creator gets 403, because a write-holder who could grant would escalate to deciding who else reaches the record. Permission elements are associations, so neither endpoint bumps version or reads If-Match.

Write implies read, as a cross-element invariant over the set a write would produce. A body producing a set where some grantee holds write with no read beside it is 422 with code validation — here and anywhere else a request body carries permissions — so revoking a read while its write stands earns the same refusal. An anyone element satisfies it for every grantee: a world-readable record leaves no writer blind. permissions: [] is an ordinary one-call write, since it names nothing at all.

Non-owner entities authenticate either via the DID challenge-response handshake above or with tokens issued by the owner via POST /tokens, and are subject to both record-level permissions and create-grant checks on write.

A type-level grant names its grantee affirmatively in content.grantee, in one of three tiers: { "kind": "entity", "entityId": ... }, { "kind": "group", "groupId": ..., "role": "member" | "admin" }, or { "kind": "authenticated" }. The field is required, so no tier is reachable by omission — a body that lost it is 422 rather than stored as a grant to everyone. { "kind": "authenticated" } is a grant to the public: it resolves for any authenticated entity, and since POST /auth/challenge lets anyone authenticate by generating a keypair, that means anyone who can reach the server — there is no vouching step in between. Name a DID unless you genuinely mean everyone. It is distinct from a record permission's anyone, which reaches an unauthenticated requester too.

The grant surface

There is no /grants endpoint: a grant is a record of the system type _grant@1, so the record routes are its surface.

POST   /records                            — write a grant
GET    /records?typeId=_grant@1            — list them
DELETE /records/:id                        — withdraw one (soft delete)
POST   /records/:id/undelete               — put it back

A write is an ordinary POST /records whose typeId is _grant@1 and whose content is { typeId, actions, grantee }:

{
  "typeId": "_grant@1",
  "content": {
    "typeId": "com.example/comment@1",
    "actions": ["create", "read-own", "update-own", "delete-own"],
    "grantee": { "kind": "entity", "entityId": "did:key:..." }
  }
}

Only the stack owner acting alone writes a grant. No grant can confer it — _grant is among the families a grant cannot target — and no record-level write element reaches it either, so a delegated token is refused however the owner issued it. Editing an existing grant is refused on the same terms as minting a fresh one, since rewriting a grant's actions, typeId or grantee reaches the same escalation by another route. The fence covers every write verb, not merely the content one: PATCH /records/:id, both /permissions endpoints, both /associations endpoints, DELETE /records/:id, POST /records/:id/undelete and POST /records/:id/restore/:version are all refused. A requester who can read the grant gets 403; one who cannot gets 404, per the disclosure rule above.

Reads are not fenced: GET /records/:id, GET /records/:id/versions and GET /records/:id/versions/:version on a grant stay on their ordinary gates, so a write-holder can audit the grant they hold. History is the mutate surface, so a read element alone does not reach it.

Listing is enumeration, so listing state applies. A grant created with unlistedAt still confers — withholding a record from enumeration decides nothing about what it means — but it is excluded from GET /records?typeId=_grant@1 like any other unlisted record. Pass includeUnlisted=true (owner only) to see every grant, which is what a "what have I granted" view needs: a grant that confers and cannot be found is the one state worth avoiding.

Withdrawal is a soft delete, and only deletion withdraws a grant. DELETE /records/:id takes effect on the next request — there is no authority cache to outlive it, on a query or a by-id read — and POST /records/:id/undelete restores it, the same recovery any other accidental delete has.

Principal and subject

A token names two identities: the principal, who authenticated (governs authority — grant lookups, a change set's permissions key), and the subject, who the principal acts for (governs attribution — record.createdBy.subjectId on writes, -own matching). They're equal unless the token was issued with a distinct subjectId. A delegated write stamps both into createdBy (and updatedBy): subjectId is the subject, and principalId is present when the two differ. GET /records and POST /records/query can filter on either via createdBySubject/createdByPrincipal. Effective authority under delegation is the intersection of both parties' grants — a delegated app can't act beyond what the subject itself also permits.