From c699b48f941118a24d590ed98c818c49bd62964f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:10:39 +0000 Subject: [PATCH 1/2] fix(spec)!: chunked-upload init request declares filename and mimeType non-empty InitiateChunkedUploadRequestSchema.filename and .mimeType carry min(1), so the declaration says what the init door has always refused. The door keeps its presence check ahead of the scope gate: status, code and message are unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .../27801-chunked-init-filename-min1.md | 19 +++++++++++++++ .../src/storage-routes.test.ts | 23 +++++++++++++++++++ .../service-storage/src/storage-routes.ts | 4 ++++ packages/spec/src/api/storage.test.ts | 22 ++++++++++++++++++ packages/spec/src/api/storage.zod.ts | 6 +++-- 5 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 .changeset/27801-chunked-init-filename-min1.md diff --git a/.changeset/27801-chunked-init-filename-min1.md b/.changeset/27801-chunked-init-filename-min1.md new file mode 100644 index 00000000000..981cff35d9a --- /dev/null +++ b/.changeset/27801-chunked-init-filename-min1.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec)!: `InitiateChunkedUploadRequestSchema.filename` and `.mimeType` declare that they are non-empty, as the chunked-upload init door has always required (#27801) + +Clause-②: no (narrowing) — the published declaration of the chunked-upload init body refuses an empty `filename` or `mimeType`, which it admitted before. The door already refused both with a 400; nothing is widened and no key is added, removed or renamed. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing of a published request declaration (`@objectstack/spec/api`), read by the `POST /api/v1/storage/upload/chunked` door, codegen, the reference docs and clients. + +- **Before.** `filename` and `mimeType` were bare strings, so `''` parsed, and the published JSON Schema admitted an empty value that the door refuses. +- **Now.** Both carry a minimum length of 1, and their descriptions say non-empty. A parse of an empty `filename` or `mimeType` fails on that key. +- **Unchanged.** The door answers an empty or missing `filename` or `mimeType` with `400 INVALID_REQUEST` and the same message as before, ahead of the scope check, and writes no row. A whitespace-only value is still admitted by both. +- **Who sees it.** Code that parses an init body with this schema and sent an empty name or type, or a generated client that trusted the wider declaration. The SDK's `storage.initChunkedUpload` takes this schema's input type, which stays `string` for both keys. + +**The remedy.** Send the file's name and MIME type; an unknown type is sent as `application/octet-stream`. diff --git a/packages/services/service-storage/src/storage-routes.test.ts b/packages/services/service-storage/src/storage-routes.test.ts index 7a712f3b744..02059604545 100644 --- a/packages/services/service-storage/src/storage-routes.test.ts +++ b/packages/services/service-storage/src/storage-routes.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { InitiateChunkedUploadRequestSchema } from '@objectstack/spec/api'; import { LocalStorageAdapter } from './local-storage-adapter.js'; import { StorageMetadataStore } from './metadata-store.js'; import { registerStorageRoutes } from './storage-routes.js'; @@ -284,6 +285,28 @@ describe('Storage REST Routes', () => { expect(createSession, 'no upload-session row').not.toHaveBeenCalled(); }); + // [#27801] An empty `filename` or `mimeType` is refused at the door, and + // the declared request schema refuses the same body: declared and + // enforced agree on `''`. + it.each(['filename', 'mimeType'] as const)( + 'REFUSED 400 INVALID_REQUEST: an empty %s, which the declared schema refuses too, with no row written', + async (key) => { + const initHandler = httpServer._getHandler('POST', '/api/v1/storage/upload/chunked')!; + const createFile = vi.spyOn(store, 'createFile'); + const createSession = vi.spyOn(store, 'createSession'); + const body = { filename: 'b.bin', mimeType: 'application/octet-stream', totalSize: 100, [key]: '' }; + const res = createMockRes(); + await initHandler(createMockReq({ body }), res); + + expect(res._status, JSON.stringify(res._json)).toBe(400); + expect(res._json.success).toBe(false); + expect(res._json.error.code).toBe('INVALID_REQUEST'); + expect(InitiateChunkedUploadRequestSchema.safeParse(body).success, 'the declaration refuses it').toBe(false); + expect(createFile, 'no pending sys_file row').not.toHaveBeenCalled(); + expect(createSession, 'no upload-session row').not.toHaveBeenCalled(); + }, + ); + it.each<[string, number | undefined, number, number]>([ ['an omitted chunkSize takes the declared default', undefined, 5242880, 3], ['a chunkSize at the declared minimum', 5242880, 5242880, 3], diff --git a/packages/services/service-storage/src/storage-routes.ts b/packages/services/service-storage/src/storage-routes.ts index 6b8c79453e4..8fea7953ad7 100644 --- a/packages/services/service-storage/src/storage-routes.ts +++ b/packages/services/service-storage/src/storage-routes.ts @@ -1115,6 +1115,10 @@ export function registerStorageFamily( const session = await requireUploadSession(req, res); if (session === false) return; const body = req.body ?? {}; + // [#27801] The presence check refuses nothing the schema parse below + // accepts: `InitiateChunkedUploadRequestSchema` declares `filename` and + // `mimeType` non-empty. It stays ahead of the scope gate so a missing + // field keeps this answer whatever `scope` says. if (!body.filename || !body.mimeType || body.totalSize == null) { sendError(res, 400, 'INVALID_REQUEST', 'filename, mimeType, and totalSize are required'); return; diff --git a/packages/spec/src/api/storage.test.ts b/packages/spec/src/api/storage.test.ts index dee12d69dbd..a28f2f68dc3 100644 --- a/packages/spec/src/api/storage.test.ts +++ b/packages/spec/src/api/storage.test.ts @@ -338,6 +338,28 @@ describe('InitiateChunkedUploadRequestSchema', () => { }); expect(req.metadata?.source).toBe('upload-form'); }); + + // [#27801] `filename` and `mimeType` are non-empty: the init door has always + // refused `''` for either, and the declaration now says the same. + it.each(['filename', 'mimeType'] as const)('refuses an empty %s, naming that key', (key) => { + const result = InitiateChunkedUploadRequestSchema.safeParse({ + filename: 'report.pdf', + mimeType: 'application/pdf', + totalSize: 1, + [key]: '', + }); + expect(result.success).toBe(false); + expect(result.error!.issues).toHaveLength(1); + expect(result.error!.issues[0].path).toEqual([key]); + expect(result.error!.issues[0].code).toBe('too_small'); + }); + + it('CONTROL: a one-character filename and mimeType parse', () => { + const result = InitiateChunkedUploadRequestSchema.safeParse({ filename: 'a', mimeType: 'b', totalSize: 1 }); + expect(result.success).toBe(true); + expect(result.data!.filename).toBe('a'); + expect(result.data!.mimeType).toBe('b'); + }); }); describe('InitiateChunkedUploadResponseSchema', () => { diff --git a/packages/spec/src/api/storage.zod.ts b/packages/spec/src/api/storage.zod.ts index ba47c422edd..e4b1a731384 100644 --- a/packages/spec/src/api/storage.zod.ts +++ b/packages/spec/src/api/storage.zod.ts @@ -194,8 +194,10 @@ export type FileTypeValidation = z.input; * { filename: 'large-video.mp4', mimeType: 'video/mp4', totalSize: 1073741824, chunkSize: 5242880 } */ export const InitiateChunkedUploadRequestSchema = lazySchema(() => z.object({ - filename: z.string().describe('Original filename'), - mimeType: z.string().describe('File MIME type'), + // [#27801] Non-empty: the init door has always refused an empty `filename` + // or `mimeType` with 400 INVALID_REQUEST, so the declaration says so too. + filename: z.string().min(1).describe('Original filename (non-empty)'), + mimeType: z.string().min(1).describe('File MIME type (non-empty)'), totalSize: z.number().int().min(1).describe('Total file size in bytes'), chunkSize: z.number().int().min(5242880).default(5242880) .describe('Size of each chunk in bytes (minimum 5MB per S3 spec)'), From a739f74eba657e3157e777ac8cd9fef923add956 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:21:19 +0000 Subject: [PATCH 2/2] docs(spec): regenerate the storage reference for the non-empty init keys Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- content/docs/references/api/storage.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/api/storage.mdx b/content/docs/references/api/storage.mdx index e9856b832dc..8259fd190cc 100644 --- a/content/docs/references/api/storage.mdx +++ b/content/docs/references/api/storage.mdx @@ -236,8 +236,8 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **filename** | `string` | ✅ | Original filename | -| **mimeType** | `string` | ✅ | File MIME type | +| **filename** | `string` | ✅ | Original filename (non-empty) | +| **mimeType** | `string` | ✅ | File MIME type (non-empty) | | **totalSize** | `integer` | ✅ | Total file size in bytes | | **chunkSize** | `integer` | optional (default: `5242880`) | Size of each chunk in bytes (minimum 5MB per S3 spec) | | **scope** | `Enum<'user' \| 'tenant' \| 'private' \| 'temp' \| 'attachments'>` | optional (default: `"user"`) | Storage scope the new file is filed under (default user; attachments for the record attachments surface). Not an access setting: the upload doors refuse public, and a file is served without sign-in only when its stored file record carries acl 'public_read' (ADR-0104). The upload request carries no acl; every upload is stored private. |