Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(<did>, ...)`) 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.

Expand Down
2 changes: 1 addition & 1 deletion src/lib/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T = unknown>(c: Context<AppEnv>): Promise<T> {
let parsed: unknown;
Expand Down
11 changes: 6 additions & 5 deletions src/middleware/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,12 @@ export function errorMiddleware(logger: Logger, stack: Stack): ErrorHandler<AppE
);
}
// On the wire a refused record is indistinguishable from a missing
// one (docs/spec/wire-format.md § Server implementation checklist),
// but the operator is not the adversary: an unscoped probe, on a path
// already failing, recovers the distinction for the log alone. Debug
// rather than warn — this is the sharing graph, which the spec asks
// stay out of a general-purpose aggregator by default.
// one (docs/spec/disclosure.md), but the operator is not the
// adversary, and docs/spec/wire-format.md § Server implementation
// checklist asks for the distinction: an unscoped probe, on a path
// already failing, recovers it for the log alone. Debug rather than
// warn — this is the sharing graph, which the spec asks stay out of a
// general-purpose aggregator by default.
if (err instanceof StackNotFoundError && auth) {
const recordId = c.req.param('id');
if (recordId) {
Expand Down
2 changes: 1 addition & 1 deletion src/routes/changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function changeRoutes(
}

// Single-process only: events exist only in the process owning the
// stack's storage (docs/spec/wire-format.md § Feed implementation
// stack's storage (docs/spec/change-feed.md § Feed implementation
// checklist), and query workers only read. A second process subscribing
// to its own Stack over the same storage would see nothing and look fine
// in testing — worth knowing before scaling this horizontally.
Expand Down
23 changes: 12 additions & 11 deletions tests/routes/records.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,7 @@ describe('Records', () => {
});

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']) {
Expand Down Expand Up @@ -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,
{
Expand Down Expand Up @@ -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,
{
Expand Down
2 changes: 1 addition & 1 deletion tests/routes/versions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading