diff --git a/docs/spec.md b/docs/spec.md index 9285665..a769930 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -33,7 +33,7 @@ stack.timezone; // from adapter.timezone `LocalAdapter.initialize()` fails if the file already exists. `LocalAdapter.open()` fails if the file does not exist. This makes the distinction explicit and prevents silent config divergence. -Plugin and extension code that doesn't need to know the underlying backend should accept `StackClient` rather than the concrete `Stack` or `ScopedStack`. `StackClient` is the passable interface covering the full record API (`create`, `get`, `query`, `update`, `delete`, `associate`, `dissociate`, `setPermissions`, `getVersions`, `getVersion`, `restoreVersion`, `putAttachment`) plus a `features` getter. Both `Stack` and `ScopedStack` implement it. +Plugin and extension code that doesn't need to know the underlying backend should accept `StackClient` rather than the concrete `Stack` or `ScopedStack`. `StackClient` is the passable interface covering the full record API (`create`, `get`, `query`, `update`, `delete`, `associate`, `dissociate`, `setPermissions`, `getVersions`, `getVersion`, `restoreVersion`, `getAttachment`, `putAttachment`, `deleteAttachment`) plus a `features` getter. Both `Stack` and `ScopedStack` implement it. **Stack identity** (`ownerEntityId`, `timezone`) is stored as a singleton `_config@1` record in the records table. Adapters expose these values as typed readonly properties (`adapter.ownerEntityId`, `adapter.timezone`) rather than as a generic key/value store. @@ -449,6 +449,10 @@ const fileId = await stack.putAttachment(data: Uint8Array, mimeType: string, fil // Fetch the binary const data: Uint8Array = await stack.getAttachment(fileId) + +// Delete the binary and its _attachment@1 metadata record(s) +// Throws StackConflictError if any record still references the file +await stack.deleteAttachment(fileId) ``` A `fileId` is referenced in an `Association` of kind `"attachment"`. To read metadata for a given `fileId`, query `_attachment@1` records: @@ -464,11 +468,17 @@ const results = await stack.query({ const meta = results.records[0]?.content as AttachmentContent | undefined; ``` -**`Stack.putAttachment()` vs `ScopedStack.putAttachment()`:** +**`Stack` vs `ScopedStack` attachment methods:** - `Stack.putAttachment(data, mimeType, filename?)` — owner-level upload. Creates an `_attachment@1` record with no `entityId`. No grant check. - `ScopedStack.putAttachment(data, mimeType, filename?)` — entity-scoped upload. Requires a `create` grant on `_attachment@1`. The created record's `entityId` is set to the uploading entity. +- `Stack.getAttachment(fileId)` — no permission check; always succeeds if the bytes exist. +- `ScopedStack.getAttachment(fileId)` — accessible if the requester is the owner, can read any record that references the file, or uploaded the file themselves and it hasn't been associated with a record yet. Throws `StackPermissionError` otherwise. + +- `Stack.deleteAttachment(fileId)` — deletes bytes and all `_attachment@1` metadata records for the file. Throws `StackConflictError` if any record still references the file. Throws `StackNotFoundError` if the file doesn't exist. +- `ScopedStack.deleteAttachment(fileId)` — owner only. Throws `StackPermissionError` for non-owners. Delegates to `Stack.deleteAttachment()`. + **Deduplication:** Bytes are deduplicated — uploading the same content twice stores the binary only once. However, each call to `putAttachment()` creates a new `_attachment@1` record with its own `mimeType` and `filename`. The same `fileId` may have multiple `_attachment@1` records from separate uploads. --- @@ -618,6 +628,7 @@ Standard HTTP status codes are used throughout: | **401** | Unauthorized | Missing or invalid bearer token | | **403** | Forbidden | `StackPermissionError` — record exists but the requester lacks access | | **404** | Not found | `StackNotFoundError` — record or version does not exist | +| **409** | Conflict | `StackConflictError` — operation blocked by a constraint violation (e.g. deleting an attachment still referenced by a record) | | **413** | Request entity too large | Attachment upload exceeds the server's size limit | | **422** | Unprocessable entity | `StackValidationError` — request is syntactically valid but content fails schema validation (e.g. a required field has the wrong type) | @@ -761,6 +772,8 @@ GET /attachments/?contentType=image/png&filename=photo.png When neither parameter is provided the server queries the `_attachment@1` record: the stored `mimeType` becomes `Content-Type`, and the filename is taken from the requester's own `_attachment@1` record (if one exists). Falls back to `Content-Type: application/octet-stream` when no metadata record is found. +**Delete:** Owner only. Returns `409 Conflict` if any record in the stack still references the file (i.e. has it in an `attachment` association or its content references the `fileId`). The bytes and all `_attachment@1` metadata records for the file are removed atomically on success. + **Attachment permissions** are governed by the Record(s) that reference them, not the attachment itself. If any Record referencing a `fileId` is accessible to the requester, the attachment is accessible. A non-owner requester can also access a file if they own an `_attachment@1` record for it, enabling access in the window between upload and record association. ### Entity diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index bfa906b..72afe2b 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -97,6 +97,14 @@ export class StackNotFoundError extends Error { } } +/** Thrown when an operation cannot proceed due to a constraint violation (e.g. deleting an attachment that is still referenced). */ +export class StackConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'StackConflictError'; + } +} + // ------------------------------------------------------- // StackClient interface // ------------------------------------------------------- @@ -123,7 +131,9 @@ export interface StackClient { getVersions(id: string): Promise; getVersion(id: string, version: number): Promise; restoreVersion(id: string, version: number): Promise; + getAttachment(fileId: string): Promise; putAttachment(data: Uint8Array, mimeType: string, filename?: string): Promise; + deleteAttachment(fileId: string): Promise; } // ------------------------------------------------------- @@ -606,6 +616,42 @@ export class Stack implements StackClient { return this.adapter.getAttachment(fileId); } + /** + * Delete an attachment's bytes and its _attachment@1 metadata record(s). + * Throws StackConflictError if any record in the stack still references the file. + * Throws StackNotFoundError if neither metadata records nor bytes exist. + */ + async deleteAttachment(fileId: string): Promise { + const refResult = await this.query({ filter: { attachmentFileId: fileId }, limit: 1 }); + if (refResult.records.length > 0) { + throw new StackConflictError('Attachment is still referenced by one or more records'); + } + + const metaResult = await this.query({ + filter: { + typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`, + ...(this.features.contentFieldQuery && { content: { fileId } }), + }, + }); + const metaRecords = this.features.contentFieldQuery + ? metaResult.records + : metaResult.records.filter((r) => (r.content as AttachmentContent).fileId === fileId); + + if (!metaRecords.length) { + try { + await this.adapter.getAttachment(fileId); + } catch { + throw new StackNotFoundError(`Attachment not found: "${fileId}"`); + } + } + + for (const record of metaRecords) { + await this.delete(record.id, { hard: true }); + } + + await this.adapter.deleteAttachment(fileId); + } + // ------------------------------------------------------- // Lifecycle // ------------------------------------------------------- @@ -965,4 +1011,50 @@ export class ScopedStack implements StackClient { ); return fileId; } + + /** + * Download attachment bytes. Accessible if the requester is the owner, + * can read any record referencing the file, or uploaded the file themselves + * and it hasn't been associated with a record yet. + */ + async getAttachment(fileId: string): Promise { + if (this.requesterEntityId === this.stack.ownerEntityId) { + return this.stack.getAttachment(fileId); + } + + // Accessible if the requester can read any record that references this file + const refResult = await this.query({ filter: { attachmentFileId: fileId }, limit: 1 }); + if (refResult.records.length > 0) { + return this.stack.getAttachment(fileId); + } + + // Accessible if the requester uploaded it and it hasn't been associated yet + if (this.requesterEntityId) { + const uploadResult = await this.stack.query({ + filter: { + typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`, + entityId: this.requesterEntityId, + ...(this.stack.features.contentFieldQuery && { content: { fileId } }), + }, + limit: 1, + }); + const hasUpload = this.stack.features.contentFieldQuery + ? uploadResult.records.length > 0 + : uploadResult.records.some((r) => (r.content as AttachmentContent).fileId === fileId); + if (hasUpload) return this.stack.getAttachment(fileId); + } + + throw new StackPermissionError(); + } + + /** + * Delete an attachment. Only the stack owner may delete attachments. + * Delegates to Stack.deleteAttachment(), which enforces the "not referenced" check. + */ + async deleteAttachment(fileId: string): Promise { + if (this.requesterEntityId !== this.stack.ownerEntityId) { + throw new StackPermissionError('Only the stack owner can delete attachments'); + } + return this.stack.deleteAttachment(fileId); + } }