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
4 changes: 4 additions & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -755,6 +757,8 @@ Authorization: Bearer <token>
<binary data>
```

`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.
Expand Down
24 changes: 19 additions & 5 deletions packages/core/src/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export interface StackClient {
getVersion(id: string, version: number): Promise<RecordVersion | null>;
restoreVersion(id: string, version: number): Promise<StackRecord>;
getAttachment(fileId: string): Promise<Uint8Array>;
putAttachmentBytes(data: Uint8Array): Promise<string>;
putAttachment(data: Uint8Array, mimeType: string, filename?: string): Promise<string>;
deleteAttachment(fileId: string): Promise<void>;
}
Expand Down Expand Up @@ -986,19 +987,32 @@ 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<string> {
async putAttachmentBytes(data: Uint8Array): Promise<string> {
const requester = this.requesterEntityId;
if (!requester) {
throw new StackPermissionError('Anonymous requesters cannot upload attachments');
}
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<string> {
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`,
{
Expand Down
52 changes: 52 additions & 0 deletions packages/core/tests/scoped-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
Loading