Skip to content

Commit 01178c0

Browse files
fix(client): storage.upload sends application/octet-stream for a file of unknown type (#28187)
Fixes #28157 domain:server#3 · os-elon-musk · session_016WV4czuTWNgKR22fKv7jia ## What changes - `packages/client/src/index.ts` (`storage.upload`): the presigned request now sends `mimeType: file.type || 'application/octet-stream'` instead of `file.type` bare. A typed file keeps its own type. - `packages/client/src/storage-wire-dialect.test.ts`: two cases beside the existing storage wire tests. A `File` with `type: ''` produces a presigned request whose `mimeType` is `application/octet-stream`; control: a `File` typed `image/png` keeps `image/png`. - `content/docs/api/plugin-endpoints.mdx`: one sentence under the storage table saying the helper sends `application/octet-stream` for a file of unknown type. - `.changeset/28157-upload-unknown-mime-type.md`: `@objectstack/client` patch. The presigned door (`packages/services/service-storage/src/storage-routes.ts`) is not touched: its `400 INVALID_REQUEST` on an empty `mimeType` is the published contract (`GetPresignedUrlRequestSchema.mimeType` is `min(1)`). Other SDK sites checked: `storage.upload` is the only place in `packages/client/src` that derives this door's `mimeType` from a `File`/`Blob` (`grep -n "\.type\b"` and `grep -rn mimeType` over `packages/client/src`, non-test, at `d70e94ae`: one hit, line 5371). `getPresignedUrl` and `initChunkedUpload` take a caller-built request; `uploadPart` and `resumeUpload` send no `mimeType`. ## Why A browser `File` whose type the browser does not know carries `type === ''`. The SDK sent that empty string, the door refused it with `400 INVALID_REQUEST`, and the user could not attach the file at all. ## Verification Premise, measured on `d70e94ae` (unit level): with the fix reverted, the new case reads `"mimeType": ""` off the presigned request. Pin bites (fix committed first, then reverted in the tree, then restored with `git restore --source=HEAD --staged --worktree`): ``` fix reverted: Tests 1 failed | 11 passed (12) - "mimeType": "application/octet-stream", + "mimeType": "", restored: Tests 12 passed (12) ``` - `@objectstack/client` `pnpm test`: 52 files, 682 tests passed, exit 0. - `@objectstack/client` `pnpm typecheck` (tsc + `check:test-typecheck`): exit 0. - Lint in the repo's form (`eslint --no-inline-config`) over the two changed `.ts` files: exit 0. - `dispatch-gates --commands` over the four changed paths: 88 commands, each run with its exit code recorded. Two first exited 3 (`check:skill-examples`, `check:dual-build-cjs-loads`: no `dist/` to read). After `pnpm build` both exited 0. `dispatch-gates --ran`: 88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN. - `origin/main` moved 5 commits since `d70e94ae`. None touches `packages/client`, the storage routes, the storage schema or the docs page, so no merge. Skills-docs: `grep -rn "storage\.upload\|mimeType" skills` has one hit, `skills/objectstack-ai/SKILL.md:237` (`mimeTypes?` on a knowledge source, unrelated), so no skill text changes. `content/docs/api/plugin-endpoints.mdx` gains the one sentence above. `content/docs/api/client-sdk.mdx:504` only shows the call and is unchanged. Clause-②: no Metadata-protocol: none ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (scoped to the changed files). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [ ] The *Before Submitting* list in CONTRIBUTING.md is done. (Not run: the full repo-wide `pnpm test`. The client package suite and the 88 derived gates were run instead.) --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4czuTWNgKR22fKv7jia)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent d359857 commit 01178c0

4 files changed

Lines changed: 80 additions & 2 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/client': patch
3+
---
4+
5+
fix(client): `storage.upload` sends `application/octet-stream` for a file of unknown type instead of an empty `mimeType` the presigned door refuses
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A browser `File` whose type the browser does not know carries `type === ''`. `storage.upload(file)` sent that empty string as the presigned request's `mimeType`, and `POST /storage/upload/presigned` refuses an empty `mimeType` with `400 INVALID_REQUEST`, so such a file could not be attached at all. The helper now sends `application/octet-stream` when the file has no type; a typed file keeps its own type. The door is unchanged.

‎content/docs/api/plugin-endpoints.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,9 @@ other adapter.
163163
| GET | `/storage/files/:fileId` | — | Stable browser URL; 302s to the same signed URL (what file fields carry) |
164164

165165
`storage.upload(file)` runs steps 1–3 for you, including the direct-to-storage
166-
`PUT` in step 2.
166+
`PUT` in step 2. Step 1 refuses an empty `mimeType` with `400 INVALID_REQUEST`,
167+
so for a file whose type the browser does not know (`file.type === ''`) the
168+
helper sends `application/octet-stream`; a typed file keeps its own type.
167169

168170
A file field takes the `fileId` only after step 3. A signed-in record write
169171
that names a file whose upload was never completed is refused with `400

‎packages/client/src/index.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5366,9 +5366,14 @@ export class ObjectStackClient {
53665366
storage = {
53675367
upload: async (file: any, scope: UploadScope = 'user'): Promise<FileUploadResponse> => {
53685368
// 1. Get Presigned URL
5369+
// A browser `File` of unknown type carries `type === ''`, and the
5370+
// presigned door refuses an empty `mimeType` (its published contract,
5371+
// `GetPresignedUrlRequestSchema.mimeType` is `min(1)`). An unknown type
5372+
// is therefore sent as `application/octet-stream`; a typed file keeps
5373+
// its own type.
53695374
const presignedReq: GetPresignedUrlRequest = {
53705375
filename: file.name,
5371-
mimeType: file.type,
5376+
mimeType: file.type || 'application/octet-stream',
53725377
size: file.size,
53735378
scope
53745379
};

‎packages/client/src/storage-wire-dialect.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,3 +246,64 @@ describe('storage.resumeUpload exits on an expired session before uploading (#78
246246
).resolves.toBeDefined();
247247
});
248248
});
249+
250+
/**
251+
* `storage.upload` names a type for a file the browser could not type.
252+
*
253+
* A browser `File` whose type the browser does not know carries
254+
* `type === ''`. The presigned door refuses an empty `mimeType` with
255+
* `400 INVALID_REQUEST` — that refusal is the published contract
256+
* (`GetPresignedUrlRequestSchema.mimeType` is `min(1)`), so the helper is the
257+
* one that must name a type: an unknown type is sent as
258+
* `application/octet-stream`. The control pins the other half — a typed file
259+
* keeps its own type, so the fallback cannot widen into overwriting one.
260+
*/
261+
describe('storage.upload sends application/octet-stream for a file of unknown type', () => {
262+
function uploadHarness() {
263+
const fetchMock = vi.fn(async (url: string) => {
264+
const body = url.includes('/upload/presigned')
265+
? {
266+
success: true,
267+
data: {
268+
uploadUrl: 'http://localhost:3000/api/v1/storage/_local/raw/tok',
269+
method: 'PUT',
270+
fileId: 'f1',
271+
expiresIn: 3600,
272+
},
273+
}
274+
: { success: true, data: { fileId: 'f1' } };
275+
return { ok: true, status: 200, statusText: 'OK', json: async () => body, headers: new Headers() };
276+
});
277+
const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock as any });
278+
const presignedBody = () => {
279+
const call = fetchMock.mock.calls.find(([url]) => String(url).includes('/upload/presigned'));
280+
expect(call, 'storage.upload never asked the presigned door').toBeDefined();
281+
return JSON.parse((call as any)[1].body as string);
282+
};
283+
return { client, presignedBody };
284+
}
285+
286+
it('a File with type "" asks the door for application/octet-stream', async () => {
287+
const { client, presignedBody } = uploadHarness();
288+
const file = new File(['payload'], 'notes.weird-ext', { type: '' });
289+
expect(file.type).toBe('');
290+
291+
await client.storage.upload(file);
292+
293+
expect(presignedBody()).toMatchObject({
294+
filename: 'notes.weird-ext',
295+
mimeType: 'application/octet-stream',
296+
size: 7,
297+
scope: 'user',
298+
});
299+
});
300+
301+
it('control: a typed File keeps its own type', async () => {
302+
const { client, presignedBody } = uploadHarness();
303+
const file = new File(['payload'], 'photo.png', { type: 'image/png' });
304+
305+
await client.storage.upload(file);
306+
307+
expect(presignedBody().mimeType).toBe('image/png');
308+
});
309+
});

0 commit comments

Comments
 (0)