Skip to content

Commit cce1735

Browse files
committed
fix(service-storage): stop handing out _local/file/:key, a URL nothing mounts (#3641)
Three call sites built `${basePath}/_local/file/<key>`. No registrar has ever mounted it, so anyone who followed one got a 404. The tranche-3 storage ledger (#3636) recorded the URL as deliberately ABSENT and filed this; now nothing builds it either. Each site fixed according to what it could honestly do: - LocalStorageAdapter.getPresignedUpload() omits `downloadUrl` (optional on the descriptor). It CANNOT construct the real capability URL — that is keyed by sys_file.id and an adapter only ever sees the storage key. Nothing read the field, which is how it survived: the presigned-upload route builds its own downloadUrl and ignores this one, and all three real readers of `desc.downloadUrl` take it from getPresignedDownload, whose URL IS mounted. - GET /files/:fileId/url and GET /files/:fileId answer 501 NOT_IMPLEMENTED when the adapter has neither getPresignedDownload nor getSignedUrl, instead of returning/redirecting to the unmounted URL. The caller learns the adapter is the limitation rather than chasing a broken link. Behaviour change is confined to adapters implementing neither capability; LocalStorageAdapter and the S3 adapter both implement getPresignedDownload, so no shipped path changes. A 200/302 pointing at a 404 becomes a 501 that says why. Two conformance cases added, mutation-checked: restoring either dead URL fails them. service-storage: 198 passed (17 files), up from 196. Note on typecheck: this package has no `typecheck` script and CI's TypeScript gate runs `--filter @objectstack/spec` only. A bare `tsc --noEmit` here reports 39 pre-existing errors, 7 of them in test files this change never touches (the repo-wide `.js`-extension style under node16 resolution). Not touched, and not claimed as clean. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6877e9a commit cce1735

5 files changed

Lines changed: 94 additions & 5 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/service-storage": patch
3+
---
4+
5+
fix(service-storage): stop handing out `_local/file/:key`, a URL nothing mounts (#3641)
6+
7+
Three call sites built `${basePath}/_local/file/<key>`. No registrar has ever
8+
mounted it, so anyone who followed one got a 404. Found by the tranche-3
9+
storage ledger (#3636), which recorded the URL as deliberately absent and filed
10+
this; now nothing builds it either.
11+
12+
Each site is fixed according to what it could honestly do:
13+
14+
- **`LocalStorageAdapter.getPresignedUpload()`** simply omits `downloadUrl`
15+
(optional on the descriptor). It cannot construct the real capability URL —
16+
that is keyed by `sys_file.id`, and an adapter only ever sees the storage
17+
key. Nothing read the field anyway, which is how it survived: the
18+
presigned-upload route builds its own `downloadUrl`
19+
(`${basePath}/files/:fileId/url`) and ignores this one, while all three real
20+
readers of `desc.downloadUrl` take it from `getPresignedDownload`, whose URL
21+
*is* mounted (`_local/raw/<token>`).
22+
23+
- **`GET /files/:fileId/url` and `GET /files/:fileId`** answer **501
24+
`NOT_IMPLEMENTED`** when the adapter has neither `getPresignedDownload` nor
25+
`getSignedUrl`, instead of returning (or redirecting to) the unmounted URL.
26+
The caller now learns the adapter is the limitation rather than chasing a
27+
broken link.
28+
29+
Behaviour change is confined to adapters implementing neither capability —
30+
`LocalStorageAdapter` and the S3 adapter both implement `getPresignedDownload`,
31+
so no shipped path changes. A 200/302 pointing at a 404 becomes a 501 that says
32+
why.
33+
34+
Two conformance cases added for the new branches, and mutation-checked:
35+
restoring either dead URL fails them.

packages/services/service-storage/src/error-envelope.conformance.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,32 @@ describe('storage error envelope (#3675)', () => {
222222
return drive(routes, 'PUT', `${BASE}/_local/raw/:token`, { params: { token: 'x' } });
223223
},
224224
},
225+
{
226+
// #3641: these two used to answer 200/302 with
227+
// `${basePath}/_local/file/<key>` — a URL no registrar mounts — so a
228+
// caller followed a link straight into a 404. An adapter that can
229+
// produce no download URL now says so.
230+
name: 'download URL from an adapter with neither presigned nor signed URLs',
231+
status: 501,
232+
code: 'NOT_IMPLEMENTED',
233+
run: async () => {
234+
const store = new StorageMetadataStore(null);
235+
await committedAttachment(store, 'nocap', { scope: 'user', key: 'user/nocap.bin' });
236+
const routes = mount({} as any, store);
237+
return drive(routes, 'GET', `${BASE}/files/:fileId/url`, { params: { fileId: 'nocap' } });
238+
},
239+
},
240+
{
241+
name: 'the redirect twin of the same adapter limitation',
242+
status: 501,
243+
code: 'NOT_IMPLEMENTED',
244+
run: async () => {
245+
const store = new StorageMetadataStore(null);
246+
await committedAttachment(store, 'nocap2', { scope: 'user', key: 'user/nocap2.bin' });
247+
const routes = mount({} as any, store);
248+
return drive(routes, 'GET', `${BASE}/files/:fileId`, { params: { fileId: 'nocap2' } });
249+
},
250+
},
225251
{
226252
name: 'raw download with a forged token signature',
227253
status: 403,

packages/services/service-storage/src/local-storage-adapter.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,20 @@ export class LocalStorageAdapter implements IStorageService {
267267
method: 'PUT',
268268
headers: options?.contentType ? { 'content-type': options.contentType } : { 'content-type': 'application/octet-stream' },
269269
expiresIn,
270-
downloadUrl: `${this.baseUrl}${this.basePath}/_local/file/${encodeURIComponent(key)}`,
270+
// `downloadUrl` is deliberately OMITTED (it is optional on the
271+
// descriptor). It used to be `${basePath}/_local/file/<key>` — a URL no
272+
// registrar has ever mounted, so anyone following it got a 404 (#3641).
273+
//
274+
// Nothing read it, which is why it survived: the presigned-upload route
275+
// builds its own `downloadUrl` (`${basePath}/files/:fileId/url`) and
276+
// ignores this field, and the three real readers of `desc.downloadUrl`
277+
// all take it from `getPresignedDownload`, whose URL IS mounted
278+
// (`_local/raw/<token>`).
279+
//
280+
// Not repaired into a working URL, because this adapter cannot build
281+
// one: the capability URL is keyed by `sys_file.id`, and an adapter only
282+
// ever sees the storage key. Emitting nothing is honest; emitting a
283+
// 404 was not.
271284
};
272285
}
273286

packages/services/service-storage/src/storage-route-ledger.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,13 @@
2525
* SCOPE & SHAPE. Rows carry full wire paths at the DEFAULT base
2626
* (`/api/v1/storage`); `StorageRoutesOptions.basePath` can move the whole
2727
* family, and the conformance test enumerates at the same default so the two
28-
* stay comparable. `GET {base}/_local/file/:key` is deliberately ABSENT — three
29-
* call sites build that URL but no registrar has ever mounted it (#3641).
28+
* stay comparable. `GET {base}/_local/file/:key` is ABSENT because nothing
29+
* mounts it — and since #3641 nothing BUILDS it either. The three call sites
30+
* that used to are gone: the local adapter's upload descriptor simply omits
31+
* the optional `downloadUrl` (it cannot construct the real capability URL,
32+
* which is keyed by `sys_file.id` rather than the storage key), and the two
33+
* download routes now answer 501 when the adapter can produce no URL at all,
34+
* instead of handing back one that 404s.
3035
*
3136
* This module is package-internal (not exported from the index): it is the
3237
* guard's data, not public API. It must stay import-free — the client-side

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,11 @@ export function registerStorageRoutes(
543543
} else if (storage.getSignedUrl) {
544544
url = await storage.getSignedUrl(file.key, ttl, downloadOpts);
545545
} else {
546-
url = `${basePath}/_local/file/${encodeURIComponent(file.key)}`;
546+
// See the sibling branch on `GET /files/:fileId`: no adapter capability
547+
// means no download URL, and handing back an unmounted one is worse
548+
// than admitting it (#3641).
549+
sendError(res, 501, 'NOT_IMPLEMENTED', 'This storage adapter cannot issue download URLs');
550+
return;
547551
}
548552

549553
res.json({ url });
@@ -583,7 +587,13 @@ export function registerStorageRoutes(
583587
} else if (storage.getSignedUrl) {
584588
url = await storage.getSignedUrl(file.key, ttl, downloadOpts);
585589
} else {
586-
url = `${basePath}/_local/file/${encodeURIComponent(file.key)}`;
590+
// An adapter with neither `getPresignedDownload` nor `getSignedUrl`
591+
// cannot produce a download URL. This used to redirect to
592+
// `${basePath}/_local/file/<key>`, which no registrar mounts — a 302
593+
// straight into a 404 (#3641). Say so instead: the caller learns the
594+
// adapter is the limitation, rather than chasing a broken link.
595+
sendError(res, 501, 'NOT_IMPLEMENTED', 'This storage adapter cannot issue download URLs');
596+
return;
587597
}
588598

589599
res.status(302).header('Location', url).send('');

0 commit comments

Comments
 (0)