Skip to content

Commit 69aa630

Browse files
fix(cli,service-storage): the Console CSP's connect-src names the store the server presigns uploads to (#26281)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XE7Tix5uExkLdxanf8sDFR5)_ Fixes #26200 ## Summary Since the Console document got its nonce CSP, `connect-src` names only `'self'`, the default map style host, the deployment's map style and error-reporting origins, and `OS_CONSOLE_CSP_CONNECT_SRC`. With S3 or an S3-compatible store, the Console uploads a file by `PUT`ting it straight to the presigned URL, on the store's host. That host was not in the policy, so the browser refused every upload with `TypeError: Failed to fetch`. The ruling on the card (「另在 objectstack 立卡让 CSP 自动带上存储端点」) asks for the configured store's origin exactly as the presigner emits it, with one copy of the endpoint rule and no wildcard. This PR does that in two parts. - **`@objectstack/service-storage`.** `S3StorageAdapter.getPresignedUploadOrigin()` signs a probe `PutObject` for the adapter's own bucket with the same client options every real presign uses (factored into one private `clientOptions()`), and answers the URL's origin. The presigner decides the host, so a virtual-hosted bucket, a region-templated AWS host, a path-style endpoint and a bucket name that cannot be a host label all come out as the upload URL carries them. The probe signs with fixed fake credentials, so it never consults the AWS credential chain and never opens a socket. The answer is cached per adapter instance, and a rejection is not cached. `SwappableStorageService.getPresignedUploadOrigin()` answers the active adapter's origin, or `undefined` when that adapter has none (the local adapter's uploads go to the server itself). - **`@objectstack/cli` (`utils/console.ts`).** The mount reads `getPresignedUploadOrigin` off the kernel's `storage` service by name, per document, and adds the answer to `connect-src` after the error-reporting origin and before `OS_CONSOLE_CSP_CONNECT_SRC`. The module still imports Node built-ins only. An answer that does not match the module's existing connect-source grammar (an IPv6 literal, say) is left out, and so is a failure to answer (an endpoint the SDK refuses); either is reported once at `warn`, naming `OS_CONSOLE_CSP_CONNECT_SRC` as the remedy. No storage service, or the local adapter, adds nothing. **Runtime settings.** Storage can be changed in Setup → Settings without a restart: `StorageServicePlugin` swaps the adapter inside the one `SwappableStorageService`. Because the mount reads the origin per document, not at boot, the next document after a swap names the new store (pinned below). **Hosted.** cloud's `objectos-runtime` mounts the Console through `createConsoleStaticPlugin`, whose signature is unchanged. The origin arrives through the kernel's `storage` service, so no call-site change is needed wherever that kernel registers `StorageServicePlugin`. Whether `objectos-runtime`'s kernel does is outside this repository and not measured here (see Acceptance notes). ## Tests - `packages/cli/src/utils/console.content-security-policy.test.ts`, new describe `connect-src names the host the storage service presigns browser uploads to`. Each case runs the REAL `StorageServicePlugin.init` and the REAL console plugin on a REAL Hono app: - an R2-style store (virtual-hosted) adds `https://csp-uploads.acct123.r2.cloudflarestorage.com`, the bucket host and not the endpoint host; - parity: for an R2 endpoint, AWS `eu-west-2`, AWS with a dotted bucket, and a path-style `http://127.0.0.1:9000`, the served `connect-src` equals `'self'`, the map host and the origin of a real `getPresignedUpload` URL from the same service; - a settings swap (`storage.swap(...)`) is followed on the next document; - local storage adds nothing; - an endpoint the presigner refuses adds nothing and warns once over two documents; - `OS_CONSOLE_CSP_CONNECT_SRC` still follows the storage origin. - `packages/services/service-storage/src/s3-storage-adapter.presign-origin.test.ts`: five configurations (virtual-hosted and path-style S3-compatible endpoints, a self-hosted port, AWS region-templated, AWS dotted bucket), each answering the origin of a real presigned upload URL and the literal expected origin; no credentials needed; two refused endpoints reject; the swappable service follows a swap and answers `undefined` for the local adapter. **Red first** (base `3d50a43bc`, the CLI suite before the fix): 4 failed / 40 passed. Each storage case read `connect-src 'self' https://demotiles.maplibre.org` and nothing else. **Ablation** (at head `0fd3cd0d`, via `scripts/ablation-replace.mjs`; the suite imports `console.ts` from source, so there is no dist leg): deleting the line that pushes `storageUploadOrigin` into `connect-src` landed (anchor 1 → 0, blob `497ea91966dc` → `1f54438adfbc`), the CSP suite went 4 failed / 41 passed, and the restore matched `HEAD` with `git diff HEAD` empty. Package runs, at head `8990880046` (after merging `origin/main`, which touched `service-storage`): - `pnpm --filter @objectstack/service-storage test`: 67 files, 1236 tests passed. - `pnpm --filter @objectstack/service-storage typecheck` and `pnpm --filter @objectstack/cli typecheck`: exit 0. - `vitest run --project unit` for the CSP suite and `test/published-subpath-console.pin.test.ts` (the packed `@objectstack/cli/console` still resolves with Node built-ins only): 2 files, 59 tests passed. - Before that merge (head `0fd3cd0d`): the whole CLI `unit` project, 334 files, 4884 tests passed. The `integration` tier is declared to CI; the diff touches no spawn entry. - `eslint --no-inline-config` over the six touched files: exit 0. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 72 commands at head `0fd3cd0d`. Each ran with its exit code captured by redirect before any pipe. Three first exited 3 on a prerequisite this worktree lacked (`check-plugin-teardown-shape --self-test` on a shallow clone, and `check:dual-build-cjs-loads` and `check:i18n-coverage` on unbuilt packages outside the diff). They were rerun after unshallowing and building, and exited 0. - `--ran` reconciliation: "72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN", exit 0. - After merging `origin/main` (head `8990880046`) the derivation is the same list less the two `check-position-name-fold-loaders` rows that main retired. The ratchet families (`check:type-check-coverage`, `check:type-check-debt`, `check:query-options-erasure`, `check:test-source-alias`, `check:cross-package-test-inputs`) and `check:nul-bytes` were rerun there, and all exited 0. At the final head `1f201e46b4`, which changes only the changeset's wording, the changeset gates (`check-changeset-no-major`, `check-adr-0087-registration`, `check-empty-changeset`) and `check:nul-bytes` exited 0. - Dogfood battery: not run. The title has no `!` and `Clause-②` has no `(narrowing)` arm. ## Declarations Clause-②: yes (widening) Metadata-protocol: none Measured on the diff. Two new public methods ship on exported classes of a published package (`S3StorageAdapter#getPresignedUploadOrigin`, `SwappableStorageService#getPresignedUploadOrigin`), and the Console policy accepts one more origin. That origin is always the one the server itself hands the page. No metadata an author writes moves, so both packages take a `patch` changeset (`.changeset/26200-console-csp-storage-upload-origin.md`). Nothing is removed or narrowed, so ADR-0087 has no disposition to state. `packages/spec` is untouched, as the claim requires. ## Acceptance notes - **The hosted half is a verification, not a change.** `createConsoleStaticPlugin` reads `storage` off the kernel context it is started in. If cloud's `objectos-runtime` registers `StorageServicePlugin` on that kernel, hosted uploads work once the framework pin moves past this PR, and the `OS_CONSOLE_CSP_CONNECT_SRC` stop-gap can be dropped. If it composes storage elsewhere (another kernel, or `mountStorageRoutes` over a service the Console's kernel never sees), the CSP adds nothing there. That cannot be measured from this repository, so it is reported to the seat for a cloud-lane card. - **What "endpoint unset" was read as.** AWS S3 with no custom endpoint still adds its region-templated origin (`https://BUCKET.s3.REGION.amazonaws.com`). The ruling names region-templated hosts, and those exist only when no endpoint is set. "Unset" was read as "no S3 store configured", which adds nothing. The other reading would leave every AWS-native deployment's uploads blocked. - **The accessor is not on the `IStorageService` contract.** The claim forbids `packages/spec/**`, so the Console reads the method by name. An adapter from outside this repository that presigns to another host is not covered until it implements the same method, or the contract declares it. - **The local adapter's `baseUrl` option** could make its presigned URLs absolute on another origin. No producer in this repository sets it, and the ruling says local adds nothing, so the CSP adds nothing for it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JFi7QBsDBsBeLzDyTyerZh --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b3ed6c8 commit 69aa630

6 files changed

Lines changed: 401 additions & 25 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/service-storage': patch
4+
---
5+
6+
fix(cli,service-storage): the Console document's `connect-src` names the store the server presigns browser uploads to
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
With S3 or an S3-compatible store (R2, MinIO), the Console uploads a file by `PUT`ting it straight to the URL `POST /api/v1/storage/upload/presigned` answers, on the store's host. The Console document's content-security-policy did not name that host, so the browser refused every such upload with `TypeError: Failed to fetch`.
12+
13+
- **`@objectstack/service-storage`.** `S3StorageAdapter.getPresignedUploadOrigin()` answers the origin every presigned upload URL carries. It signs a probe upload with the same client options as every real presign, so a virtual-hosted bucket (`BUCKET.ENDPOINT-HOST`), a region-templated AWS host and a path-style endpoint come out exactly as the upload URL carries them. Signing is local and uses fixed probe credentials, so it never reaches the network or the AWS credential chain. `SwappableStorageService.getPresignedUploadOrigin()` answers the active adapter's origin, or `undefined` for an adapter without one (the local adapter's uploads go to the server itself).
14+
- **`@objectstack/cli`.** The Console mount reads that origin off the kernel's `storage` service on every document and adds it to `connect-src`, after the error-reporting origin and before `OS_CONSOLE_CSP_CONNECT_SRC`. A storage settings change that swaps the adapter is followed on the next document, with no restart. Local storage, no storage service, or a store whose endpoint the presigner refuses adds nothing. The last case logs one `warn` that names the remedy. `OS_CONSOLE_CSP_CONNECT_SRC` still adds an operator's extra sources, and a deployment that named its store there as a stop-gap can drop it.

‎packages/cli/src/utils/console.content-security-policy.test.ts‎

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
* document carries. These cases run the REAL plugin on a REAL Hono app (the
1010
* `HonoHttpServer` production resolves as `http.server`); the error-reporting
1111
* case adds the REAL `RuntimeConfigPlugin`, so the `connect-src` follows what
12-
* that door serves.
12+
* that door serves; the storage cases add the REAL `StorageServicePlugin`, so
13+
* the `connect-src` follows the adapter it builds.
1314
*/
1415

1516
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
@@ -18,6 +19,7 @@ import os from 'node:os';
1819
import path from 'node:path';
1920
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
2021
import { RuntimeConfigPlugin } from '@objectstack/cloud-connection';
22+
import { S3StorageAdapter, StorageServicePlugin, type StorageServicePluginOptions } from '@objectstack/service-storage';
2123

2224
import { createConsoleStaticPlugin } from './console.js';
2325

@@ -45,6 +47,8 @@ const ENV = [
4547
'OS_CONSOLE_MAP_STYLE_URL',
4648
'OS_CLOUD_URL',
4749
'OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN',
50+
'AWS_ACCESS_KEY_ID',
51+
'AWS_SECRET_ACCESS_KEY',
4852
];
4953

5054
let saved: Record<string, string | undefined>;
@@ -77,6 +81,8 @@ afterEach(() => {
7781

7882
async function mount(opts: {
7983
runtimeConfig?: ConstructorParameters<typeof RuntimeConfigPlugin>[0];
84+
/** The kernel services the console plugin's context resolves by name. */
85+
services?: Map<string, unknown>;
8086
warnings?: string[];
8187
} = {}) {
8288
const server = new HonoHttpServer(0);
@@ -99,7 +105,14 @@ async function mount(opts: {
99105
} as any);
100106
for (const fn of hooks) await fn();
101107
}
102-
await createConsoleStaticPlugin(distPath).start({ getServiceAsync: async () => server, logger });
108+
await createConsoleStaticPlugin(distPath).start({
109+
getServiceAsync: async () => server,
110+
getService: (name: string) => {
111+
if (opts.services?.has(name)) return opts.services.get(name);
112+
throw new Error(`no service ${name}`);
113+
},
114+
logger,
115+
});
103116
const app = server.getRawApp();
104117
return async (p: string, init?: RequestInit): Promise<Response> => app.request(`${ORIGIN}${p}`, init);
105118
}
@@ -388,3 +401,118 @@ describe('connect-src', () => {
388401
expect(await verdict(() => mount())).toBe(runtimeConfig);
389402
});
390403
});
404+
405+
/**
406+
* The kernel services a REAL `StorageServicePlugin` registers when it is
407+
* initialised with `options`: the `storage` service the upload doors presign
408+
* through, built from the same options.
409+
*/
410+
async function storageServices(options: StorageServicePluginOptions): Promise<Map<string, unknown>> {
411+
const services = new Map<string, unknown>();
412+
await new StorageServicePlugin(options).init({
413+
registerService: (name: string, service: unknown) => { services.set(name, service); },
414+
getService: (name: string) => {
415+
if (services.has(name)) return services.get(name);
416+
throw new Error(`no service ${name}`);
417+
},
418+
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} },
419+
} as any);
420+
return services;
421+
}
422+
423+
/** An S3-compatible store whose presigned URLs are virtual-hosted: the bucket is in the host name. */
424+
const R2_STORAGE: StorageServicePluginOptions = {
425+
adapter: 's3',
426+
s3: { bucket: 'csp-uploads', region: 'auto', endpoint: 'https://acct123.r2.cloudflarestorage.com', keyPrefix: 'projects/env_1' },
427+
};
428+
429+
describe('connect-src names the host the storage service presigns browser uploads to', () => {
430+
it('adds the origin of the presigned upload URL when the deployment stores files in S3', async () => {
431+
const request = await mount({ services: await storageServices(R2_STORAGE) });
432+
const res = await request('/_console/');
433+
expect(directives(res.headers.get('content-security-policy')).get('connect-src')).toEqual([
434+
"'self'",
435+
'https://demotiles.maplibre.org',
436+
'https://csp-uploads.acct123.r2.cloudflarestorage.com',
437+
]);
438+
});
439+
440+
it('allows exactly the origin of a real presigned upload URL from the same storage service', async () => {
441+
// The presign itself signs with whatever credentials the adapter resolves.
442+
process.env.AWS_ACCESS_KEY_ID = 'AKIAFAKEFAKEFAKEFAKE';
443+
process.env.AWS_SECRET_ACCESS_KEY = 'fake-secret-access-key';
444+
for (const options of [
445+
R2_STORAGE,
446+
{ adapter: 's3', s3: { bucket: 'csp-uploads', region: 'eu-west-2', keyPrefix: null } },
447+
{ adapter: 's3', s3: { bucket: 'csp.uploads.example', region: 'us-east-1', keyPrefix: null } },
448+
{ adapter: 's3', s3: { bucket: 'csp-uploads', region: 'us-east-1', endpoint: 'http://127.0.0.1:9000', keyPrefix: null } },
449+
] satisfies StorageServicePluginOptions[]) {
450+
const services = await storageServices(options);
451+
const presigned = await (services.get('storage') as S3StorageAdapter).getPresignedUpload('a/b.txt', 60, { contentType: 'text/plain' });
452+
const request = await mount({ services });
453+
const res = await request('/_console/');
454+
const connectSrc = directives(res.headers.get('content-security-policy')).get('connect-src')!;
455+
expect(connectSrc, options.s3?.endpoint ?? options.s3?.region).toEqual([
456+
"'self'",
457+
'https://demotiles.maplibre.org',
458+
new URL(presigned.uploadUrl).origin,
459+
]);
460+
}
461+
});
462+
463+
it('follows the adapter a storage settings change swaps in, on the next document', async () => {
464+
const services = await storageServices(R2_STORAGE);
465+
const request = await mount({ services });
466+
const storage = services.get('storage') as { swap(next: unknown): void };
467+
storage.swap(new S3StorageAdapter({
468+
bucket: 'moved', region: 'auto', endpoint: 'https://acct456.r2.cloudflarestorage.com', keyPrefix: null, forcePathStyle: true,
469+
}));
470+
const res = await request('/_console/');
471+
expect(directives(res.headers.get('content-security-policy')).get('connect-src')).toEqual([
472+
"'self'",
473+
'https://demotiles.maplibre.org',
474+
'https://acct456.r2.cloudflarestorage.com',
475+
]);
476+
});
477+
478+
it('adds nothing when the deployment stores files on the local disk, whose uploads go to the server itself', async () => {
479+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-test-console-csp-storage-'));
480+
try {
481+
const request = await mount({ services: await storageServices({ adapter: 'local', local: { rootDir } }) });
482+
const res = await request('/_console/');
483+
expect(directives(res.headers.get('content-security-policy')).get('connect-src')).toEqual([
484+
"'self'",
485+
'https://demotiles.maplibre.org',
486+
]);
487+
} finally {
488+
fs.rmSync(rootDir, { recursive: true, force: true });
489+
}
490+
});
491+
492+
it('adds nothing, and says so once, when the store\'s endpoint is one the presigner refuses', async () => {
493+
const warnings: string[] = [];
494+
const services = await storageServices({ adapter: 's3', s3: { bucket: 'csp-uploads', region: 'auto', endpoint: 'not a url', keyPrefix: null } });
495+
const request = await mount({ services, warnings });
496+
for (let i = 0; i < 2; i += 1) {
497+
const res = await request('/_console/');
498+
expect(res.status).toBe(200);
499+
expect(directives(res.headers.get('content-security-policy')).get('connect-src')).toEqual([
500+
"'self'",
501+
'https://demotiles.maplibre.org',
502+
]);
503+
}
504+
expect(warnings.filter((w) => w.includes('presigns uploads to'))).toHaveLength(1);
505+
});
506+
507+
it('keeps OS_CONSOLE_CSP_CONNECT_SRC working beside it, after the storage origin', async () => {
508+
process.env.OS_CONSOLE_CSP_CONNECT_SRC = 'https://glyphs.example.com';
509+
const request = await mount({ services: await storageServices(R2_STORAGE) });
510+
const res = await request('/_console/');
511+
expect(directives(res.headers.get('content-security-policy')).get('connect-src')).toEqual([
512+
"'self'",
513+
'https://demotiles.maplibre.org',
514+
'https://csp-uploads.acct123.r2.cloudflarestorage.com',
515+
'https://glyphs.example.com',
516+
]);
517+
});
518+
});

‎packages/cli/src/utils/console.ts‎

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -826,8 +826,10 @@ const DEFAULT_MAP_STYLE_ORIGIN = 'https://demotiles.maplibre.org';
826826
* when the operator names one in `OS_CONSOLE_MAP_STYLE_URL` (the URL
827827
* `GET /api/v1/runtime/config` serves as `mapStyleUrl`), the
828828
* error-reporting endpoint when the runtime serves a DSN
829-
* (`telemetry.errorReporting.dsn` from the same door), and the hosts an
830-
* operator names in `OS_CONSOLE_CSP_CONNECT_SRC`.
829+
* (`telemetry.errorReporting.dsn` from the same door), the store the
830+
* deployment's storage presigns browser uploads to (S3 and S3-compatible
831+
* stores; `readStorageUploadOrigin`), and the hosts an operator names in
832+
* `OS_CONSOLE_CSP_CONNECT_SRC`.
831833
* - `default-src 'self'` covers the rest the Console uses: the module workers
832834
* maplibre starts from its own file, the PWA manifest, and the same-origin
833835
* frames (the Studio's live canvas). The build ships no web fonts.
@@ -842,6 +844,7 @@ function consoleContentSecurityPolicy(input: {
842844
reactPages: boolean;
843845
errorReportingOrigin: string | undefined;
844846
mapStyleOrigin: string | undefined;
847+
storageUploadOrigin: string | undefined;
845848
extraConnectSources: readonly string[];
846849
embeddable: boolean;
847850
}): string {
@@ -850,6 +853,7 @@ function consoleContentSecurityPolicy(input: {
850853
const connectSrc = ["'self'", DEFAULT_MAP_STYLE_ORIGIN];
851854
if (input.mapStyleOrigin !== undefined) connectSrc.push(input.mapStyleOrigin);
852855
if (input.errorReportingOrigin !== undefined) connectSrc.push(input.errorReportingOrigin);
856+
if (input.storageUploadOrigin !== undefined) connectSrc.push(input.storageUploadOrigin);
853857
connectSrc.push(...input.extraConnectSources);
854858
const directives: string[][] = [
855859
['default-src', "'self'"],
@@ -1030,6 +1034,21 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
10301034
);
10311035
};
10321036

1037+
// A storage service that cannot say where its presigned uploads go leaves
1038+
// the store out of the policy, so browser uploads to it are refused while
1039+
// every other part of the page works: said once, at `warn`.
1040+
let reportedStorageUploadOriginFailure = false;
1041+
const reportStorageUploadOriginFailure = (cause: string) => {
1042+
if (reportedStorageUploadOriginFailure) return;
1043+
reportedStorageUploadOriginFailure = true;
1044+
ctx.logger?.warn?.(
1045+
`Console static: the storage service could not name the host it presigns uploads to (${cause}), so the `
1046+
+ "console document's content-security-policy does not allow it and browser file uploads to that store "
1047+
+ `are blocked. Check the storage settings, or name the store's origin in ${CONSOLE_CSP_CONNECT_SRC_ENV}. `
1048+
+ 'Reported once; every later console document reads it again.',
1049+
);
1050+
};
1051+
10331052
const readIndexHtml = (reactPages: boolean, nonce: string): string => {
10341053
const raw = fs.readFileSync(indexPath, 'utf-8');
10351054
// Inject <base href="${CONSOLE_PATH}/"> so:
@@ -1068,6 +1087,7 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
10681087
reactPages,
10691088
errorReportingOrigin: shell?.errorReportingOrigin,
10701089
mapStyleOrigin,
1090+
storageUploadOrigin: await readStorageUploadOrigin(ctx, reportStorageUploadOriginFailure),
10711091
extraConnectSources,
10721092
embeddable: reqPath.startsWith(`${PUBLIC_FORM_PAGE}/`),
10731093
});
@@ -1498,6 +1518,53 @@ async function anonymousFormDoorServes(app: any, c: any, slug: string): Promise<
14981518
return res.status === 200;
14991519
}
15001520

1521+
/**
1522+
* The origin the kernel's `storage` service presigns browser uploads to, for
1523+
* the console document's `connect-src`; `undefined` when there is none.
1524+
*
1525+
* With S3 or an S3-compatible store (R2, MinIO) the Console uploads a file by
1526+
* `PUT`ting it straight to the URL `POST /api/v1/storage/upload/presigned`
1527+
* answers, on the store's host. The service names that host itself
1528+
* (`getPresignedUploadOrigin` on `@objectstack/service-storage`'s
1529+
* `SwappableStorageService`, which asks the active adapter's presigner), so
1530+
* the policy allows exactly the origin the upload URL carries, and the rule
1531+
* for building it lives in one place. The service is read off the kernel by
1532+
* name, not imported, because this module imports Node built-ins only
1533+
* (`test/published-subpath-console.pin.test.ts`).
1534+
*
1535+
* Read per document, as the shell config is: a storage settings change swaps
1536+
* the adapter without a restart, and the next document follows it. A
1537+
* deployment without the service, or whose adapter answers no origin (the
1538+
* local adapter's uploads go to this server, which `'self'` covers), adds
1539+
* nothing. An answer that is not a `connect-src` source, or a failure to
1540+
* answer, adds nothing either and is reported through `onFailure`.
1541+
*/
1542+
async function readStorageUploadOrigin(
1543+
ctx: any,
1544+
onFailure: (cause: string) => void,
1545+
): Promise<string | undefined> {
1546+
let storage: { getPresignedUploadOrigin?: () => Promise<unknown> } | undefined;
1547+
try {
1548+
storage = ctx.getService?.('storage');
1549+
} catch {
1550+
return undefined;
1551+
}
1552+
if (typeof storage?.getPresignedUploadOrigin !== 'function') return undefined;
1553+
let origin: unknown;
1554+
try {
1555+
origin = await storage.getPresignedUploadOrigin();
1556+
} catch (e: any) {
1557+
onFailure(e?.message ?? String(e));
1558+
return undefined;
1559+
}
1560+
if (origin === undefined) return undefined;
1561+
if (typeof origin !== 'string' || !CONNECT_SOURCE.test(origin)) {
1562+
onFailure(`it answered ${JSON.stringify(origin)}, which is not a connect-src source`);
1563+
return undefined;
1564+
}
1565+
return origin;
1566+
}
1567+
15011568
/** What the console document is written from, read from {@link RUNTIME_CONFIG_DOOR}. */
15021569
interface ConsoleShellConfig {
15031570
branding?: ConsoleShellBranding;
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `getPresignedUploadOrigin()` answers the origin every presigned upload URL
5+
* carries.
6+
*
7+
* The Console document's `connect-src` names this origin so the browser may
8+
* `PUT` a file straight to the store. An origin assembled beside the presigner
9+
* would drift from it: S3 puts a DNS-compatible bucket in the host name
10+
* (virtual-hosted), templates the region into the AWS host, and falls back to
11+
* the path for a bucket a host name cannot carry. So every case below compares
12+
* the answer with the origin of a REAL presigned upload URL from the same
13+
* adapter, through the REAL SDK, fully offline: presigning is local
14+
* computation over the fake credentials, and nothing here opens a socket.
15+
*/
16+
17+
import { describe, it, expect } from 'vitest';
18+
import { S3StorageAdapter, type S3StorageAdapterOptions } from './s3-storage-adapter.js';
19+
import { SwappableStorageService } from './swappable-storage-service.js';
20+
import { LocalStorageAdapter } from './local-storage-adapter.js';
21+
22+
const CREDENTIALS = { accessKeyId: 'AKIAFAKEFAKEFAKEFAKE', secretAccessKey: 'fake-secret-access-key' };
23+
24+
async function presignedUploadOrigin(options: S3StorageAdapterOptions): Promise<string> {
25+
const adapter = new S3StorageAdapter({ ...CREDENTIALS, ...options });
26+
const presigned = await adapter.getPresignedUpload('dir/file.txt', 60, { contentType: 'text/plain' });
27+
return new URL(presigned.uploadUrl).origin;
28+
}
29+
30+
describe('S3StorageAdapter.getPresignedUploadOrigin', () => {
31+
it.each<[string, S3StorageAdapterOptions, string]>([
32+
[
33+
'an S3-compatible endpoint, virtual-hosted',
34+
{ bucket: 'uploads', region: 'auto', endpoint: 'https://acct123.r2.cloudflarestorage.com', keyPrefix: 'projects/env_1' },
35+
'https://uploads.acct123.r2.cloudflarestorage.com',
36+
],
37+
[
38+
'an S3-compatible endpoint, path-style',
39+
{ bucket: 'uploads', region: 'auto', endpoint: 'https://acct123.r2.cloudflarestorage.com', keyPrefix: null, forcePathStyle: true },
40+
'https://acct123.r2.cloudflarestorage.com',
41+
],
42+
[
43+
'a self-hosted endpoint on a port',
44+
{ bucket: 'uploads', region: 'us-east-1', endpoint: 'http://127.0.0.1:9000', keyPrefix: null, forcePathStyle: true },
45+
'http://127.0.0.1:9000',
46+
],
47+
[
48+
'AWS, region-templated',
49+
{ bucket: 'uploads', region: 'eu-west-2', keyPrefix: null },
50+
'https://uploads.s3.eu-west-2.amazonaws.com',
51+
],
52+
[
53+
'AWS, a bucket a host name cannot carry',
54+
{ bucket: 'uploads.example.com', region: 'us-east-1', keyPrefix: null },
55+
'https://s3.us-east-1.amazonaws.com',
56+
],
57+
])('answers the origin of a real presigned upload URL: %s', async (_name, options, expected) => {
58+
const adapter = new S3StorageAdapter(options);
59+
expect(await adapter.getPresignedUploadOrigin()).toBe(await presignedUploadOrigin(options));
60+
expect(await adapter.getPresignedUploadOrigin()).toBe(expected);
61+
});
62+
63+
it('needs no credentials, so it never consults the SDK credential chain', async () => {
64+
const adapter = new S3StorageAdapter({ bucket: 'uploads', region: 'eu-west-2', keyPrefix: null });
65+
expect(await adapter.getPresignedUploadOrigin()).toBe('https://uploads.s3.eu-west-2.amazonaws.com');
66+
});
67+
68+
it.each([['not a url'], ['ftp://store.example.com']])('rejects on an endpoint the SDK refuses: %j', async (endpoint) => {
69+
const adapter = new S3StorageAdapter({ bucket: 'uploads', region: 'us-east-1', endpoint, keyPrefix: null });
70+
await expect(adapter.getPresignedUploadOrigin()).rejects.toThrow();
71+
});
72+
});
73+
74+
describe('SwappableStorageService.getPresignedUploadOrigin', () => {
75+
it('answers the active adapter\'s origin, and follows a swap', async () => {
76+
const storage = new SwappableStorageService(
77+
new S3StorageAdapter({ bucket: 'uploads', region: 'eu-west-2', keyPrefix: null }),
78+
);
79+
expect(await storage.getPresignedUploadOrigin()).toBe('https://uploads.s3.eu-west-2.amazonaws.com');
80+
storage.swap(new S3StorageAdapter({
81+
bucket: 'moved', region: 'auto', endpoint: 'https://acct456.r2.cloudflarestorage.com', keyPrefix: null, forcePathStyle: true,
82+
}));
83+
expect(await storage.getPresignedUploadOrigin()).toBe('https://acct456.r2.cloudflarestorage.com');
84+
});
85+
86+
it('answers undefined for the local adapter, whose uploads go to the server itself', async () => {
87+
const storage = new SwappableStorageService(new LocalStorageAdapter({ rootDir: '/nonexistent-never-written' }));
88+
expect(await storage.getPresignedUploadOrigin()).toBeUndefined();
89+
});
90+
});

0 commit comments

Comments
 (0)