Skip to content

Commit 3b8ea15

Browse files
fix(storage)!: an unclaimed file is served only within its organization (#25704)
Fixes #25693 Clause-②: no (narrowing) Metadata-protocol: none ## What this changes This PR makes one rule true: **an unclaimed file is served only within its organization**, as every other organization-scoped row is read. An unclaimed file is a private file with neither an attachments scope nor a field owner. It is either an upload that no record has claimed yet, or one whose record let it go. - **The download doors** (`@objectstack/service-storage`, `storage-routes.ts`, `authorizeDownload`). `GET /api/v1/storage/files/:fileId/url` and `GET /api/v1/storage/files/:fileId` still ask for a signed-in caller first, and answer `401 AUTH_REQUIRED` without one. Then they hand an unclaimed file to the download authorizer, as they already do for every other private file. A `deny` answers `403 FILE_DOWNLOAD_DENIED`, the status and code the doors already give a non-attachment file the caller may not read. Its message names no record. An allowed download keeps the presigned TTL this class has always been minted with. No error code is added. - **The download authorizer** (`storage-service-plugin.ts`, `buildFileReadAuthorizer` calling the new module-private `isWithinCallerOrganization`). The existing owner shortcut comes first. After it, an unclaimed file is judged in the two places that judge every other organization-scoped row. The first is the organization wall's verdict (`computeTenantLayer0Verdict`, ADR-0131 D8), asked over the caller's resolved context for the posture in force. The second is the driver's tenant term: the row is read by id under the caller's active organization (`tenantId`) and, under `group`, their organization access set. This package writes no organization comparison of its own. The context's organization is the one `resolveRequestExecutionContext` vetted against current membership. A caller with no organization scope under a walled posture is in no organization's reach, as the wall decides for every other row. The door answers that `deny` verdict itself, before the read. What stays the same: - The uploader is served their own upload. This is the owner rule every private file already follows. - The `single` posture serves every signed-in caller, as before. - `acl: 'public_read'` stays anonymous. - Attachments-scope and field-owned files keep their parent-derived verdicts. - An anonymous caller still gets `401 AUTH_REQUIRED`. - A kernel with no auth service binds no authorizer and still serves these files without a check. `registerStorageRoutes`' exported `authorizeFileRead` option is now also asked about this class. Its doc says so, and the changeset's BREAKING section covers it. ## Measurement The card's maintainer word was 「立卡,先实测再修」, so the measurement came first. It ran through the public door on a fresh boot (a real HTTP server under the `isolated` posture, with the organizations runtime loaded) at `b77bb37eb2`, before any edit. Its one-line result is on the card. The detail is with the seat, off GitHub. The same measurement was repeated on the fixed build at `34c018bbad`: the rule holds, and the controls are unchanged. That detail is with the seat too. ## Declarations - `Clause-②: no (narrowing)`. Under a walled posture the doors serve an unclaimed file to fewer callers, and nothing is widened. The claim carries `Clause-②: no` with no arm. The dispatch asked for the `(narrowing)` arm, and AGENTS.md Post-Task Checklist step 3 names that arm for this case, so it is added. The report notes the difference. - `Metadata-protocol: none`. Nothing a metadata author writes changes. - One `patch` changeset on `@objectstack/service-storage`, titled `fix(storage)!:`. It carries the BREAKING section (Before / Now / Fix) and the ADR-0087 disposition `not-required (no-migration-prescription)`. - This PR touches no governed surface. `skip-changeset` is not applied. - It does not touch `file-reference-lifecycle.ts` (PR #25682 is open there), so the two PRs are file-disjoint. ## Tests (head `34c018bbad`) - New `packages/services/service-storage/src/unclaimed-file-organization-scope.test.ts`, with 15 tests. It uses a real `ObjectQL` and `SqlDriver` (sqlite `:memory:`) and the real `StorageServicePlugin` booted as it ships, so the door, the session gate, the authorizer and `resolveRequestExecutionContext` are all real. Each case calls both doors, and where a file is served it follows the minted URL to the bytes. The stand-ins are the session read (two test headers), the permission store (`sys_member` fixture rows; empty for every other unregistered object) and the `tenancy` service. - `isolated`: refused (`403 FILE_DOWNLOAD_DENIED` at both doors, no URL minted, nothing about the file in the body) to a caller acting in another organization, to a signed-in caller acting in no organization, and to a caller whose session names the file's organization without a membership in it. Served to a colleague in the file's organization and to the uploader. The uploader is also served while acting elsewhere (the owner rule). An anonymous caller gets `401`. - Controls under `isolated`: `public_read` is served to anyone, and an attached file keeps `ATTACHMENT_DOWNLOAD_DENIED` for another organization and is served to a colleague. - `group`: served to a member of the file's organization who is acting in another of their organizations, and refused to a caller with no membership in it. - `single`: served to every signed-in caller whatever organization the session names, and refused (`401`) to an anonymous caller. - `storage-routes.test.ts`: five existing pins asserted the session-only branch this PR removes. Each was re-judged on its own subject: the unclaimed class now asks the authorizer, its `deny` answers `403 FILE_DOWNLOAD_DENIED`, an allowed download keeps the presigned TTL, and the bare-kernel pin binds no authorizer, as a bare kernel binds none. One route-level pin was added: a denied signed-in caller is refused at both doors and no URL is minted. - `pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2`: Test Files 63 passed (63), Tests 1147 passed (1147). - `pnpm --filter @objectstack/service-storage typecheck`: exit 0. The test-layer program (`tsconfig.test.json`, checked with `--listFiles`) compiles both test files. - `eslint --no-inline-config` with the root config over `packages/services/service-storage/src`: exit 0. - Full dogfood battery (owed for the `(narrowing)` arm): `pnpm --filter @objectstack/dogfood test` under the shared lock, with no shard and no path, on this head. Its counts are in the `os-dev-report` comment on #25693. ## Proof (ablation) Each leg was mutated through `node scripts/ablation-replace.mjs`, which checks on disk that the anchor hit and that the blob changed, then restores and checks the result against the HEAD blob. The subject is this package's source, imported relatively by both suites, so no `dist/` is involved. - **Leg 1, the door's organization scoping removed for the unclaimed class.** 10 tests went red: all five refusal pins of the new file (three under `isolated`, the refusal-comparison pin, and one under `group`) and the five route-level pins of the unclaimed class. 54 stayed green: every served pin, both controls and the `single` block. Restored: blob `b34c59e7fdcb` equals HEAD's, and `git diff HEAD` is empty. - **Leg 2, the wall's `deny` exit removed.** Exactly the 2 pins for a caller with no organization scope went red ("acts in no organization", and "names the file's organization without a membership in it"). The other 13 stayed green, so the driver's tenant term alone still refuses another organization. Restored: blob `1b929b2e9d08` equals HEAD's, and `git diff HEAD` is empty. ## Acceptance notes - **The uploader is served from any organization.** This is the owner shortcut the authorizer already applies to every private file. Should an unclaimed upload be readable by its uploader alone, or by the whole organization? Neither the upload door nor ADR-0104 settles that. This PR keeps the organization scope (plus the uploader) and routes the question to the seat. - **A platform administrator** reads the unclaimed files of the organization they act in, like any member. The door holds no permission sets, so it grants no wall exemption. The engine makes the same choice for a system read. - **A file row with no organization** (invalid state on a walled deployment under ADR-0131) is in no organization's reach under a walled posture. Only its uploader is served it. ## Gates The local subset is the full `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` list for this diff, reconciled with `--ran`. Every family CI runs is read from this PR's check-runs. Both are in the `os-dev-report` comment on #25693. --- _Generated by [Claude Code](https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0dc4b5d commit 3b8ea15

5 files changed

Lines changed: 602 additions & 42 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
fix(storage)!: an unclaimed file is served only within its organization
6+
7+
The two download doors (`GET /api/v1/storage/files/:fileId/url` and `GET /api/v1/storage/files/:fileId`) now serve a file with neither an attachments scope nor a field owner (an upload no record has claimed yet, or one whose record let it go) only within its organization. The signed-in check comes first, as before. The download authorizer then judges the file as every other organization-scoped row is read: the organization wall's verdict for the tenancy posture in force, and the driver's tenant scope for the reach. A caller outside that reach is refused `403 FILE_DOWNLOAD_DENIED`, the status and code the doors already give a file the caller may not read, with a message that names no record. No error code is added.
8+
9+
What does not change: the uploader is still served their own upload, as for every other private file. Under the `single` posture every signed-in caller is served as before. A file marked `acl: 'public_read'` stays anonymous. Attachments-scope and field-owned files keep their parent-derived verdicts. An anonymous caller still gets `401 AUTH_REQUIRED`. A kernel with no auth service still serves these files without a check.
10+
11+
Clause-②: no (narrowing) — the download doors serve an unclaimed file to fewer callers under a walled posture (`isolated`, `group`); nothing is widened.
12+
13+
Metadata-protocol: none (how two storage download doors authorize a request; nothing a metadata author writes changes)
14+
15+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is which callers the storage download doors serve an unclaimed file to under a walled tenancy posture. The remedy is operational (act in the file's organization, or have a record claim the file, or mark it `acl: 'public_read'`), never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
16+
17+
**BREAKING** for a walled deployment (`isolated` or `group` posture) whose users download unclaimed files from outside the file's organization, shipped as `patch` because the metadata protocol does not move.
18+
19+
- **Before.** The download doors did not apply the organization wall to an unclaimed file.
20+
- **Now.** A signed-in caller is served an unclaimed file when they uploaded it, or when the file is in their organization's reach under the posture in force (their active organization under `isolated`, any organization they belong to under `group`). Anyone else gets `403 FILE_DOWNLOAD_DENIED`. A caller with no organization under a walled posture is in no organization's reach.
21+
- **Fix.** Download the file while acting in its organization. Have a record claim it (a file field or an attachment), so that the record's access decides. Or, for a file that must be readable by everyone, mark its stored file record `acl: 'public_read'`.

‎packages/services/service-storage/src/storage-routes.test.ts‎

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -481,12 +481,14 @@ describe('Storage REST Routes', () => {
481481
expect(authorizeFileRead).not.toHaveBeenCalled();
482482
});
483483

484-
it('never gates a file with neither an attachments scope nor a field owner', async () => {
484+
it('[#25693] asks the authorizer about a file with neither an attachments scope nor a field owner, and answers its deny 403 FILE_DOWNLOAD_DENIED', async () => {
485485
const { server, store: s, authorizeFileRead } = serverWith('deny');
486486
await commit(s, { id: 'a5', scope: 'user', key: 'user/a5.png' });
487487
const res = await hit(server, '/api/v1/storage/files/:fileId', 'a5');
488-
expect(res._status).toBe(302);
489-
expect(authorizeFileRead).not.toHaveBeenCalled();
488+
expect(res._status).toBe(403);
489+
expect(res._json?.error?.code).toBe('FILE_DOWNLOAD_DENIED');
490+
expect(res._headers.Location).toBeUndefined();
491+
expect(authorizeFileRead).toHaveBeenCalledOnce();
490492
});
491493

492494
it('stays open when no authorizer is wired (back-compat)', async () => {
@@ -542,34 +544,45 @@ describe('Storage REST Routes', () => {
542544
expect(authorizeFileRead).not.toHaveBeenCalled();
543545
});
544546

547+
/** Seconds of life the minted local capability URL carries. */
548+
const ttlOf = (url: string) =>
549+
adapter.verifyToken(url.split('/').pop()!, 'get').exp - Math.floor(Date.now() / 1000);
550+
545551
/**
546552
* DUAL-MODE REGRESSION. A pre-cutover field holds an inline blob or an
547-
* external URL, never a sys_file id — so no legacy file is ever claimed,
548-
* `ref_object` stays unset, and this change gates nothing that used to
549-
* be open. An unclaimed upload behaves the same way.
553+
* external URL, never a sys_file id — so no legacy file is ever claimed
554+
* and `ref_object` stays unset. Such a file is the UNCLAIMED class, not
555+
* the field-owned one: [#25693] the authorizer judges it as unclaimed,
556+
* and an allowed download keeps the presigned TTL that class was always
557+
* minted with rather than the field-owned class's short one.
550558
*/
551-
it('does not gate an unclaimed file (no ref_object) — legacy fields keep working', async () => {
552-
const { server, store: s, authorizeFileRead } = serverWith('deny');
559+
it('judges an unclaimed file (no ref_object) as the unclaimed class, not the field-owned one', async () => {
560+
const { server, store: s, authorizeFileRead } = serverWith('allow', { downloadTtl: 120, presignedTtl: 1800 });
553561
await commit(s, { id: 'fo5', scope: 'user', key: 'user/fo5.png' });
554-
const res = await hit(server, '/api/v1/storage/files/:fileId', 'fo5');
555-
expect(res._status).toBe(302);
556-
expect(authorizeFileRead).not.toHaveBeenCalled();
562+
const res = await hit(server, '/api/v1/storage/files/:fileId/url', 'fo5');
563+
expect(res._status).toBe(200);
564+
expect(ttlOf(res._json.data.url)).toBeGreaterThan(1700);
565+
expect(authorizeFileRead).toHaveBeenCalledOnce();
557566
});
558567

559-
it('does not gate a file whose ownership was released', async () => {
560-
const { server, store: s, authorizeFileRead } = serverWith('deny');
568+
it('judges a file whose ownership was released as the unclaimed class', async () => {
569+
const { server, store: s, authorizeFileRead } = serverWith('allow', { downloadTtl: 120, presignedTtl: 1800 });
561570
await commit(s, owned('fo6', { ref_object: null, ref_id: null, ref_field: null }));
562-
const res = await hit(server, '/api/v1/storage/files/:fileId', 'fo6');
563-
expect(res._status).toBe(302);
564-
expect(authorizeFileRead).not.toHaveBeenCalled();
571+
const res = await hit(server, '/api/v1/storage/files/:fileId/url', 'fo6');
572+
expect(res._status).toBe(200);
573+
expect(ttlOf(res._json.data.url)).toBeGreaterThan(1700);
574+
expect(authorizeFileRead).toHaveBeenCalledOnce();
565575
});
566576
});
567577
});
568578

569579
// ── [#22431] The unclaimed class: neither an attachments scope nor a field
570580
// owner. It has no parent record to derive access from, so the download
571-
// asks for one thing only — a signed-in caller — unless the file declares
572-
// `acl: 'public_read'` (ADR-0104's one anonymous download).
581+
// asks for a signed-in caller first — unless the file declares
582+
// `acl: 'public_read'` (ADR-0104's one anonymous download). [#25693] A
583+
// signed-in caller is then judged by the authorizer, which serves such a
584+
// file only within its organization (pinned on a real engine in
585+
// `unclaimed-file-organization-scope.test.ts`).
573586
describe('a file with neither an attachments scope nor a field owner needs a signed-in caller (#22431)', () => {
574587
const DOORS = ['/api/v1/storage/files/:fileId/url', '/api/v1/storage/files/:fileId'] as const;
575588

@@ -624,7 +637,7 @@ describe('Storage REST Routes', () => {
624637
}
625638
expect(minted).not.toHaveBeenCalled();
626639
expect(resolveSession).toHaveBeenCalledTimes(2);
627-
// Not the parent-governed authorizer's file: there is no parent record.
640+
// Refused at the session gate, before the authorizer is asked.
628641
expect(authorizeFileRead).not.toHaveBeenCalled();
629642
minted.mockRestore();
630643
});
@@ -656,8 +669,9 @@ describe('Storage REST Routes', () => {
656669
}
657670
});
658671

659-
it('serves a signed-in caller as before: a 302 and a URL carrying the presigned TTL', async () => {
660-
const { server, store: s, authorizeFileRead } = serverWith(async () => ({ userId: 'user-7' }), { presignedTtl: 1800 });
672+
it('serves a signed-in caller the authorizer allows: a 302 and a URL carrying the presigned TTL', async () => {
673+
const authorizeFileRead = vi.fn(async () => 'allow' as const);
674+
const { server, store: s } = serverWith(async () => ({ userId: 'user-7' }), { presignedTtl: 1800, authorizeFileRead });
661675
await commit(s, { id: 'u4' });
662676
const url = await hit(server, DOORS[0], 'u4');
663677
expect(url._status).toBe(200);
@@ -668,7 +682,26 @@ describe('Storage REST Routes', () => {
668682
const redirect = await hit(server, DOORS[1], 'u4');
669683
expect(redirect._status).toBe(302);
670684
expect(redirect._headers.Location).toContain('/_local/raw/');
671-
expect(authorizeFileRead).not.toHaveBeenCalled();
685+
// [#25693] Asked once per door, after the session gate passed.
686+
expect(authorizeFileRead).toHaveBeenCalledTimes(2);
687+
});
688+
689+
it('[#25693] refuses a signed-in caller the authorizer denies 403 FILE_DOWNLOAD_DENIED at both doors, minting no URL', async () => {
690+
const resolveSession = vi.fn(async () => ({ userId: 'user-7', organizationId: 'org-2' }));
691+
const { server, store: s, authorizeFileRead } = serverWith(resolveSession);
692+
await commit(s, { id: 'u4d' });
693+
const minted = vi.spyOn(adapter, 'getPresignedDownload');
694+
for (const door of DOORS) {
695+
const res = await hit(server, door, 'u4d');
696+
expect(res._status, door).toBe(403);
697+
expect(res._json?.success, door).toBe(false);
698+
expect(res._json?.error?.code, door).toBe('FILE_DOWNLOAD_DENIED');
699+
expect(res._headers.Location, door).toBeUndefined();
700+
}
701+
expect(minted).not.toHaveBeenCalled();
702+
expect(resolveSession).toHaveBeenCalledTimes(2);
703+
expect(authorizeFileRead).toHaveBeenCalledTimes(2);
704+
minted.mockRestore();
672705
});
673706

674707
it("keeps an acl: 'public_read' file anonymous without asking for a session", async () => {
@@ -705,7 +738,9 @@ describe('Storage REST Routes', () => {
705738
});
706739

707740
it('keeps a kernel with no session resolver open, and says so once', async () => {
708-
const { server, store: s, logger } = serverWith(undefined);
741+
// A bare kernel: no auth service, so neither a session resolver nor an
742+
// authorizer is bound (`composeStorageRoutes`).
743+
const { server, store: s, logger } = serverWith(undefined, { authorizeFileRead: undefined });
709744
await commit(s, { id: 'u6' });
710745
expect((await hit(server, DOORS[1], 'u6'))._status).toBe(302);
711746
expect((await hit(server, DOORS[0], 'u6'))._status).toBe(200);

‎packages/services/service-storage/src/storage-routes.ts‎

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -292,21 +292,24 @@ export interface StorageRoutesOptions {
292292
* with neither an attachments scope nor a field owner (an upload no record
293293
* has claimed — an avatar or an organization logo stored as a URL, a pick
294294
* not yet saved, a released file). Such a file has no parent record to
295-
* derive access from, so the one thing its download requires is a
296-
* signed-in caller; none ⇒ `401 AUTH_REQUIRED`, the answer the upload
297-
* routes give. A file marked `acl: 'public_read'` stays anonymous
295+
* derive access from, so its download first requires a signed-in caller;
296+
* none ⇒ `401 AUTH_REQUIRED`, the answer the upload routes give. [#25693]
297+
* A signed-in caller is then judged by
298+
* {@link StorageRoutesOptions.authorizeFileRead} when one is wired: the
299+
* file is served only within its organization. A file marked
300+
* `acl: 'public_read'` stays anonymous
298301
* (ADR-0104). A browser's `<img src>` / `<a href>` reaches this resolver
299302
* with the session cookie its sign-in set, which reads the same as a bearer
300303
* header, so a signed-in page keeps rendering these files. Absent: these
301304
* downloads stay open as before — logged once.
302305
*/
303306
resolveSession?: (req: IHttpRequest) => Promise<StorageUploadSession | null | undefined>;
304307
/**
305-
* Authorize a DOWNLOAD of a parent-governed file (#2970 item 2, extended by
306-
* ADR-0104 D3 wave 2). When wired, the download endpoints
308+
* Authorize a DOWNLOAD of a private file (#2970 item 2, extended by
309+
* ADR-0104 D3 wave 2 and #25693). When wired, the download endpoints
307310
* (`/files/:fileId` and `/files/:fileId/url`) consult this for
308-
* `scope==='attachments'` files AND for field-owned files (`ref_object` set),
309-
* excluding `public_read` ones:
311+
* `scope==='attachments'` files, for field-owned files (`ref_object` set)
312+
* AND for the unclaimed files below, excluding `public_read` ones:
310313
* - `unauthenticated` → 401 (no session)
311314
* - `deny` → 403 (session, but cannot read the parent record the file
312315
* belongs to / is attached to, and is not the owner)
@@ -316,14 +319,17 @@ export interface StorageRoutesOptions {
316319
* `SERVICE_UNAVAILABLE` rather than flattened into the `deny` 403 — the
317320
* store was unreadable, so no verdict was ever reached. Every other throw
318321
* still fails closed to `deny`.
319-
* A file with neither an attachments scope nor a field owner is not this
320-
* authorizer's to judge — it has no parent record — and needs only a
321-
* signed-in caller ({@link StorageRoutesOptions.resolveSession}, #22431).
322+
* [#25693] A file with neither an attachments scope nor a field owner has
323+
* no parent record. It first needs a signed-in caller
324+
* ({@link StorageRoutesOptions.resolveSession}, #22431), and then this
325+
* authorizer judges it too: such a file is served only within its
326+
* organization, as every other organization-scoped row is read. Its
327+
* `deny` answers `403 FILE_DOWNLOAD_DENIED`, naming no record.
322328
* Any file explicitly marked `acl: 'public_read'` keeps the stable
323329
* anonymous capability URL (ADR-0104: the opt-in for genuinely public
324330
* embedding, which no sign-in precedes).
325331
* When absent (bare kernels, tests), parent-governed downloads stay open
326-
* (back-compat).
332+
* (back-compat), and an unclaimed file needs only the signed-in caller.
327333
*/
328334
authorizeFileRead?: (file: FileRecord, req: IHttpRequest) => Promise<FileReadVerdict>;
329335
/**
@@ -505,9 +511,11 @@ export function registerStorageFamily(
505511
// (`ref_object`/`ref_id`, ADR-0104 D3 wave 2).
506512
// [#22431] Every OTHER file — neither an attachments scope nor a field
507513
// owner: an upload no record has claimed — has no parent record to derive
508-
// access from, and needs a signed-in caller instead
514+
// access from, and needs a signed-in caller first
509515
// (`requireDownloadSession` below). It used to be the one class still
510-
// served as an anonymous capability URL by default.
516+
// served as an anonymous capability URL by default. [#25693] The same
517+
// authorizer then judges it within its organization, as every other
518+
// organization-scoped row is read (`StorageRoutesOptions.authorizeFileRead`).
511519
// `acl: 'public_read'` opts any file back out to the stable anonymous
512520
// capability URL — the explicit declaration for genuinely public
513521
// embedding, which no sign-in precedes (ADR-0104). A signed-in browser
@@ -528,10 +536,12 @@ export function registerStorageFamily(
528536
if (file.acl === 'public_read') return currentLimits().presignedTtl;
529537
const fieldOwned = !!file.ref_object && file.ref_id != null && file.ref_id !== '';
530538
const gated = file.scope === 'attachments' || fieldOwned;
531-
if (!gated) {
532-
if (!(await requireDownloadSession(req, res))) return false;
533-
return currentLimits().presignedTtl;
534-
}
539+
// [#22431] The unclaimed class asks for a signed-in caller first, with the
540+
// session gate's own 401. [#25693] Then, like every other private file, it
541+
// is judged by the download authorizer below: it is served only within its
542+
// organization (see `StorageRoutesOptions.authorizeFileRead`). It keeps the
543+
// presigned TTL it was always minted with.
544+
if (!gated && !(await requireDownloadSession(req, res))) return false;
535545
if (!opts.authorizeFileRead) {
536546
return currentLimits().presignedTtl;
537547
}
@@ -581,12 +591,17 @@ export function registerStorageFamily(
581591
if (verdict === 'deny') {
582592
if (fieldOwned) {
583593
sendError(res, 403, 'FILE_DOWNLOAD_DENIED', 'You do not have access to the record this file belongs to');
584-
} else {
594+
} else if (gated) {
585595
sendError(res, 403, 'ATTACHMENT_DOWNLOAD_DENIED', 'You do not have access to a record this file is attached to');
596+
} else {
597+
// [#25693] An unclaimed file belongs to no record, so the refusal names
598+
// none: the status and code the door gives every other file the caller
599+
// may not read, and nothing about whose file it is.
600+
sendError(res, 403, 'FILE_DOWNLOAD_DENIED', 'You do not have access to this file');
586601
}
587602
return false;
588603
}
589-
return downloadTtl;
604+
return gated ? downloadTtl : currentLimits().presignedTtl;
590605
};
591606

592607
// ── Download servability (#10246) ────────────────────────────────────

0 commit comments

Comments
 (0)