Skip to content

Commit 0bc685a

Browse files
baozhoutaoclaude
andauthored
fix(approvals+storage): decision attachments — name, open, correct download filename (#3504) (#3505)
* fix(approvals+storage): surface decision attachments with name, open, and correct download filename (#3504) The approval inbox timeline showed a nameless "附件" chip that did nothing on click, and even the downloaded file was named after the opaque URL token. Two root causes, both in the framework: approvals — `sys_approval_action.attachments` (a `Field.file`) stores rich descriptors `{ id, name, url, mimeType, size }`, but `rowFromAction` mapped them with `.map(String)`, collapsing each to "[object Object]". `ApprovalActionRow. attachments` is now `ApprovalActionAttachment[]`; the descriptor carries name + url, so consumers label/open attachments without reading the system `sys_file`. storage — presigned downloads served `application/octet-stream` with no `Content-Disposition`. `getSignedUrl`/`getPresignedDownload` now take `PresignedDownloadOptions { filename, contentType, disposition }`; the REST download routes pass the `sys_file` name+mime; the local adapter carries them in the token and `_local/raw` emits an RFC 5987 Content-Disposition; S3 bakes the same into the signed URL. Default `inline` preserves in-browser preview. Verified end-to-end in app-showcase. Refs objectui #2820. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(spec): regenerate API surface snapshot for the two added interfaces Additive only (0 breaking): ApprovalActionAttachment + PresignedDownloadOptions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(approvals): correct the attachment read-shape rationale and share the id rule Follow-up on this PR's own change, after ADR-0104 D3 wave 2 landed on main. The fix in this PR is right; its stated cause was inverted. It says the `sys_approval_action.attachments` column "stores rich descriptors — a fileId is resolved to a full descriptor on write". The code says otherwise: approval-service.ts writes `input.attachments` verbatim (a fileId string[]), and there is no write-side descriptor resolution anywhere in the repo. What actually happens is that the column stores an opaque sys_file id — the stored form of every media field — and the ObjectQL read path expands it into `{ id, name, size, mimeType, url }` on the way out. That resolver was already in this PR's base commit, so the descriptors observed in listActions came from the read path, not from storage. Left as written, that inverted account would have been a false statement about storage sitting in the protocol contract, in the changeset, and in a regression test comment — and PR-5a has since made the stored form explicitly an id, so it would also have been contradicted by the schema. Corrected in all three places. Also names the three read forms the normalizer actually handles, rather than one: the expanded value (normal), a bare id (nothing to expand it into), and a legacy inline blob written before the cutover, whose keys are snake_case (`file_id`, `mime_type`). The last of those had no test; it has one now. The id-token test reuses `isFileIdToken` from @objectstack/spec/data — the platform's single arbiter of "opaque id or URL?" — so this normalizer and the engine's read resolver cannot drift apart on that question, and a non-id string is now surfaced as a url rather than mislabelled an id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd * docs(storage): record the presigned-download options in the hand-written contract docs The drift check flagged these: both storage-service pages transcribe IStorageService by hand, and this PR adds an optional PresignedDownloadOptions argument to getSignedUrl / getPresignedDownload. Left alone they would describe a signature the code no longer has — the same declared-vs-actual gap the rest of this PR is about, one level up in the docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent deb538f commit 0bc685a

17 files changed

Lines changed: 349 additions & 30 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/plugin-approvals": patch
4+
---
5+
6+
fix(approvals): return decision attachments as file values, not "[object Object]" (#3504)
7+
8+
`sys_approval_action.attachments` is a `Field.file`, so the column **stores an
9+
opaque `sys_file` id** (ADR-0104 D3 — the stored form of every media field). The
10+
ObjectQL read path resolves that id into its expanded
11+
`{ id, name, size, mimeType, url }` form on the way out. But `rowFromAction`
12+
mapped the column with `.map(String)`, collapsing each expanded value to the
13+
literal string `"[object Object]"`. Every `listActions` consumer (the approval
14+
inbox timeline) then received garbage: the attachment chip had no filename and
15+
its id was `"[object Object]"`, so opening it 404'd.
16+
17+
- `ApprovalActionRow.attachments` is now `ApprovalActionAttachment[]` — the
18+
expanded file value plus its id, so a consumer can label and open an
19+
attachment without needing read access to the system `sys_file` object (which
20+
regular approvers do not have).
21+
- Three read forms are accepted: the expanded value (the normal case), a bare id
22+
(nothing to expand it into — storage service absent, file not committed), and
23+
a legacy inline blob written before file-as-reference (`file_id` /
24+
`mime_type`), until the backfill converts it. The id test reuses the
25+
platform's `isFileIdToken`, so this and the engine's read resolver cannot
26+
disagree about what counts as an id.
27+
- The decision *input* (`ApprovalDecisionInput.attachments`) is unchanged — it
28+
still takes fileId strings, which is also exactly what the column stores. Only
29+
the read shape changed.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/service-storage": patch
4+
---
5+
6+
fix(storage): downloads carry the real filename + content-type, not the URL token (#3504)
7+
8+
A presigned download served the bytes as `application/octet-stream` with no
9+
`Content-Disposition`, so a browser saved the file under the opaque URL token
10+
(e.g. `eyJrIjoiYXR0YWNo…`) instead of its real name — an approval's
11+
`signed-contract.pdf` downloaded as a nameless blob.
12+
13+
- `IStorageService.getSignedUrl` / `getPresignedDownload` take an optional
14+
`PresignedDownloadOptions` (`filename`, `contentType`, `disposition`).
15+
- The REST download routes (`GET /storage/files/:id/url` and `/:id`) pass the
16+
`sys_file` record's `name` + `mime_type`.
17+
- The local adapter carries them in the signed token; the `_local/raw` route
18+
emits `Content-Type` + an RFC 5987 `Content-Disposition` (ASCII fallback +
19+
`filename*=UTF-8''…` for non-ASCII names). The S3 adapter bakes the same into
20+
the signed URL via `ResponseContentType` / `ResponseContentDisposition`.
21+
- Default disposition is `inline`, so previewable types (PDF, images) still open
22+
in the browser — now with the correct name when saved.

.claude/launch.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,24 @@
1818
"file:/tmp/showcase-dogfood-3777/data.db"
1919
],
2020
"port": 3777
21+
},
22+
{
23+
"name": "pr3505-attach-3477",
24+
"runtimeExecutable": "pnpm",
25+
"runtimeArgs": [
26+
"-C",
27+
"/home/user/objectstack-pr3505/examples/app-showcase",
28+
"exec",
29+
"objectstack",
30+
"dev",
31+
"--ui",
32+
"--seed-admin",
33+
"-p",
34+
"3477",
35+
"-d",
36+
"file:/tmp/pr3505-attach/data.db"
37+
],
38+
"port": 3477
2139
}
2240
]
2341
}

content/docs/kernel/contracts/storage-service.mdx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,19 @@ export interface IStorageService {
2929
list?(prefix: string): Promise<StorageFileInfo[]>;
3030

3131
// Signed URL (optional)
32-
getSignedUrl?(key: string, expiresIn: number): Promise<string>;
32+
getSignedUrl?(key: string, expiresIn: number, options?: PresignedDownloadOptions): Promise<string>;
3333

3434
// Presigned browser-direct upload / download (optional)
3535
getPresignedUpload?(
3636
key: string,
3737
expiresIn: number,
3838
options?: StorageUploadOptions,
3939
): Promise<PresignedUploadDescriptor>;
40-
getPresignedDownload?(key: string, expiresIn: number): Promise<PresignedDownloadDescriptor>;
40+
getPresignedDownload?(
41+
key: string,
42+
expiresIn: number,
43+
options?: PresignedDownloadOptions,
44+
): Promise<PresignedDownloadDescriptor>;
4145

4246
// Chunked / multipart upload (optional)
4347
initiateChunkedUpload?(key: string, options?: StorageUploadOptions): Promise<string>;

content/docs/kernel/runtime-services/storage-service.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@ services.storage.delete(key: string): Promise<void>
1717
services.storage.exists(key: string): Promise<boolean>
1818
services.storage.getInfo(key: string): Promise<StorageFileInfo>
1919
services.storage.list?(prefix: string): Promise<StorageFileInfo[]>
20-
services.storage.getSignedUrl?(key: string, expiresIn: number): Promise<string>
20+
services.storage.getSignedUrl?(key: string, expiresIn: number, options?: PresignedDownloadOptions): Promise<string>
2121
```
2222
2323
## Presigned / Chunked (optional)
2424
2525
```ts
2626
services.storage.getPresignedUpload?(key: string, expiresIn: number, options?: StorageUploadOptions): Promise<PresignedUploadDescriptor>
27-
services.storage.getPresignedDownload?(key: string, expiresIn: number): Promise<PresignedDownloadDescriptor>
27+
services.storage.getPresignedDownload?(key: string, expiresIn: number, options?: PresignedDownloadOptions): Promise<PresignedDownloadDescriptor>
2828
services.storage.initiateChunkedUpload?(key: string, options?: StorageUploadOptions): Promise<string>
2929
services.storage.uploadChunk?(uploadId: string, partNumber: number, data: Buffer): Promise<string>
3030
services.storage.completeChunkedUpload?(uploadId: string, parts: Array<{ partNumber: number; eTag: string }>): Promise<string>

packages/plugins/plugin-approvals/src/approval-service.test.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1969,15 +1969,58 @@ describe('ApprovalService — decision_progress & deep links (#2678 P1.5)', () =
19691969
// listActions must surface decision attachments through the contract mapping
19701970
// (#3266 — the column existed but rowFromAction dropped it; caught in browser).
19711971
describe('ApprovalService — listActions attachments mapping (#3266)', () => {
1972-
it('returns the attachments recorded on a decision', async () => {
1972+
it('normalizes a bare fileId string into an attachment descriptor', async () => {
19731973
const engine = makeFakeEngine();
19741974
let n = 0;
19751975
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
19761976
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
19771977
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_a'] }, SYS);
19781978
const acts = await svc.listActions(req.id, SYS);
19791979
const approve = acts.find(a => a.action === 'approve');
1980-
expect(approve?.attachments).toEqual(['file_a']);
1980+
expect(approve?.attachments).toEqual([{ id: 'file_a' }]);
1981+
});
1982+
1983+
// The normal case. The column STORES an opaque sys_file id (ADR-0104 D3);
1984+
// the ObjectQL read path resolves it into the expanded
1985+
// `{ id, name, size, mimeType, url }` form on the way out. The old
1986+
// `.map(String)` turned that object into "[object Object]", so the inbox chip
1987+
// had no name and 404'd on open. The mapping must pass it through unmangled.
1988+
it('passes the engine-expanded file value through with its name and url', async () => {
1989+
const engine = makeFakeEngine();
1990+
let n = 0;
1991+
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
1992+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
1993+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
1994+
// Simulate what the read path hands back after resolving the stored id.
1995+
const row = engine._tables['sys_approval_action'].find((a: any) => a.action === 'approve');
1996+
row.attachments = [
1997+
{ id: 'file_a', name: 'signed-contract.pdf', mimeType: 'application/pdf', size: 24, url: '/api/v1/storage/files/file_a' },
1998+
];
1999+
const acts = await svc.listActions(req.id, SYS);
2000+
const approve = acts.find(a => a.action === 'approve');
2001+
expect(approve?.attachments).toEqual([
2002+
{ id: 'file_a', name: 'signed-contract.pdf', mimeType: 'application/pdf', size: 24, url: '/api/v1/storage/files/file_a' },
2003+
]);
2004+
});
2005+
2006+
// Rows written before file-as-reference hold an inline blob whose keys are
2007+
// snake_case (`file_id`, `mime_type`). They stay readable until the backfill
2008+
// converts them, so both casings must map — the same drift that made objectui
2009+
// stop recognising images when the expanded form arrived.
2010+
it('maps a legacy inline blob (file_id / mime_type) written before the cutover', async () => {
2011+
const engine = makeFakeEngine();
2012+
let n = 0;
2013+
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
2014+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
2015+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
2016+
const row = engine._tables['sys_approval_action'].find((a: any) => a.action === 'approve');
2017+
row.attachments = [
2018+
{ file_id: 'file_b', name: 'old.pdf', mime_type: 'application/pdf', size: 12, url: 'https://cdn/old.pdf' },
2019+
];
2020+
const acts = await svc.listActions(req.id, SYS);
2021+
expect(acts.find(a => a.action === 'approve')?.attachments).toEqual([
2022+
{ id: 'file_b', name: 'old.pdf', mimeType: 'application/pdf', size: 12, url: 'https://cdn/old.pdf' },
2023+
]);
19812024
});
19822025
});
19832026

packages/plugins/plugin-approvals/src/approval-service.ts

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
IApprovalService,
2020
ApprovalRequestRow,
2121
ApprovalActionRow,
22+
ApprovalActionAttachment,
2223
ApprovalDecisionInput,
2324
ApprovalDecisionResult,
2425
ApprovalRecallInput,
@@ -30,6 +31,7 @@ import type {
3031
ApprovalStatus,
3132
SharingExecutionContext,
3233
} from '@objectstack/spec/contracts';
34+
import { isFileIdToken } from '@objectstack/spec/data';
3335
import { isGrantActive } from '@objectstack/core';
3436

3537
/**
@@ -255,7 +257,58 @@ function slaDueAt(createdAt: unknown, cfg: any): string | undefined {
255257
return new Date(t + hours * 3600_000).toISOString();
256258
}
257259

260+
/**
261+
* Normalize one raw `attachments` entry into an {@link ApprovalActionAttachment}.
262+
*
263+
* `sys_approval_action.attachments` is a `Field.file` (multiple), so the column
264+
* **stores opaque `sys_file` ids** — that is the stored form of every media
265+
* field (ADR-0104 D3). What arrives here is whichever of three forms the read
266+
* path produced:
267+
*
268+
* 1. the **expanded** `{ id, name, size, mimeType, url }` the ObjectQL read
269+
* path resolves a stored id into — the normal case;
270+
* 2. a **bare id**, when there was nothing to expand it into (storage service
271+
* absent, file not committed);
272+
* 3. a **legacy inline blob** (`{ file_id, name, mime_type, url, … }`) written
273+
* before file-as-reference, until the backfill converts it.
274+
*
275+
* The original mapping did `String(entry)`, which turned form 1 into the
276+
* literal `"[object Object]"` — so the inbox timeline showed a nameless,
277+
* un-openable attachment chip (#3266 follow-up; caught by browser verification).
278+
*
279+
* Note the casing: the expanded form carries `mimeType`, the legacy blob
280+
* `mime_type`. Both are accepted for the duration of the migration window.
281+
*/
282+
function normalizeActionAttachment(entry: any): ApprovalActionAttachment | undefined {
283+
if (entry == null) return undefined;
284+
// Form 2 — a bare reference. `isFileIdToken` is the platform's single arbiter
285+
// of "is this string an opaque file id, or a URL?", shared with the engine's
286+
// read resolver, so the two cannot disagree about what counts as an id.
287+
if (typeof entry === 'string') {
288+
const id = entry.trim();
289+
if (!id) return undefined;
290+
return isFileIdToken(id) ? { id } : { id, url: id };
291+
}
292+
if (typeof entry === 'object') {
293+
// Forms 1 and 3 — `file_id` is the legacy blob's key for the same thing.
294+
const id = entry.id ?? entry.file_id;
295+
if (id == null || String(id) === '') return undefined;
296+
const mimeType = entry.mimeType ?? entry.mime_type;
297+
return {
298+
id: String(id),
299+
name: typeof entry.name === 'string' ? entry.name : undefined,
300+
url: typeof entry.url === 'string' ? entry.url : undefined,
301+
mimeType: typeof mimeType === 'string' ? mimeType : undefined,
302+
size: typeof entry.size === 'number' ? entry.size : undefined,
303+
};
304+
}
305+
return undefined;
306+
}
307+
258308
function rowFromAction(row: any): ApprovalActionRow {
309+
const attachments = Array.isArray(row.attachments)
310+
? row.attachments.map(normalizeActionAttachment).filter((a: ApprovalActionAttachment | undefined): a is ApprovalActionAttachment => !!a)
311+
: [];
259312
return {
260313
id: String(row.id),
261314
request_id: String(row.request_id),
@@ -264,10 +317,9 @@ function rowFromAction(row: any): ApprovalActionRow {
264317
action: row.action,
265318
actor_id: row.actor_id ?? undefined,
266319
comment: row.comment ?? undefined,
267-
// Decision attachments (#3266). The column shipped in #3268 but this
268-
// contract mapping didn't — the raw engine row carried the fileIds while
269-
// every consumer of listActions saw none (caught by browser verification).
270-
attachments: Array.isArray(row.attachments) && row.attachments.length ? row.attachments.map(String) : undefined,
320+
// Decision attachments (#3266): rich descriptors carrying the display name +
321+
// download URL, so consumers label/open them without reading `sys_file`.
322+
attachments: attachments.length ? attachments : undefined,
271323
created_at: row.created_at ?? undefined,
272324
};
273325
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { contentDispositionValue } from './content-disposition.js';
5+
6+
describe('contentDispositionValue', () => {
7+
it('defaults to inline and emits both filename and filename*', () => {
8+
expect(contentDispositionValue('signed-contract.pdf')).toBe(
9+
"inline; filename=\"signed-contract.pdf\"; filename*=UTF-8''signed-contract.pdf",
10+
);
11+
});
12+
13+
it('honors an attachment disposition', () => {
14+
expect(contentDispositionValue('report.csv', 'attachment')).toBe(
15+
"attachment; filename=\"report.csv\"; filename*=UTF-8''report.csv",
16+
);
17+
});
18+
19+
it('keeps a non-ASCII name in filename* and sanitizes the ASCII fallback', () => {
20+
const v = contentDispositionValue('季度报告.pdf');
21+
// ASCII fallback replaces each of the 4 non-ascii chars; filename* preserves them.
22+
expect(v).toContain('filename="____.pdf"');
23+
expect(v).toContain("filename*=UTF-8''%E5%AD%A3%E5%BA%A6%E6%8A%A5%E5%91%8A.pdf");
24+
});
25+
26+
it('neutralizes quotes/backslashes in the ASCII fallback (no header injection)', () => {
27+
const v = contentDispositionValue('a"b\\c.txt');
28+
expect(v).toContain('filename="a_b_c.txt"');
29+
});
30+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Build a `Content-Disposition` header value that survives non-ASCII filenames.
5+
*
6+
* Emits both a sanitized ASCII `filename=` (legacy fallback) and the RFC 5987
7+
* `filename*=UTF-8''…` form that modern browsers prefer — so a download saves
8+
* under its real name instead of the opaque URL token. Used by the local
9+
* adapter's `_local/raw` route (header) and the S3 adapter's
10+
* `ResponseContentDisposition` (baked into the signed URL).
11+
*/
12+
export function contentDispositionValue(
13+
filename: string,
14+
type: 'inline' | 'attachment' = 'inline',
15+
): string {
16+
const asciiFallback = filename.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
17+
const encoded = encodeURIComponent(filename).replace(
18+
/['()*]/g,
19+
(c) => '%' + c.charCodeAt(0).toString(16).toUpperCase(),
20+
);
21+
return `${type}; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
22+
}

packages/services/service-storage/src/local-storage-adapter.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,21 @@ describe('LocalStorageAdapter', () => {
151151
expect(desc.downloadUrl).toContain('/_local/raw/');
152152
expect(desc.expiresIn).toBe(60);
153153
});
154+
155+
it('carries filename + content-type into the download token (so the browser saves the real name)', async () => {
156+
await createTempDir();
157+
await adapter.upload('attachments/abc.pdf', Buffer.from('%PDF-'));
158+
const desc = await adapter.getPresignedDownload!('attachments/abc.pdf', 60, {
159+
filename: 'signed-contract.pdf',
160+
contentType: 'application/pdf',
161+
disposition: 'inline',
162+
});
163+
const token = desc.downloadUrl.split('/_local/raw/')[1];
164+
const payload = adapter.verifyToken(token, 'get');
165+
expect(payload.n).toBe('signed-contract.pdf');
166+
expect(payload.ct).toBe('application/pdf');
167+
expect(payload.d).toBe('inline');
168+
});
154169
});
155170

156171
// =========================================================================

0 commit comments

Comments
 (0)