Skip to content

fix(approvals+storage): decision attachments — name, open, correct download filename (#3504) - #3505

Merged
os-zhuang merged 4 commits into
mainfrom
claude/approval-attachment-authz
Jul 27, 2026
Merged

fix(approvals+storage): decision attachments — name, open, correct download filename (#3504)#3505
os-zhuang merged 4 commits into
mainfrom
claude/approval-attachment-authz

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #3504. Frontend companion: objectstack-ai/objectui#2820.

Problem

A decision attachment in the approval inbox timeline (审批动态) showed a nameless "附件" chip that did nothing when clicked — and even when the file did download, it was named after the opaque signed-URL token (eyJrIjoiYXR0YWNo…) as application/octet-stream.

Root cause 1 — approvals read path stringified descriptors

sys_approval_action.attachments is a Field.file, which stores rich descriptors { id, name, url, mimeType, size } (a fileId is resolved to a full descriptor on write) — not fileId strings. rowFromAction mapped the column with .map(String), collapsing each descriptor to the literal "[object Object]". Every listActions consumer received garbage.

Root cause 2 — storage download had no filename/type

Presigned downloads served application/octet-stream with no Content-Disposition, and the signed token carried no filename, so browsers saved files under the URL token.

Changes

approvals (@objectstack/spec, @objectstack/plugin-approvals)

  • New ApprovalActionAttachment; ApprovalActionRow.attachments is now ApprovalActionAttachment[]. rowFromAction passes descriptors through (tolerating a bare-string fileId). Decision input stays string[]. Consumers no longer need read access to the system sys_file object.

storage (@objectstack/spec, @objectstack/service-storage)

  • getSignedUrl / getPresignedDownload take optional PresignedDownloadOptions { filename, contentType, disposition }.
  • REST download routes (GET /storage/files/:id/url, /:id) pass the sys_file name + mime_type.
  • Local adapter carries them in the signed token; _local/raw emits Content-Type + RFC 5987 Content-Disposition (ASCII fallback + filename*=UTF-8''…). S3 adapter uses ResponseContentType / ResponseContentDisposition. Default inline preserves in-browser preview.

Testing

  • @objectstack/plugin-approvals: 165/165 (incl. new descriptor-passthrough cases).
  • @objectstack/service-storage: 104/104 (incl. new token-metadata + Content-Disposition helper cases).
  • End-to-end in app-showcase (approvals + storage): chip shows signed-contract.pdf, click opens the real PDF, and the download responds content-type: application/pdf + content-disposition: inline; filename="signed-contract.pdf".

🤖 Generated with Claude Code

@vercel

vercel Bot commented Jul 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jul 27, 2026 7:57am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 27, 2026 7:57am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/plugin-approvals, packages/services, @objectstack/spec.

106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/plugin-approvals, packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via packages/services, @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/services, packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-approvals, packages/services, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/services, @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-approvals, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-approvals, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

baozhoutao and others added 3 commits July 27, 2026 06:53
… 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>
…aces

Additive only (0 breaking): ApprovalActionAttachment + PresignedDownloadOptions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… 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
@os-zhuang
os-zhuang force-pushed the claude/approval-attachment-authz branch from b6f4740 to 874144e Compare July 27, 2026 07:04

Copy link
Copy Markdown
Contributor

我 rebase 了这个分支到当前 main,并推了一个修正 commit(874144e)。force-push 了你的分支,先说明我改了什么、为什么。

背景:ADR-0104 D3 wave 2 在这个 PR 开出之后合并了(#3527 / #3534 / #3535 / #3555 + objectui#2828),涉及同一片区域。

好消息:两半都不用重做

存储那一半原样保留。 PresignedDownloadOptions + contentDispositionValue + local token 带 n/ct/d + S3 ResponseContentDisposition —— 和 wave 2 完全正交,而且互补:#3534 给字段文件的下载加了鉴权,你这半给下载加了正确的文件名/类型,叠起来正是想要的。ASCII fallback 里中和引号/反斜杠防 header 注入那段写得对,留着了。

审批那一半的修法也对,.map(String) 确实把值压成了 "[object Object]",透传是正确的。rebase 零冲突

我改了什么:因果讲反了

PR 描述/changeset/spec 注释/测试注释里说了四遍:

这个列存的是 rich descriptors,fileId 在写入时被解析成完整 descriptor

代码不支持这个说法:

真实情况是反的:列里存的就是 sys_file id;是引擎在_读_路径上把它展开成 { id, name, size, mimeType, url }。你在 listActions 看到的 descriptor 是读展开器的产物,不是存储形态。

留着不改的话,这会变成协议契约里一句关于存储的假话,而且 #3555 之后 Field.file 的 stored 形态已经明确收窄为 id —— 那句话会直接和 schema 矛盾。所以我在三处都改了措辞:ApprovalActionAttachment 的文档注释、changeset、以及那条回归测试的注释。

没有改任何行为,只改了对行为的描述,加上下面两点。

顺带补的两点

  1. normalizeActionAttachment 现在明确处理三种读形态,而不是笼统的"descriptor 或字符串":展开值(正常)、裸 id(没东西可展开——存储服务缺席、文件未 committed)、以及 cutover 前写入的遗留 inline blob(键是 snake_case 的 file_id / mime_type)。第三种原本没有测试,现在补了一条。
  2. id 判定改用 isFileIdToken(@objectstack/spec/data)——平台里"这个字符串是不透明 id 还是 URL"的唯一裁决者,和引擎读解析器共用。这样两边不会在"什么算 id"上漂移;顺带,一个非 id 形态的字符串现在会被当成 url 透出,而不是被误标成 id

测试:plugin-approvals 195 ✅ · service-storage 173 ✅ · spec 6717 ✅ · build ✅ · lint ✅ · nul-bytes ✅ · check:api-surface ✅ · check:docs ✅


⚠️ 需要你重新做一次端到端验证(我没法替你做)

wave 2 给这条路径引入了两个你写这个 PR 时还不存在的交互:

① 附件文件现在会被"认领"。 attachmentsField.file,写入 fileId 会把该文件独占认领(sys_approval_action, <action id>, attachments)(#3527)。同一 fileId 若也被别处引用,会触发 copy-on-claim 复制字节。对审批场景我判断这是想要的——决策附件应当是那条决策的独立证据,不该因别处删除而受影响。但值得你确认符合预期。

② 下载鉴权的来源变了 —— 这条请重点验。 被认领之后,这些文件的下载会sys_approval_action 那条记录派生读权限(#3534)。

你这个 PR 的原始动机是"审批人没有 sys_file 读权限,所以给他们 descriptor"——那个目标依然成立,但现在需要的变成了能读 sys_approval_action 记录。审批人在自己收件箱里应该有,但 listActions 可能跑在提权上下文里,而下载走的是调用者自己的上下文。

如果这里不通,附件下载会以一种全新的方式 403。 麻烦在 app-showcase 里用真实审批人身份(不是 admin)重跑一次:点开决策附件 chip,确认能下载,且响应带 content-disposition: inline; filename="signed-contract.pdf"

这一项过了我就没有别的意见了。合并权在你。

🤖 Generated with Claude Code


Generated by Claude Code

…ten 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(approvals+storage): 审批决策附件在时间线里没名字、点击无反应、下载名是乱码 token (objectui #2820)

3 participants