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
2 changes: 2 additions & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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);
}

Expand Down
30 changes: 30 additions & 0 deletions packages/core/tests/scoped-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down Expand Up @@ -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 });
Expand Down
Loading