From 438a36c226ea084ae76021ae5e6ca92ad5df1bb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 21:04:09 +0000 Subject: [PATCH] docs: give the grant surface a wire account, and fix stale references A verification pass over the permission surface, driven by a wire-only client against a real server: 126 assertions across type-level grants, grantee validation, evaluation-time refusal, the _grant write fence, record-level permissions and the reshare gate, group rosters, delegation, query scoping on the worker thread, the change feed, and the journal's authority projection. Enforcement held on every one; what it turned up was documentation and references. docs/api.md described a grant's grantee shape and no way to write one: `_grant` appeared nowhere in the docs, so the only path to a grant -- POST /records with typeId `_grant@1` -- had to be inferred. Give it a section: the four routes that are the grant surface, the owner-acting- alone fence over every write verb, that reads stay on their ordinary gates, and that an unlisted grant still confers but needs includeUnlisted=true to be found, which is what a "what have I granted" view has to pass. docs/deployment.md told operators a grant with no grantee is a grant to the public, and spelled it `grant(null, ...)`. Absence now confers nothing and is refused, so the bullet inverted the rule it warned about; its link into api.md#access-control had no target either. Three spec references named sections that do not exist: the change feed's implementation checklist has moved to its own document, the 400/422 split sits under wire-format's error responses, and the disclosure rule has a document of its own that the code implementing it did not cite. Four comments described the move to the current behavior rather than the behavior. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01St5i1czsAp26y8H56pmANC --- docs/api.md | 32 ++++++++++++++++++++++++++++++++ docs/deployment.md | 4 ++-- src/lib/json.ts | 2 +- src/middleware/errors.ts | 11 ++++++----- src/routes/changes.ts | 2 +- tests/routes/records.test.ts | 23 ++++++++++++----------- tests/routes/versions.test.ts | 2 +- 7 files changed, 55 insertions(+), 21 deletions(-) diff --git a/docs/api.md b/docs/api.md index dc78be5..22d3ef1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -355,6 +355,38 @@ Non-owner entities authenticate either via the DID challenge-response handshake 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 }`: + +```json +{ + "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.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. diff --git a/docs/deployment.md b/docs/deployment.md index 03c46f0..19fa28f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -196,8 +196,8 @@ Because the stack is designed to be accessed by many different kinds of apps, yo Two consequences worth being deliberate about: -- **A grant with no grantee is a grant to the public.** `grant(null, ...)` resolves for any authenticated entity, and with the handshake open that is anyone at all. See [Access Control](./api.md#access-control). Named grants (`grant(, ...)`) are unaffected — those are the vouching mechanism. -- **Rate limiting matters more than it used to.** A stranger's handshake writes a token row with a 7-day expiry. Expired rows are reclaimed, but live ones are bounded only by issuance rate × TTL, so the proxy-level rate limit configured above is what actually caps the table. The reverse-proxy examples in this guide already cover `/auth/*`; if you write your own, do not exempt it. +- **The `authenticated` tier is a grant to the public.** A grant whose grantee is `{ "kind": "authenticated" }` resolves for any authenticated entity, and with the handshake open that is anyone at all. See [Permissions](./api.md#permissions). A grant naming a DID (`{ "kind": "entity", "entityId": "did:key:..." }`) is unaffected — that is the vouching mechanism. No tier is reachable by omission: a grant whose grantee is absent or unrecognized is refused, and confers nothing if it reaches storage some other way, so this is a reach you have to ask for by name. +- **Rate limiting is what bounds the token table.** A stranger's handshake writes a token row with a 7-day expiry. Expired rows are reclaimed, but live ones are bounded only by issuance rate × TTL, so the proxy-level rate limit configured above is what actually caps the table. The reverse-proxy examples in this guide already cover `/auth/*`; if you write your own, do not exempt it. Read access is unaffected either way: `GET /records`, `POST /records/query`, `GET /records/:id` and `GET /types` already serve anonymous requests, subject to record-level permissions, and a token changes nothing about what they return. diff --git a/src/lib/json.ts b/src/lib/json.ts index 8435da8..0ec77d9 100644 --- a/src/lib/json.ts +++ b/src/lib/json.ts @@ -6,7 +6,7 @@ import type { AppEnv } from '../types.js'; * `c.req.json()` throws a bare `SyntaxError` on malformed input, which isn't * a `StackError` — left uncaught it falls through errorMiddleware's * catch-all as an unlabeled 500 instead of the 400 `bad_request` every other - * structurally-invalid request gets (docs/spec/wire-format.md § 400 vs 422). + * structurally-invalid request gets (docs/spec/wire-format.md § Error responses). */ export async function readJson(c: Context): Promise { let parsed: unknown; diff --git a/src/middleware/errors.ts b/src/middleware/errors.ts index a249f7b..4b9a5ad 100644 --- a/src/middleware/errors.ts +++ b/src/middleware/errors.ts @@ -35,11 +35,12 @@ export function errorMiddleware(logger: Logger, stack: Stack): ErrorHandler { }); describe('_attachment@1 immutable field protection', () => { - // Enforced by ScopedStack now (docs/spec/attachments.md § The _attachment + // Enforced by ScopedStack (docs/spec/attachments.md § The _attachment // record type) — putAttachment() itself returns the created record, so // no separate query is needed to find it. async function seedAttachment(ctx: TestApp['ctx']) { @@ -812,12 +812,13 @@ 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 - // record itself, only to what it grants — which would now 404 under - // the anti-oracle rule rather than exercise the write-protection fence - // this test targets. Set an explicit read+write permission directly on - // the grant record instead (same approach as the PATCH sibling above) - // so the requester can read it, and the refusal proven here is really - // "_grant records refuse deletion" rather than "can't read it". + // 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 + // permission directly on the grant record instead (same approach as + // the PATCH sibling above) so the requester can read it, and the + // refusal proven here is really "_grant records refuse deletion" + // rather than "can't read it". const grantRecord = await t.ctx.stack.create( GRANT_TYPE_ID, { @@ -875,10 +876,10 @@ describe('Records', () => { }); it('blocks escalation: a write-holder on a grant record cannot expand its actions', async () => { - // stack.grant() itself now 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 still possible: a grant - // record with an explicit record-level write permission on itself. + // stack.grant() 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. const readGrant = await t.ctx.stack.create( GRANT_TYPE_ID, { diff --git a/tests/routes/versions.test.ts b/tests/routes/versions.test.ts index 25ed106..b4a1bee 100644 --- a/tests/routes/versions.test.ts +++ b/tests/routes/versions.test.ts @@ -97,7 +97,7 @@ describe('Versions', () => { }); }); - // Version history is now gated at the same level as update() — a + // Version history is gated at the same level as update() — a // write-holder, or owner/creator — not plain read, per // docs/spec/versioning.md § History access. describe('history access requires write, not read', () => {