Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/27801-chunked-init-filename-min1.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and no metadata type or stored shape moves: a REST request declaration no metadata type embeds stops admitting an empty `filename` or `mimeType`, values the chunked-upload init door already refused. A caller sends the file's name and MIME type; there is nothing for a migration to rewrite and no tombstone. -->

**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`.
4 changes: 2 additions & 2 deletions content/docs/references/api/storage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
23 changes: 23 additions & 0 deletions packages/services/service-storage/src/storage-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
Expand Down
4 changes: 4 additions & 0 deletions packages/services/service-storage/src/storage-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions packages/spec/src/api/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
6 changes: 4 additions & 2 deletions packages/spec/src/api/storage.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,10 @@ export type FileTypeValidation = z.input<typeof FileTypeValidationSchema>;
* { 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)'),
Expand Down
Loading