From 0e8472814b4cdb0c1c8fe099d7a2f54a38478b40 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 18:15:27 +0000 Subject: [PATCH] feat(core): move _grant's grantee into content, add listGrants()/revoke() (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grant() stamped the grantee into record.entityId, the field that means "author" everywhere else — the owner creates a grant for Alice, and the record says Alice authored it, an entity who may never have touched the stack. This polluted "everything this entity authored" queries with grants merely naming them, and made hasGrant's -own check resolve to "is the grantee" instead of "authored it" for grant records specifically. GrantContent gains an optional granteeEntityId; grant() writes it there and stops stamping entityId, so a grant record is owner-authored like any other system record it creates. hasGrant() reads content.granteeEntityId instead of record.entityId. No version bump — additive field, no install base yet, per #57. Also promotes the issue's own follow-up comment to landed: listGrants(entityId?) and revoke(entityId, grants) round out the read/undo side of grant management now that the grantee is a queryable content field. revoke() is a soft delete like any other mutation, so an accidental revocation is undeletable the same way. Fixes #57 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KDmUW3oNxPSCLf2ftqaF8q --- docs/spec.md | 24 ++++++- packages/core/src/stack.ts | 67 ++++++++++++++++-- packages/core/src/types.ts | 2 + packages/core/tests/stack.test.ts | 113 +++++++++++++++++++++++++++++- 4 files changed, 194 insertions(+), 12 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index 11cc87f..c09539b 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -128,6 +128,7 @@ A Grant authorises one or more Entities to perform specific actions on Records o type GrantContent = { typeId: TypeId; // Which record type this grant covers actions: GrantAction[]; // Which actions are permitted + granteeEntityId?: string; // Who the grant applies to. Absent = default grant (any authenticated entity). }; type GrantAction = @@ -140,6 +141,8 @@ type GrantAction = | 'delete-any'; // Delete all records of this type ``` +The grantee lives in `content.granteeEntityId`, not `record.entityId`. `entityId` means "author" on every other Record in the system, and a `_grant` Record is always authored by the stack owner (the only caller of `grant()`) — never by the entity it names. A grant Record therefore carries no `entityId` of its own, consistent with the owner-authored-records invariant, and "everything this entity authored" queries (`filter: { entityId }`) don't pick up grants that merely name that entity. + `Stack.grant()` is the owner-facing helper for creating grant records: ```ts @@ -148,15 +151,30 @@ await stack.grant('bob-entity-id', [ { typeId: 'com.example/comment@1', actions: ['create', 'read-own', 'update-own', 'delete-own'] }, ]); -// Default grant — applies to any authenticated entity (null entityId on the grant record) +// Default grant — applies to any authenticated entity (no granteeEntityId in content) await stack.grant(null, [{ typeId: 'com.example/comment@1', actions: ['create', 'read-own'] }]); ``` +`Stack.listGrants(entityId?)` and `Stack.revoke(entityId, grants)` are the read/undo counterparts, both owner-facing like `grant()`: + +```ts +await stack.listGrants(); // every grant record, any grantee +await stack.listGrants(null); // only default grants +await stack.listGrants('bob-entity-id'); // grants naming Bob, plus every default grant — what currently applies to him + +// The inverse of grant(): soft-deletes the _grant record(s) matching entityId +// (null for a default grant) and each { typeId, actions } pair, matched by +// typeId baseId and action set — the same granularity grant() writes at. +await stack.revoke('bob-entity-id', [{ typeId: 'com.example/comment@1', actions: ['create'] }]); +``` + +A revocation is a soft delete like any other mutation — the owner can `undelete()` it the same as an accidental delete anywhere else (see [Versions](#versions)). + **Design decisions:** - **No wildcard `typeId`**: there is no `*` or catch-all. Every grant is opt-in per type. Adding a new type never implicitly inherits existing grants — it starts default-deny. -- **Grants target the type family, not the exact version**: a grant naming `com.example/comment@1` also covers `com.example/comment@2` — matching is by `baseId`, derived from whichever form the grant's `typeId` was given in. This is what keeps a version bump from silently orphaning existing grants (previously, registering a migration broke every grant pinned to the old version, with no data change at all — grants are checked in memory _before_ any migration applies). -- **Default grants** (grant record has no `entityId`): apply to any authenticated entity. Useful for "any logged-in user can comment" scenarios. Anonymous requesters (no `entityId`) are always denied, even under a default grant. +- **Grants target the type family, not the exact version**: a grant naming `com.example/comment@1` also covers `com.example/comment@2` — matching is by `baseId`, derived from whichever form the grant's `typeId` was given in. This is what keeps a version bump from silently orphaning existing grants (previously, registering a migration broke every grant pinned to the old version, with no data change at all — grants are checked in memory _before_ any migration applies). `revoke()` matches at the same granularity. +- **Default grants** (grant content has no `granteeEntityId`): apply to any authenticated entity. Useful for "any logged-in user can comment" scenarios. Anonymous requesters (no `entityId`) are always denied, even under a default grant. - **Actions are independent**: `'create'` does not imply `'read-own'`, and so on. The combination `['create', 'read-own', 'update-own', 'delete-own']` is a common bundle for contributor access, but each action must be listed explicitly. - **`-own` scope**: `-own` actions apply only to Records where `record.entityId` equals the requester — Records the entity authored. Records with no `entityId` (owner-created) do not satisfy any `-own` check. diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 9809044..061433e 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -1300,6 +1300,10 @@ export class Stack implements StackClient { * for specific record types. Pass null as entityId for a default grant * that applies to any authenticated entity. * + * The grantee lives in `GrantContent.granteeEntityId`, not `record.entityId` + * — `entityId` means "author" everywhere else, and the owner (who calls + * grant()) authored this record, never the entity it names (#57). + * * The _grant@1 type is defined automatically on first use. */ async grant( @@ -1309,16 +1313,66 @@ export class Stack implements StackClient { const records: StackRecord[] = []; for (const g of grants) { records.push( - await this.create( - `${SYSTEM_TYPES.GRANT}@1`, - { typeId: g.typeId, actions: g.actions }, - entityId ? { entityId } : {}, - ), + await this.create(`${SYSTEM_TYPES.GRANT}@1`, { + typeId: g.typeId, + actions: g.actions, + ...(entityId && { granteeEntityId: entityId }), + }), ); } return records; } + /** + * List _grant records. Omit `entityId` for every grant regardless of + * grantee. Pass `null` for only default grants (no `granteeEntityId` — + * apply to any authenticated entity). Pass a specific entityId for the + * grants that currently apply to that entity: ones naming them, plus + * every default grant — the same resolution ScopedStack's hasGrant() + * uses internally. + */ + async listGrants(entityId?: string | null): Promise { + const all = await queryAllPages((q) => this.query(q), { + filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` }, + }); + if (entityId === undefined) return all; + return all.filter((r) => { + const granteeEntityId = (r.content as GrantContent).granteeEntityId; + return entityId === null + ? !granteeEntityId + : !granteeEntityId || granteeEntityId === entityId; + }); + } + + /** + * The inverse of grant(): soft-deletes _grant records exactly matching + * `entityId` (null for default grants) and each `{ typeId, actions }` + * pair — matched by typeId baseId and action set, the same granularity + * grant() writes at. A soft delete like any other mutation: the owner can + * undelete a revocation the same as any other write (#59/#61). + */ + async revoke( + entityId: string | null, + grants: Array<{ actions: GrantAction[]; typeId: TypeId }>, + ): Promise { + const all = await queryAllPages((q) => this.query(q), { + filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` }, + }); + for (const g of grants) { + const familyId = baseIdOf(g.typeId); + const actionSet = new Set(g.actions); + const matches = all.filter((r) => { + const c = r.content as GrantContent; + if (baseIdOf(c.typeId) !== familyId) return false; + if ((c.granteeEntityId ?? null) !== entityId) return false; + return c.actions.length === actionSet.size && c.actions.every((a) => actionSet.has(a)); + }); + for (const match of matches) { + await this.delete(match.id); + } + } + } + // ------------------------------------------------------- // Private helpers // ------------------------------------------------------- @@ -1344,6 +1398,7 @@ export class Stack implements StackClient { await this.defineType(`${SYSTEM_TYPES.GRANT}@1`, 'Grant', { typeId: { kind: 'string', required: true }, actions: { kind: 'array', items: { kind: 'string' }, required: true }, + granteeEntityId: { kind: 'string' }, }); await this.defineType(`${SYSTEM_TYPES.ATTACHMENT}@1`, 'Attachment', { fileId: { kind: 'string', required: true }, @@ -1518,7 +1573,7 @@ export class ScopedStack implements StackClient { return grantRecords.some((r) => { const c = r.content as GrantContent; if (baseIdOf(c.typeId) !== familyId) return false; - if (r.entityId && r.entityId !== this.requesterEntityId) return false; + if (c.granteeEntityId && c.granteeEntityId !== this.requesterEntityId) return false; return actions.some((action) => { if (!(c.actions as string[]).includes(action)) return false; if (action.endsWith('-own')) return record?.entityId === this.requesterEntityId; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b3703ef..c721344 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -208,6 +208,8 @@ export type GrantContent = { typeId: TypeId; /** Which actions are permitted. */ actions: GrantAction[]; + /** Who the grant applies to. Absent = default grant, applies to any authenticated entity. */ + granteeEntityId?: string; }; /** Content for _attachment records — one per upload, tracks file metadata. */ diff --git a/packages/core/tests/stack.test.ts b/packages/core/tests/stack.test.ts index b4842eb..1549c43 100644 --- a/packages/core/tests/stack.test.ts +++ b/packages/core/tests/stack.test.ts @@ -1019,13 +1019,20 @@ describe('grant', () => { test('creates a grant record for the given entity and type', async () => { const records = await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); expect(records).toHaveLength(1); - expect(records[0].entityId).toBe('entity-abc'); - expect(records[0].content).toEqual({ typeId: NOTE_V1, actions: ['create'] }); + // The grantee lives in content, not record.entityId — entityId means + // "author", and the owner (who called grant()) authored this record (#57). + expect(records[0].entityId).toBeUndefined(); + expect(records[0].content).toEqual({ + typeId: NOTE_V1, + actions: ['create'], + granteeEntityId: 'entity-abc', + }); }); - test('null entityId creates a default grant (no entityId on the record)', async () => { + test('null entityId creates a default grant (no granteeEntityId in content)', async () => { const records = await stack.grant(null, [{ actions: ['create'], typeId: NOTE_V1 }]); expect(records[0].entityId).toBeUndefined(); + expect(records[0].content).toEqual({ typeId: NOTE_V1, actions: ['create'] }); }); test('creates multiple grant records in one call', async () => { @@ -1047,6 +1054,106 @@ describe('grant', () => { test('_attachment@1 type is available immediately after Stack.create()', async () => { expect(await stack.getType('_attachment@1')).not.toBeNull(); }); + + // A grant record used to carry the grantee in record.entityId, which + // means "author" everywhere else — so "everything Alice authored" queries + // picked up grants *about* Alice that she never touched. Moving the + // grantee into content fixes this (#57). + test('an authorship query does not pick up grants naming that entity', async () => { + await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + const result = await stack.query({ filter: { entityId: 'entity-abc' } }); + expect(result.records).toHaveLength(0); + }); + + test('a grant record still resolves through ScopedStack for its named grantee', async () => { + await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + const record = await stack.asEntity('entity-abc').create(NOTE_V1, { text: 'hi' }); + expect(record.content.text).toBe('hi'); + }); +}); + +// ------------------------------------------------------- +// listGrants +// ------------------------------------------------------- + +describe('listGrants', () => { + test('omitting entityId returns every grant record', async () => { + await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + await stack.grant(null, [{ actions: ['read-any'], typeId: NOTE_V1 }]); + const grants = await stack.listGrants(); + expect(grants).toHaveLength(2); + }); + + test('entityId: null returns only default grants', async () => { + await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + await stack.grant(null, [{ actions: ['read-any'], typeId: NOTE_V1 }]); + const grants = await stack.listGrants(null); + expect(grants).toHaveLength(1); + expect(grants[0].content).toMatchObject({ actions: ['read-any'] }); + }); + + test('a specific entityId returns grants naming it plus every default grant', async () => { + await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + await stack.grant('entity-xyz', [{ actions: ['delete-own'], typeId: NOTE_V1 }]); + await stack.grant(null, [{ actions: ['read-any'], typeId: NOTE_V1 }]); + + const grants = await stack.listGrants('entity-abc'); + expect(grants).toHaveLength(2); + const actionSets = grants.map((g) => (g.content as { actions: string[] }).actions); + expect(actionSets).toContainEqual(['create']); + expect(actionSets).toContainEqual(['read-any']); + }); +}); + +// ------------------------------------------------------- +// revoke +// ------------------------------------------------------- + +describe('revoke', () => { + test('deletes the grant record matching entityId, typeId, and actions', async () => { + await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + await stack.revoke('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + const grants = await stack.listGrants('entity-abc'); + expect(grants).toHaveLength(0); + }); + + test('revocation is a soft delete — the owner can undelete it like any other mutation', async () => { + const [granted] = await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + await stack.revoke('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + expect(await stack.listGrants('entity-abc')).toHaveLength(0); + + await stack.undelete(granted.id); + expect(await stack.listGrants('entity-abc')).toHaveLength(1); + }); + + test('does not affect a grant for a different entity or a default grant', async () => { + await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + await stack.grant(null, [{ actions: ['create'], typeId: NOTE_V1 }]); + await stack.revoke('entity-xyz', [{ actions: ['create'], typeId: NOTE_V1 }]); + expect(await stack.listGrants()).toHaveLength(2); + }); + + test('does not affect a grant for the same entity with a different action set', async () => { + await stack.grant('entity-abc', [{ actions: ['create', 'read-own'], typeId: NOTE_V1 }]); + await stack.revoke('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + expect(await stack.listGrants('entity-abc')).toHaveLength(1); + }); + + test('matches by baseId, covering every version of the type family', async () => { + await stack.defineType(NOTE_V2, 'Note v2', { + text: { kind: 'text', required: true }, + title: { kind: 'string' }, + }); + await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + await stack.revoke('entity-abc', [{ actions: ['create'], typeId: NOTE_V2 }]); + expect(await stack.listGrants('entity-abc')).toHaveLength(0); + }); + + test('null entityId revokes a default grant', async () => { + await stack.grant(null, [{ actions: ['create'], typeId: NOTE_V1 }]); + await stack.revoke(null, [{ actions: ['create'], typeId: NOTE_V1 }]); + expect(await stack.listGrants(null)).toHaveLength(0); + }); }); // -------------------------------------------------------