diff --git a/docs/spec.md b/docs/spec.md index 0a0522c..06c05fe 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -571,6 +571,8 @@ stack.delete(recordId); // soft delete — reversible stack.delete(recordId, { hard: true }); // hard delete — permanent ``` +**Hard delete is owner-only under `ScopedStack`.** Neither the record-level `write` bit nor `delete-own`/`delete-any` grants reach it — a non-owner requesting `{ hard: true }` gets `StackPermissionError`, regardless of what would otherwise authorize a delete. It's irreversible and destroys version history, so it stays outside every delegated-access vocabulary; only the owner can invoke it. Non-owners are always limited to soft delete, which remains recoverable. (Plain `Stack` is unscoped and trusted-by-definition, so this restriction applies only to the `asEntity()` wrapper.) + Queries exclude soft-deleted Records by default. Opt in with: ```ts diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index c67dabe..41aa2ce 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -962,8 +962,16 @@ export class ScopedStack implements StackClient { return this.stack.setPermissions(id, permissions); } + /** + * Hard delete is owner-only: it is irreversible and destroys version + * history, so neither the write bit nor delete-own/delete-any grants + * reach it. Non-owners are always limited to soft delete. + */ async delete(id: string, opts: DeleteRecordOptions = {}): Promise { await this.requireDeletable(id); + if (opts.hard && this.requesterEntityId !== this.stack.ownerEntityId) { + throw new StackPermissionError('Hard delete is owner-only'); + } return this.stack.delete(id, opts); } diff --git a/packages/core/tests/scoped-stack.test.ts b/packages/core/tests/scoped-stack.test.ts index 3468f12..4ded0c1 100644 --- a/packages/core/tests/scoped-stack.test.ts +++ b/packages/core/tests/scoped-stack.test.ts @@ -165,6 +165,27 @@ describe('ScopedStack — write access', () => { expect((await adapter.getRecord(record.id))?.deletedAt).toBeDefined(); }); + test('write:true holder can soft-delete but not hard-delete', async () => { + const record = await adapter.createRecord( + makeRecord({ + permissions: [{ access: 'entity', entityId: MEMBER, read: true, write: true }], + }), + ); + await expect(stack.asEntity(MEMBER).delete(record.id, { hard: true })).rejects.toThrow( + StackPermissionError, + ); + expect(await adapter.getRecord(record.id)).not.toBeNull(); + + await stack.asEntity(MEMBER).delete(record.id); + expect((await adapter.getRecord(record.id))?.deletedAt).toBeDefined(); + }); + + test('owner can hard-delete', async () => { + const record = await adapter.createRecord(makeRecord()); + await stack.asEntity(OWNER).delete(record.id, { hard: true }); + expect(await adapter.getRecord(record.id)).toBeNull(); + }); + test('associate/dissociate/setPermissions enforce write access', async () => { const record = await adapter.createRecord(makeRecord()); const tag: Association = { kind: 'tag', label: 'starred' }; @@ -493,6 +514,15 @@ describe('ScopedStack — grant-based update/delete', () => { expect((await adapter.getRecord(record.id))?.deletedAt).toBeDefined(); }); + test('delete-any grant does not allow hard delete', async () => { + await stack.grant(MEMBER, [{ actions: ['delete-any'], typeId: COMMENT }]); + const record = await stack.create(COMMENT, { text: 'hello' }, { entityId: STRANGER }); + await expect(stack.asEntity(MEMBER).delete(record.id, { hard: true })).rejects.toThrow( + StackPermissionError, + ); + expect(await adapter.getRecord(record.id)).not.toBeNull(); + }); + test('update grant does not allow delete', async () => { await stack.grant(MEMBER, [{ actions: ['update-any'], typeId: COMMENT }]); const record = await stack.create(COMMENT, { text: 'hello' }, { entityId: MEMBER });