fix(api): scope attachment.resolve to visible cloud workspaces and reap files with the box
What & why
Part of SUPER-2874. This is the first API step of the attachment plan (seam 6, "PR 2"). It is API only, with no client changes.
1. attachment.resolve has a tighter scope. Before this change, any org member could get a signed URL for any composer upload in the org, including files sent to a cloud workspace that is private to someone else. The rule is now (router/attachment/resolvable.ts):
- A box: only the files anchored to its own workspace. This is unchanged.
- A file kept on a workspace (
ready): only through a cloud workspace the caller can see (visibleTo: org-visible, created by the caller, or shared with the caller). - An upload still in flight (
pending): org-wide, the same as before. This is different from the plan, which says "the caller uploaded it". A host callsresolvewith its owner's credential, and the person sending can be a teammate on that host (v2_users_hostsmembers). Mobile → teammate's hostimportFromCloudand #8704'smaterializeIntoWorkspacewould both break under an uploader-only rule.
2. New attachment.resolveEach. It takes the same input and scope. It returns one entry per id, in order, and a file that is gone or not visible comes back as { fileId, missing: true }. This is for seam 5 (a transcript renders the files it can and shows a badge for the rest). The plan changes resolve's output, but released host-service builds read .url from every entry, so resolve keeps its output and its NOT_FOUND / BAD_REQUEST errors (trpc-compat rule 5). resolveEach is not in SANDBOX_ALLOWED_PROCEDURES, because a box has no use for it yet.
3. Cloud workspace files are reaped with the box. When the archive's final reap stage deletes the sandbox, it now runs detachAll + reapOrphanFiles for the files anchored to the workspace. The fallback in cloudWorkspace.delete (used when the reap cannot be queued) does the same. Within the 7-day grace period an unarchive keeps its files. If the reap fails, the error is logged, the same as the final settle. Objects whose row delete did not finish are still reclaimed by sweepOrphanFiles.
tRPC compatibility
resolve: the input, output, error codes and procedure type are unchanged. Auth is stricter only forreadyfiles. Released callers ofresolve(desktop 1.34–1.39 and cli 1.36–1.39 tags, mobile on main): desktopRichText/uploadFileresolves its ownpendingupload right away; host-serviceimportFromCloud/materializeIntoWorkspaceresolvependinguploads, or run in a box assandboxCaller; mobile cloud chat callsattachFilesand then imports on the box (sandboxCaller). None of them resolves areadyfile as a non-box caller.resolveEach: a new procedure, so the change is additive.
Not in this PR
- Archives on retired (non-vercel) providers queue no reap, so they still keep their files.
chat_sessionscoping waits for the durable session store.
How I tested it
packages/trpc:bun testpasses (675 tests),bun run typecheckpasses, and biome is clean.reap.test.ts: the delete stage reaps the workspace's files. The stop stage, an unarchived workspace and a later archive do not.resolvable.integration.ts(a manual DB test, likesweep.integration.ts): I ran it against this workspace's Neon branch and all 5 tests pass. It covers: a teammate resolves a pending upload; a ready file resolves through an org-visible workspace but not through ajust_meworkspace for a teammate; a ready file anchored to no cloud workspace does not resolve; a box resolves only its own files; a page asset never resolves.- I did not test this end to end in the running app.
Checklist
- PR title follows conventional commits (
type(scope): subject) -
bun run lintandbun run typecheckpass (CI fails on lint warnings too)
Summary by cubic
Part of SUPER-2874, the first API step of the attachment plan. API only, no client changes.
Scopes attachment.resolve so a kept (ready) file resolves only through a cloud workspace the caller can see; pending uploads stay org-wide because a host resolves with its owner's credential and the sender may be a teammate. A box still sees only its own workspace's files. Adds attachment.resolveEach, which returns one entry per id with { fileId, missing: true } for files that are gone or not visible. resolve keeps its output and errors for released hosts.
Also reaps cloud workspace files with the box: the archive's final delete stage (and the fallback in cloudWorkspace.delete) detaches and deletes the files anchored to the workspace; unarchives within the 7-day grace period keep their files, and failed reaps only log the error.
Not included: archives on retired providers still keep their files, and chat_session scoping waits for the durable session store.