diff --git a/docs/spec.md b/docs/spec.md index a769930..0a0522c 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -472,6 +472,8 @@ const meta = results.records[0]?.content as AttachmentContent | undefined; - `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.putAttachmentBytes(data)` — owner-level, bytes only. Stores the file and returns its `fileId` without creating an `_attachment@1` record. No grant check. +- `ScopedStack.putAttachmentBytes(data)` — entity-scoped, bytes only. Gated identically to `ScopedStack.putAttachment()` (a `create` grant on `_attachment@1`): a bytes upload is only meaningful as a precursor to metadata creation, so the two share one authorization check. Anonymous requesters are always denied. - `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. @@ -755,6 +757,8 @@ Authorization: Bearer ``` +`POST /attachments` requires the same authorization as creating an `_attachment@1` record: `401` for anonymous/missing tokens, `403` if the requester lacks a `create` grant on `_attachment@1`. A bytes upload is only meaningful as a precursor to metadata creation, so the two share one authorization requirement. + Returns `413 Request Entity Too Large` if the payload exceeds the server's configured limit (default 50 MB, controlled by `MAX_ATTACHMENT_BYTES`). The SDK's `Stack.putAttachment()` and `ScopedStack.putAttachment()` perform both steps automatically. Direct HTTP callers must create the `_attachment@1` record separately if metadata is needed. diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 72afe2b..c67dabe 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -132,6 +132,7 @@ export interface StackClient { getVersion(id: string, version: number): Promise; restoreVersion(id: string, version: number): Promise; getAttachment(fileId: string): Promise; + putAttachmentBytes(data: Uint8Array): Promise; putAttachment(data: Uint8Array, mimeType: string, filename?: string): Promise; deleteAttachment(fileId: string): Promise; } @@ -986,11 +987,13 @@ export class ScopedStack implements StackClient { } /** - * Store bytes and create an _attachment@1 metadata record owned by the - * requester. Requires a `create` grant on `_attachment@1`. - * Anonymous requesters are always denied. + * Store raw bytes only — no _attachment@1 record. Gated identically to + * putAttachment(): a bytes upload is only meaningful as a precursor to + * metadata creation, so the two share one authorization check. + * Requires a `create` grant on `_attachment@1`. Anonymous requesters are + * always denied. */ - async putAttachment(data: Uint8Array, mimeType: string, filename?: string): Promise { + async putAttachmentBytes(data: Uint8Array): Promise { const requester = this.requesterEntityId; if (!requester) { throw new StackPermissionError('Anonymous requesters cannot upload attachments'); @@ -998,7 +1001,18 @@ export class ScopedStack implements StackClient { if (!(await this.checkCreateGrant(`${SYSTEM_TYPES.ATTACHMENT}@1`))) { throw new StackPermissionError(`No create grant for type "${SYSTEM_TYPES.ATTACHMENT}@1"`); } - const fileId = await this.stack.putAttachmentBytes(data); + return this.stack.putAttachmentBytes(data); + } + + /** + * Store bytes and create an _attachment@1 metadata record owned by the + * requester. Requires a `create` grant on `_attachment@1`. + * Anonymous requesters are always denied. + */ + async putAttachment(data: Uint8Array, mimeType: string, filename?: string): Promise { + const fileId = await this.putAttachmentBytes(data); + // putAttachmentBytes() above throws if requesterEntityId is null. + const requester = this.requesterEntityId as string; await this.stack.create( `${SYSTEM_TYPES.ATTACHMENT}@1`, { diff --git a/packages/core/tests/scoped-stack.test.ts b/packages/core/tests/scoped-stack.test.ts index 363e7dc..3468f12 100644 --- a/packages/core/tests/scoped-stack.test.ts +++ b/packages/core/tests/scoped-stack.test.ts @@ -570,3 +570,55 @@ describe('ScopedStack.putAttachment', () => { expect(typeof fileId).toBe('string'); }); }); + +// ------------------------------------------------------- +// ScopedStack.putAttachmentBytes — bytes-only upload, gated like putAttachment +// ------------------------------------------------------- + +describe('ScopedStack.putAttachmentBytes', () => { + const data = new Uint8Array([1, 2, 3]); + + test('owner can always upload without a grant', async () => { + const fileId = await stack.asEntity(OWNER).putAttachmentBytes(data); + expect(typeof fileId).toBe('string'); + }); + + test('anonymous requester cannot upload', async () => { + await expect(stack.asEntity(null).putAttachmentBytes(data)).rejects.toThrow( + StackPermissionError, + ); + }); + + test('authenticated entity without a grant cannot upload', async () => { + await expect(stack.asEntity(MEMBER).putAttachmentBytes(data)).rejects.toThrow( + StackPermissionError, + ); + }); + + test('entity with create grant on _attachment@1 can upload', async () => { + await stack.grant(MEMBER, [{ actions: ['create'], typeId: '_attachment@1' }]); + const fileId = await stack.asEntity(MEMBER).putAttachmentBytes(data); + expect(typeof fileId).toBe('string'); + }); + + test('default grant allows any authenticated entity to upload', async () => { + await stack.grant(null, [{ actions: ['create'], typeId: '_attachment@1' }]); + const fileId = await stack.asEntity(STRANGER).putAttachmentBytes(data); + expect(typeof fileId).toBe('string'); + }); + + test('does not create an _attachment@1 record', async () => { + await stack.grant(MEMBER, [{ actions: ['create'], typeId: '_attachment@1' }]); + await stack.asEntity(MEMBER).putAttachmentBytes(data); + const result = await stack.query({ filter: { typeId: '_attachment@1' } }); + expect(result.records).toHaveLength(0); + }); + + test('putAttachment() still succeeds and creates its metadata record via the shared gate', async () => { + await stack.grant(MEMBER, [{ actions: ['create'], typeId: '_attachment@1' }]); + const fileId = await stack.asEntity(MEMBER).putAttachment(data, 'image/png', 'photo.png'); + const result = await stack.query({ filter: { typeId: '_attachment@1', entityId: MEMBER } }); + expect(result.records).toHaveLength(1); + expect(result.records[0].content).toMatchObject({ fileId, mimeType: 'image/png' }); + }); +});