fix(api): scope attachment.resolve to visible cloud workspaces and reap files with the box

Closed
SU
16d ago
+323-67

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 calls resolve with its owner's credential, and the person sending can be a teammate on that host (v2_users_hosts members). Mobile → teammate's host importFromCloud and #8704's materializeIntoWorkspace would 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 for ready files. Released callers of resolve (desktop 1.34–1.39 and cli 1.36–1.39 tags, mobile on main): desktop RichText/uploadFile resolves its own pending upload right away; host-service importFromCloud / materializeIntoWorkspace resolve pending uploads, or run in a box as sandboxCaller; mobile cloud chat calls attachFiles and then imports on the box (sandboxCaller). None of them resolves a ready file 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_session scoping waits for the durable session store.

How I tested it

  • packages/trpc: bun test passes (675 tests), bun run typecheck passes, 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, like sweep.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 a just_me workspace 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 lint and bun run typecheck pass (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.