diff --git a/docs/api.md b/docs/api.md index 527abd9..80c54d3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 | diff --git a/package.json b/package.json index 8120fe4..2c0c048 100644 --- a/package.json +++ b/package.json @@ -41,10 +41,10 @@ "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", @@ -52,7 +52,7 @@ }, "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 438e843..3e62b61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,17 +9,17 @@ importers: .: dependencies: '@haverstack/adapter-local': - specifier: ^0.30.0 - version: 0.30.0 + specifier: ^0.31.0 + version: 0.31.0 '@haverstack/commons': - specifier: ^0.25.0 - version: 0.25.0 + specifier: ^0.26.0 + version: 0.26.0 '@haverstack/core': + specifier: ^0.32.0 + version: 0.32.0 + '@haverstack/wire-types': specifier: ^0.31.0 version: 0.31.0 - '@haverstack/wire-types': - specifier: ^0.30.0 - version: 0.30.0 '@hono/node-server': specifier: ^2.1.1 version: 2.1.1(hono@4.13.7) @@ -37,8 +37,8 @@ importers: specifier: ^10.0.1 version: 10.0.1(eslint@10.8.1) '@haverstack/conformance-fixtures': - specifier: ^0.24.0 - version: 0.24.0 + specifier: ^0.25.0 + version: 0.25.0 '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -264,28 +264,28 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@haverstack/adapter-local@0.30.0': - resolution: {integrity: sha512-18dqqtB36qoS44hxO/14aqKo+ngajZOFJ0nVZ2qh+BmmBPgNNaG9lNhXO9PIvVEPvJtE4kxdJC9/+uCmtUCbOg==} + '@haverstack/adapter-local@0.31.0': + resolution: {integrity: sha512-RwUadZDm9jRc++DPni8TPsLnYGI/7gY1kdn9b2kXoStkFb8IPnlFp6ZBApnKGUCVkqsoXqvX6ET6dOX2Lnn6kA==} engines: {node: '>=22.5.0'} - '@haverstack/blob-adapter-disk@0.29.0': - resolution: {integrity: sha512-P77ell2zy4UAoPj1ZRRBOwfwpdURusCqU93mBcVIav+hf2kRR0D1Hq+KdxPX2Ft7c5jdMFh5z+IaGyXpMpxcAw==} + '@haverstack/blob-adapter-disk@0.30.0': + resolution: {integrity: sha512-uvPKY5GImj7RNslw/HESWXd4KvhVIKuJ6ZIIi7kXKGemoWuFDxBo0cuvJxo3Qvwfr9pElgb1lQfyXfRp0I4syQ==} - '@haverstack/commons@0.25.0': - resolution: {integrity: sha512-1MwoTPcaEuOv75bm8dbYWg/PVHI4oABpmTVZ6XpervUGyYJLDuLDW5pRgz2/LsGJZM5NHRBNQyca52d99zgPcA==} + '@haverstack/commons@0.26.0': + resolution: {integrity: sha512-BFOmiMDzV9NhOuSK4fZv47/ooBw+kDR9ME9sZoA2sdXD4pbt3Uek3kJE0JVtDkmLm7ZyK2frNg9lJp/+kGrAcg==} - '@haverstack/conformance-fixtures@0.24.0': - resolution: {integrity: sha512-XXG9WYKK/dtHwhU1/kzWeZn0Viflj0YmNwZ6cVbKPIMDiF5iKFB/d2GS55pzz0tiNQO4IpGbe0wT5AzPG+P5KA==} + '@haverstack/conformance-fixtures@0.25.0': + resolution: {integrity: sha512-IjLPUJh4Zr+JfpJ2gy0WZKmAS2tybK1/4rtytGB+LYm6hj2/K/ZXhDCjKmfFn0WYCQeVvbB+8j2RSnCFOW6GpA==} - '@haverstack/core@0.31.0': - resolution: {integrity: sha512-r/cCuTE+39oEJ7ib6gLc2yoMrK2+9QfVWDX2QhdlG77msxZDLjijcx6djNrDbaZRUo1heCIDpgJunSUhjOVG+Q==} + '@haverstack/core@0.32.0': + resolution: {integrity: sha512-IPd3I9vJg3334MO+QLynGN+hULT0GG3Fv5aqvilFajJp3T4pyL1jfAQuYCD4I3ZuVZMMdsIjvQjFVB3F9v+xMg==} - '@haverstack/record-adapter-sqlite@0.23.0': - resolution: {integrity: sha512-z+Cd6qp4D3U/2ezlf5U3R5nFYTFMpF4Ax8ipfjc0oXK1yQdRc9zWquvO5sKI8sEELBYv5r8V8rjJwqkkn54NOA==} + '@haverstack/record-adapter-sqlite@0.24.0': + resolution: {integrity: sha512-8to2k+i9ydCp/O0+SdEB5Pz6YbD6sG5NRo7pXySU4tIuG/+9iiWQ4fHL0Ig0ErYcpvIA/LjM9vMTthzJqFiPow==} engines: {node: '>=22.5.0'} - '@haverstack/wire-types@0.30.0': - resolution: {integrity: sha512-SGMDol6lVi0JJUCnw4bGzohYu2HF0rSU/dmUPlCvh5/IapGr9SXIklPEcVhlH3ycf8e45rxWjksp4B/ajzPoLg==} + '@haverstack/wire-types@0.31.0': + resolution: {integrity: sha512-XoTsIfP3u72h7VMF9fq5KQi9qOvYKm3yIONdf6eNcFAnLjTk+fzylGFOISUXlUctLFWuay3xXMEHN1IWGavB5g==} '@hono/node-server@2.1.1': resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} @@ -1261,33 +1261,33 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@haverstack/adapter-local@0.30.0': + '@haverstack/adapter-local@0.31.0': dependencies: - '@haverstack/blob-adapter-disk': 0.29.0 - '@haverstack/core': 0.31.0 - '@haverstack/record-adapter-sqlite': 0.23.0 + '@haverstack/blob-adapter-disk': 0.30.0 + '@haverstack/core': 0.32.0 + '@haverstack/record-adapter-sqlite': 0.24.0 - '@haverstack/blob-adapter-disk@0.29.0': + '@haverstack/blob-adapter-disk@0.30.0': dependencies: - '@haverstack/core': 0.31.0 + '@haverstack/core': 0.32.0 - '@haverstack/commons@0.25.0': + '@haverstack/commons@0.26.0': dependencies: - '@haverstack/core': 0.31.0 + '@haverstack/core': 0.32.0 - '@haverstack/conformance-fixtures@0.24.0': + '@haverstack/conformance-fixtures@0.25.0': dependencies: - '@haverstack/wire-types': 0.30.0 + '@haverstack/wire-types': 0.31.0 - '@haverstack/core@0.31.0': {} + '@haverstack/core@0.32.0': {} - '@haverstack/record-adapter-sqlite@0.23.0': + '@haverstack/record-adapter-sqlite@0.24.0': dependencies: - '@haverstack/core': 0.31.0 + '@haverstack/core': 0.32.0 - '@haverstack/wire-types@0.30.0': + '@haverstack/wire-types@0.31.0': dependencies: - '@haverstack/core': 0.31.0 + '@haverstack/core': 0.32.0 '@hono/node-server@2.1.1(hono@4.13.7)': dependencies: diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts index a387ef3..d36ec5d 100644 --- a/src/routes/attachments.ts +++ b/src/routes/attachments.ts @@ -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, @@ -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'); @@ -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)}` diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index 0973845..1bd30f8 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -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), @@ -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),