Skip to content

fix(service-storage)!: downloading a file with no attachments scope and no field owner requires a signed-in caller - #22439

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22431-unclaimed-download-signed-in
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22431-unclaimed-download-signed-in

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22431

Clause-②: no (narrowing)

Executes ruling 6074960686 item 3 (#22146), as triage routed it: a storage download of a file with no scope and no field owner requires a signed-in caller, and a file declared acl: 'public_read' stays anonymous (ADR-0104). Function level only, as the card requires.

What changes

  • packages/services/service-storage/src/storage-routes.ts, authorizeDownload (the one gate both download routes call). A file with neither an attachments scope nor a field owner (an upload no record has claimed) now needs a session from the existing resolveSession; a caller with none is refused 401 AUTH_REQUIRED, the pair the upload gate and the attachments gate already answer. No new code, no spec change. acl: 'public_read' is checked first and stays anonymous for every class. Attachments-scope and field-owned files keep their authorizeFileRead verdicts, untouched. A resolver that throws, or a session with no user, fails closed.
  • Bare kernel unchanged. With no resolveSession wired, these downloads stay open as before, and the module now says so once (the existing one-time notice names only the upload routes).
  • Docblocks made true. The resolveSession docblock no longer calls download gating "a tracked follow-up", and the authorizeFileRead docblock no longer names an organization logo as anonymous.
  • content/docs/permissions/attachments-access.mdx said that avatars, image-field thumbnails and organization logos keep an anonymous capability URL. This change makes the avatar and logo half false, and the image-field half has been false since field-owned files were gated. The paragraph and two table rows now state the three classes. This file is outside the dispatch's file fence; see Acceptance notes.
  • One changeset for @objectstack/service-storage, minor, with the BREAKING banner, the remedy (sign in, or mark the file public_read) and an ADR-0087 disposition not-required (no-migration-prescription).

Measured before building (the card's stop condition)

Measured on origin/main b9222dc701 on a booted showcase (objectstack dev --fresh, single posture). The readings stay in the seat's container. Classes only here:

  • Reproduction. An anonymous download of an unclaimed upload was served at both download routes, bytes included. Controls: an attachments-scope file and a field-owned file were refused 401 AUTH_REQUIRED to the same caller, and an anonymous upload was refused 401 AUTH_REQUIRED.
  • Producers of unclaimed files. In this repository only the two upload routes create a sys_file row (StorageMetadataStore.createFile). The copy-on-claim copy is claimed by construction. No seed, branding, theme or import path creates one, and the showcase seeds none. The rows that stay unclaimed come from what clients do with an upload: the console writes an uploaded avatar into the user's image URL field and an uploaded organization logo into the organization's logo URL field. Neither is a file-class field, so neither is ever claimed. A picked file stays unclaimed until its record is saved, an abandoned upload stays unclaimed, and so does a file whose owner released it. Nothing in the repository produces a public_read file.
  • Readers of such a file, and when they render. The console renders avatars and organization logos (header, user menu, profile, members, organizations, organization settings) and pre-save upload previews. Every one of them is behind sign-in. The surfaces that render before sign-in read nothing in this class. The sign-in pages draw their logo from operator configuration, never from an upload. The invitation page draws no logo or avatar. A public form cannot upload anonymously. The share page renders no stored file. No in-repo email template renders an avatar or a logo.
  • How a signed-in browser reaches the routes. The console signs in through the better-auth client, which sends credentials by default, so the browser holds the HttpOnly, SameSite=Lax session cookie the sign-in sets, beside the bearer token. In a real Chromium session signed in that way, image tags pointing at the routes loaded the already-gated classes (attachments-scope and field-owned). A session with no cookie (a bearer-only client) failed them. The routes' resolveSession reads the cookie the same as a bearer header.
  • Verdict: no surface the repository ships stops rendering, for a signed-out or a signed-in viewer. Built.

Tests

  • Unit, storage-routes.test.ts: a new block for the unclaimed class. It covers the refusal at both doors (code, status, envelope, no URL minted, authorizer not consulted), parity with the upload gate's anonymous answer, fail-closed on a throwing resolver and on a user-less session, and a signed-in caller served as before (302, and the presigned TTL read back out of the minted URL). It also covers public_read anonymous without a session read, the parent-governed verdicts unchanged and the resolver not consulted, a missing file 404 before the session is asked, and a bare kernel open with one notice. Against the unfixed code: 4 failed / 40 passed (the refusal pins and the notice red; the controls green). With the fix: green.
  • Conformance, error-envelope.conformance.test.ts: the new refusal joins the driven error branches.
  • Dogfood, storage-unclaimed-download.dogfood.test.ts (one file, booted showcase with the storage plugin): the anonymous refusal at both doors, a bearer caller served, a cookie-only caller served (the transport an image tag uses), public_read anonymous and back, and controls (anonymous upload, attachments-scope download). Against main's dist: 2 failed / 4 passed. With the fix: 6 / 6.
  • Ablation (committed fix first, mutation through scripts/ablation-replace.mjs, restore trap held): deleting the session-gate call in authorizeDownload landed (anchor 1 → 0, blob changed). After a rebuild, ablation-dist-preflight --absent showed the call gone from all 4 built files. Unit + conformance went 5 failed / 57 passed, and the dogfood file went 2 failed / 4 passed. Restore: blob equal to HEAD, git diff HEAD empty, git status --porcelain empty. The rebuild preflight showed the call present in dist/index.js and dist/index.cjs, then 62 / 62 and 6 / 6. (The mutated build's DTS step failed on the now-unused helper, TS6133. ESM and CJS were rebuilt, and those are what the suites import.)
  • Full @objectstack/service-storage suite: 46 files, 782 passed; typecheck exit 0 (with check:test-typecheck).
  • Every dogfood file that uploads, downloads or touches sys_file (18 files) at 42fe8d498c: 175 passed, 1 skipped (the pre-existing skipIf(!organizationsAvailable) block), exit 0. @objectstack/dogfood typecheck exit 0, with the new file in the program.

Gates (at 42fe8d498c, after merging origin/main 05c7c3fa3b)

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 98 commands from the six changed paths. All 98 were run, and all exited 0. --ran reconciliation: 98 derived famil(ies) accounted for — 98 run, 0 NOT-MEASURED (a DERIVED zero — all 98 recorded an exit code and none of them is 3).
  • pnpm check:error-status-conformance (also by hand, per the dispatch): ✓ every derivable runtime status is documented, and every documented status is reachable.
  • Changeset, check-changeset-no-major with this body as the event: ✓ This diff introduces no major bump. and ✓ LEVEL AXIS: this PR declares clause-② no (narrowing), and no package whose packages/**/src/** it moves is graded patch. check-adr-0087-registration: ✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition (BREAKING+bang+clause-②-narrowing, not-required (no-migration-prescription)).
  • check:nul-bytes: OK (... no raw ASCII control bytes). check:route-envelope, check:doc-authoring, check:cross-package-test-inputs and check:test-source-alias all exited 0.
  • ESLint, narrowed to the four changed TypeScript files and proven: each is in the config's population (--print-config resolves for every one), the JSON output counts 4 files with 0 errors and 0 warnings, and eslint.config.mjs never enables type-aware linting, so this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint is CI's run.

Acceptance notes

  • File fence. The dispatch fenced storage-routes.ts and its tests, one dogfood file and one changeset. This PR also corrects content/docs/permissions/attachments-access.mdx, because the change makes its statement false. The standing dev rules require a published statement this change falsifies to be fixed in the same change. The conflict is named here rather than settled silently. Drop the commit if the seat rules otherwise.
  • TTL kept. A signed-in download of an unclaimed file still mints its URL with the presigned TTL, not the short gated-download TTL. "Served as today" was the instruction.
  • Order kept. The routes look the file up before judging the caller, so a missing file answers 404 before the session is asked, exactly as the parent-governed classes already do.
  • Where a signed-in page would still go dark (not a shipped shape, noted): a console served cross-site from its API (a SameSite=Lax cookie is not sent on a cross-site image request), and a session restored from a bearer token alone. Field-owned images already fail the same way there today.
  • Boot wording elsewhere. mountStorageRoutes' unbound-gates warning and the StorageRoutesMountReport.sessionResolver docstring (in storage-service-plugin.ts, which PR fix(auth,services): the services-lane in-process session reads stop renewing a cookie session (#22258) #22396 edits and this PR does not touch) still describe the resolver as gating uploads. Both are still true and now incomplete. Carrier: none.
  • .changeset grading: check-changeset-no-major and check-adr-0087-registration verdicts are under Gates.

Generated by Claude Code

claude added 4 commits October 9, 2026 07:35
…and no field owner requires a signed-in caller

The two download doors now ask the session resolver for a file that has
neither an attachments scope nor a field owner, unless the file is
acl public_read (ADR-0104). No session answers 401 AUTH_REQUIRED, the pair
the routes' other unauthenticated refusals answer. A kernel with no session
resolver keeps serving these downloads and says so once.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…ts scope and no field owner on a booted showcase

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…the signed-in download of an unclaimed file

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation labels Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-storage, touching 5 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via AUTH_REQUIRED (literal, a string literal in registerStorageRoutes))
  • content/docs/data-modeling/objects.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions; a string literal in a comment in registerStorageRoutes; a string literal in a comment in resolveSession; a string literal in registerStorageRoutes))
  • content/docs/getting-started/common-patterns.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions; a string literal in a comment in registerStorageRoutes; a string literal in a comment in resolveSession; a string literal in registerStorageRoutes))
  • content/docs/permissions/attachments-access.mdx (via AUTH_REQUIRED (literal, a string literal in registerStorageRoutes), public_read (literal, a string literal in a comment in StorageRoutesOptions; a string literal in a comment in registerStorageRoutes; a string literal in a comment in resolveSession; a string literal in registerStorageRoutes))
  • content/docs/permissions/authorization.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions; a string literal in a comment in registerStorageRoutes; a string literal in a comment in resolveSession; a string literal in registerStorageRoutes))
  • content/docs/permissions/permissions-matrix.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions; a string literal in a comment in registerStorageRoutes; a string literal in a comment in resolveSession; a string literal in registerStorageRoutes))
  • content/docs/permissions/rls.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions; a string literal in a comment in registerStorageRoutes; a string literal in a comment in resolveSession; a string literal in registerStorageRoutes))
  • content/docs/permissions/sharing-rules.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions; a string literal in a comment in registerStorageRoutes; a string literal in a comment in resolveSession; a string literal in registerStorageRoutes))
  • content/docs/protocol/objectql/security.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions; a string literal in a comment in registerStorageRoutes; a string literal in a comment in resolveSession; a string literal in registerStorageRoutes))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions; a string literal in a comment in registerStorageRoutes; a string literal in a comment in resolveSession; a string literal in registerStorageRoutes))
  • content/docs/releases/v15.mdx (via AUTH_REQUIRED (literal, a string literal in registerStorageRoutes), public_read (literal, a string literal in a comment in StorageRoutesOptions; a string literal in a comment in registerStorageRoutes; a string literal in a comment in resolveSession; a string literal in registerStorageRoutes))
  • content/docs/releases/v17/17-2.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions; a string literal in a comment in registerStorageRoutes; a string literal in a comment in resolveSession; a string literal in registerStorageRoutes))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 081e6a09d4042cc66a972baca3af51bc1e2c82af → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9f9f20034e86bb5cd0b59fff8d332e2b14df3c42 — the merge of head 42fe8d498ce437d0c261ef6f191b15cbd2258d92 into base 081e6a09d4042cc66a972baca3af51bc1e2c82af, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9f9f20034e86bb5cd0b59fff8d332e2b14df3c42 && git checkout 9f9f20034e86bb5cd0b59fff8d332e2b14df3c42
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 081e6a09d4042cc66a972baca3af51bc1e2c82af 42fe8d498ce437d0c261ef6f191b15cbd2258d92 && git checkout -B drift-repro 081e6a09d4042cc66a972baca3af51bc1e2c82af && git merge --no-ff 42fe8d498ce437d0c261ef6f191b15cbd2258d92

node scripts/docs-audit/affected-docs.mjs --json 081e6a09d4042cc66a972baca3af51bc1e2c82af

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 081e6a09d4042cc66a972baca3af51bc1e2c82af → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 08:38
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 08:38
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit dd986d8 Oct 9, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22431-unclaimed-download-signed-in branch October 9, 2026 09:02
This was referenced Oct 9, 2026
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

2 participants