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/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ This server is single-process: `GET /changes` reports only writes made through t

`POST /attachments/gc` sweeps for attachment bytes unreachable from any record — live or soft-deleted — and deletes both the bytes and their `_attachment@1` metadata. Body is `{ graceMs?, dryRun? }`, both optional: `graceMs` is how recently-uploaded an unreferenced file must be to survive collection (default 24 hours, covering the upload-then-associate window; `0` collects immediately), `dryRun` computes the result without deleting anything. Returns `{ deleted: [fileId...], reclaimedBytes }`. No built-in scheduling — invoke it directly, or drive it from an external cron; `dryRun` makes a probe-first workflow safe.

An `attachment` association (`{ kind: "attachment", label, fileId }`, set via `POST /records/:id/associations`) may carry an optional `attachmentRecordId`, naming the `_attachment` record whose upload established that particular reference — useful when several records share one `fileId` but each wants its own uploader's filename. The pointer annotates the reference rather than identifying it: association identity stays `(kind, label, fileId)`, so `dissociate()` matches without it, and re-associating with a different (or absent) `attachmentRecordId` re-points the existing association in place rather than adding a second one. `GET /attachments/:fileId` itself never resolves this pointer — a plain download names a fileId, not a reference — so a client holding the association resolves the filename itself (via `resolveReferencedAttachment()` from `@haverstack/core/wire`) and passes it as `?filename`.

## Entity & Tokens

| Method | Path | Auth | Description |
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,18 @@
"format:check": "prettier --check ."
},
"dependencies": {
"@haverstack/adapter-local": "^0.30.0",
"@haverstack/commons": "^0.25.0",
"@haverstack/core": "^0.31.0",
"@haverstack/wire-types": "^0.30.0",
"@haverstack/adapter-local": "^0.31.0",
"@haverstack/commons": "^0.26.0",
"@haverstack/core": "^0.32.0",
"@haverstack/wire-types": "^0.31.0",
"@hono/node-server": "^2.1.1",
"hono": "^4.13.7",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@haverstack/conformance-fixtures": "^0.24.0",
"@haverstack/conformance-fixtures": "^0.25.0",
"@types/node": "^26.2.0",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
Expand Down
76 changes: 38 additions & 38 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 12 additions & 8 deletions src/routes/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { bodyLimit } from 'hono/body-limit';
import { StackPermissionError, StackPayloadTooLargeError } from '@haverstack/core';
import {
resolveAttachmentDownloadContentType,
firstRecordedAttachment,
resolveReferencedAttachment,
parseUploadFilename,
NOSNIFF_HEADER_NAME,
NOSNIFF_HEADER_VALUE,
Expand Down Expand Up @@ -67,10 +67,16 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number):
}

const metaRecords = await stack.getAttachmentRecords(fileId);
const firstRecord = firstRecordedAttachment(metaRecords);
const ownRecord = auth
? firstRecordedAttachment(metaRecords.filter((r) => r.entityId === auth.subjectId))
: undefined;
// mimeType is a property of the fileId, not the requester's perspective:
// always the first-recorded record, regardless of who is asking.
const firstRecord = resolveReferencedAttachment(metaRecords);
// A plain fileId download carries no association to resolve
// attachmentRecordId from — a client holding one resolves it themselves
// and passes the result as ?filename. This falls back to the
// requester's own upload, then the first-recorded record.
const referenced = resolveReferencedAttachment(metaRecords, {
requesterEntityId: auth?.subjectId,
});

const contentTypeParam = c.req.query('contentType');
const filenameParam = c.req.query('filename');
Expand All @@ -84,9 +90,7 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number):
// goes too: forcing Content-Type alone won't stop a browser sniffing
// the body back into the original type without both nosniff and a
// non-inline disposition.
const filename = forced
? undefined
: (filenameParam ?? ownRecord?.content.filename ?? firstRecord?.content.filename);
const filename = forced ? undefined : (filenameParam ?? referenced?.content.filename);

const disposition = filename
? `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`
Expand Down
59 changes: 59 additions & 0 deletions tests/conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,36 @@ describe('associate fixtures', () => {
expect((data as { associations: unknown[] }).associations).toEqual([fixture.requestBody]);
});

test('associate-attachment-record-id', async () => {
const fixture = associateFixtures.find((f) => f.name === 'associate-attachment-record-id')!;
handled.add(fixture.name);
const body = fixture.requestBody as Association & {
kind: 'attachment';
fileId: string;
attachmentRecordId: string;
};
const uploaded = await t.ctx.stack.putAttachment(
new Uint8Array([1, 2, 3]),
'image/png',
'embed.png',
);
const fileId = (uploaded.content as { fileId: string }).fileId;
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
const { status } = await req(t.app, 'POST', `/records/${record.id}/associations`, {
token: TEST_TOKEN,
body: { ...body, fileId, attachmentRecordId: uploaded.id },
});
expect(status).toBe(fixture.responseStatus);
const { data } = await req(t.app, 'GET', `/records/${record.id}/associations`, {
token: TEST_TOKEN,
});
// attachmentRecordId travels verbatim, in both directions — the pointer
// annotates the reference rather than identifying it.
expect((data as { associations: unknown[] }).associations).toEqual([
{ ...body, fileId, attachmentRecordId: uploaded.id },
]);
});

test('coverage', () => {
assertCoverage(
associateFixtures.map((f) => f.name),
Expand Down Expand Up @@ -750,6 +780,35 @@ describe('dissociate fixtures', () => {
expect((data as { associations: unknown[] }).associations).toEqual([]);
});

test('dissociate-attachment-by-identity', async () => {
const fixture = dissociateFixtures.find((f) => f.name === 'dissociate-attachment-by-identity')!;
handled.add(fixture.name);
const body = fixture.requestBody as Association & { kind: 'attachment'; fileId: string };
const uploaded = await t.ctx.stack.putAttachment(
new Uint8Array([1, 2, 3]),
'image/png',
'embed.png',
);
const fileId = (uploaded.content as { fileId: string }).fileId;
// The stored association carries an attachmentRecordId, but identity
// stays (kind, label, fileId) — dissociating without the pointer still
// matches and removes it.
const record = await t.ctx.stack.create(
NOTE_TYPE,
{ title: 'x' },
{ associations: [{ ...body, fileId, attachmentRecordId: uploaded.id }] },
);
const { status } = await req(t.app, 'POST', `/records/${record.id}/associations/delete`, {
token: TEST_TOKEN,
body: { ...body, fileId },
});
expect(status).toBe(fixture.responseStatus);
const { data } = await req(t.app, 'GET', `/records/${record.id}/associations`, {
token: TEST_TOKEN,
});
expect((data as { associations: unknown[] }).associations).toEqual([]);
});

test('coverage', () => {
assertCoverage(
dissociateFixtures.map((f) => f.name),
Expand Down
Loading