From d01f8281572269bc50528702ea4c8f7ad9bc6bf5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 06:00:08 +0000 Subject: [PATCH 1/5] feat(spec,service-storage)!: one upload-scope vocabulary for the upload requests, the sys_file select and the upload doors Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .../src/objects/system-file.object.ts | 37 +++- .../service-storage/src/storage-routes.ts | 86 +++++--- .../src/upload-scope-vocabulary.test.ts | 199 ++++++++++++++++++ packages/spec/src/api/storage.test.ts | 49 +++++ packages/spec/src/api/storage.zod.ts | 35 ++- .../18.upload-request-scope-closed.ts | 40 ++++ .../src/type-alias-convention.pin.test.ts | 12 +- 7 files changed, 411 insertions(+), 47 deletions(-) create mode 100644 packages/services/service-storage/src/upload-scope-vocabulary.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.upload-request-scope-closed.ts diff --git a/packages/services/service-storage/src/objects/system-file.object.ts b/packages/services/service-storage/src/objects/system-file.object.ts index 5fdfd8af40f..656831697a5 100644 --- a/packages/services/service-storage/src/objects/system-file.object.ts +++ b/packages/services/service-storage/src/objects/system-file.object.ts @@ -1,6 +1,28 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { UploadScopeSchema, type UploadScope } from '@objectstack/spec/api'; + +/** + * Display labels of the `scope` select, one per upload scope. + * + * The VALUES are not listed here: the select reads them off + * `UploadScopeSchema`, the one upload-scope list `@objectstack/spec` declares + * for the upload requests and the upload doors too (#22470), so the store, the + * request and the doors cannot drift apart. `Record` makes a + * member added to that enum without a label here a compile error. + */ +const UPLOAD_SCOPE_LABELS: Record = { + user: 'User', + tenant: 'Tenant', + private: 'Private', + temp: 'Temp', + // Files uploaded through the generic Attachments surface (#2727). + // Their only legitimate referrers are sys_attachment join rows, so + // this scope is the discriminator for orphan tombstoning (#2755) — + // field-attachment scopes above are never tombstoned. + attachments: 'Attachments', +}; /** * System File Object @@ -62,19 +84,12 @@ export const SystemFile = ObjectSchema.create({ // are rewritten to `user` by `backfill-sys-file-public-scope.ts`, the // operator step that must run before a copy of such a row can succeed. // Anonymous download is `acl: 'public_read'` and nothing else. + // + // The options are `UploadScopeSchema`'s values, in its order, each with + // its label above — the list the upload requests and doors read (#22470). scope: Field.select({ label: 'Scope', - options: [ - { label: 'User', value: 'user' }, - { label: 'Tenant', value: 'tenant' }, - { label: 'Private', value: 'private' }, - { label: 'Temp', value: 'temp' }, - // Files uploaded through the generic Attachments surface (#2727). - // Their only legitimate referrers are sys_attachment join rows, so - // this scope is the discriminator for orphan tombstoning (#2755) — - // field-attachment scopes above are never tombstoned. - { label: 'Attachments', value: 'attachments' }, - ], + options: UploadScopeSchema.options.map((value) => ({ label: UPLOAD_SCOPE_LABELS[value], value })), }), bucket: Field.text({ diff --git a/packages/services/service-storage/src/storage-routes.ts b/packages/services/service-storage/src/storage-routes.ts index b1491750c26..b355ea0a5bd 100644 --- a/packages/services/service-storage/src/storage-routes.ts +++ b/packages/services/service-storage/src/storage-routes.ts @@ -3,6 +3,9 @@ import { randomUUID } from 'node:crypto'; import type { IHttpServer, IHttpRequest, IHttpResponse, IStorageService } from '@objectstack/spec/contracts'; import type { RegisteredErrorCode, StandardErrorCode } from '@objectstack/spec/api'; +// [#22470] The ONE upload-scope list — the upload requests' `scope` and the +// `sys_file.scope` select read it too. A value import: the doors ask it. +import { UploadScopeSchema } from '@objectstack/spec/api'; // The declared envelope is written in ONE place for the whole platform (#3973). import { sendOk, sendError } from '@objectstack/types'; // [#15999] The BRAND predicate, never `instanceof` — this error crosses package @@ -122,35 +125,55 @@ const INCOMPLETE_UPLOAD_STATUS = 409; const INCOMPLETE_UPLOAD_CODE: StandardErrorCode = 'RESOURCE_CONFLICT'; /** - * [#22443] The answer both upload-starting doors (presigned, chunked) give a - * request naming `scope: 'public'` — before the size check, before any row is - * written and before any URL or backend upload is started. + * [#22470] The answer both upload-starting doors (presigned, chunked) give a + * request naming a scope outside the upload-scope vocabulary — before the size + * check, before any row is written and before any URL or backend upload is + * started. * - * The scope never made a file public. Since #22431 the download doors judge a - * file by `acl: 'public_read'`, the attachments scope and field ownership - * alone, so a `public`-scoped upload was stored as a private file, with no - * word to its caller, under a name promising what the runtime does not do. - * ADR-0104 makes `acl: 'public_read'` the one opt-in for anonymous download; - * the scope is refused rather than enforced, because enforcing it would hand - * every uploader a second door to anonymity (the triage ruling on #22443). - * The spec retires the same member from `StorageScopeSchema`. + * The vocabulary is `UploadScopeSchema` (`@objectstack/spec/api`), the one list + * the upload requests' `scope` and the `sys_file.scope` select read too, and + * the message names its values read off that enum. Without this gate a value + * outside it reached the `sys_file` insert, which the data engine refused as an + * invalid option, and the door answered that caller error as `500 INTERNAL` + * telling the operator to restore the data engine. The `500` path stays for a + * real engine fault. An omitted scope is the default `user`, as in the request + * schemas; an explicit `null` is not a scope and is refused. + * + * [#22443] `public` is one such value and its refusal keeps its remedy. The + * scope never made a file public: since #22431 the download doors judge a file + * by `acl: 'public_read'`, the attachments scope and field ownership alone, so + * a `public`-scoped upload was stored as a private file under a name promising + * what the runtime does not do. ADR-0104 makes `acl: 'public_read'` the one + * opt-in for anonymous download; the scope is refused rather than enforced, + * because enforcing it would hand every uploader a second door to anonymity + * (the triage ruling on #22443). So its message also says where the opt-in + * lives — on the stored file record, because the upload request carries no + * `acl` (every upload is stored `acl: 'private'`), so a caller adding one to + * this request would get a private file again. * * `400` / `INVALID_REQUEST`: the code these doors already answer a request * they cannot take with, registered under this package in the ADR-0112 - * ledger. The message carries the remedy and where it lives — on the stored - * file record, because the upload request carries no `acl` (every upload is - * stored `acl: 'private'`), so a caller adding one to this request would get - * a private file again. + * ledger. One gate, one status, one code for every off-vocabulary scope. */ -const RETIRED_UPLOAD_SCOPE = 'public'; -const RETIRED_UPLOAD_SCOPE_STATUS = 400; -const RETIRED_UPLOAD_SCOPE_CODE: RegisteredErrorCode = 'INVALID_REQUEST'; -const RETIRED_UPLOAD_SCOPE_MESSAGE = - "scope 'public' is not accepted: a storage scope never made a file publicly readable. " + - "A file is served without sign-in only when its stored file record carries acl 'public_read' (ADR-0104). " + - "Upload with another scope, or omit scope for the default 'user', then set acl 'public_read' on the stored " + - 'file record of each file that must be readable before sign-in. The upload request carries no acl: every ' + - 'upload is stored private.'; +const UPLOAD_SCOPE_REFUSED_STATUS = 400; +const UPLOAD_SCOPE_REFUSED_CODE: RegisteredErrorCode = 'INVALID_REQUEST'; +const RETIRED_PUBLIC_SCOPE = 'public'; + +function uploadScopeRefusalMessage(scope: unknown): string { + const named = typeof scope === 'string' ? `'${scope}'` : String(JSON.stringify(scope)); + const allowed = + `The upload scopes are ${UploadScopeSchema.options.join(', ')}: name one of them, ` + + "or omit scope for the default 'user'."; + if (scope === RETIRED_PUBLIC_SCOPE) { + return ( + `scope ${named} is not accepted: a storage scope never made a file publicly readable. ${allowed} ` + + "A file is served without sign-in only when its stored file record carries acl 'public_read' (ADR-0104): " + + 'set it on the stored file record of each file that must be readable before sign-in. The upload request ' + + 'carries no acl: every upload is stored private.' + ); + } + return `scope ${named} is not accepted: it is not an upload scope. ${allowed}`; +} /** * [#22332] How many times the chunk door tries to record a stored chunk in the @@ -382,11 +405,13 @@ export function registerStorageRoutes( return false; }; - // [#22443] The scope gate the two upload-starting doors ask before the size - // gate. `false` ⇒ the 400 was already sent and the handler must stop. + // [#22470] The scope gate the two upload-starting doors ask before the size + // gate: an omitted scope (the default `user`) or a member of the spec's + // upload-scope enum, judged by that enum itself. `false` ⇒ the 400 was + // already sent and the handler must stop. const requireAcceptedUploadScope = (scope: unknown, res: IHttpResponse): boolean => { - if (scope !== RETIRED_UPLOAD_SCOPE) return true; - sendError(res, RETIRED_UPLOAD_SCOPE_STATUS, RETIRED_UPLOAD_SCOPE_CODE, RETIRED_UPLOAD_SCOPE_MESSAGE); + if (scope === undefined || UploadScopeSchema.safeParse(scope).success) return true; + sendError(res, UPLOAD_SCOPE_REFUSED_STATUS, UPLOAD_SCOPE_REFUSED_CODE, uploadScopeRefusalMessage(scope)); return false; }; @@ -762,7 +787,8 @@ export function registerStorageRoutes( sendError(res, 400, 'INVALID_REQUEST', 'filename, mimeType, and size are required'); return; } - // [#22443] A retired scope is refused before anything is stored or minted. + // [#22470] A scope outside the upload vocabulary is refused before + // anything is stored or minted. if (!requireAcceptedUploadScope(scope, res)) return; // [#22283] The declared size against the saved limit — before the // pending row and before any URL is minted. On the local adapter the @@ -894,7 +920,7 @@ export function registerStorageRoutes( sendError(res, 400, 'INVALID_REQUEST', 'filename, mimeType, and totalSize are required'); return; } - // [#22443] Same scope gate as the presigned door — before the file row, + // [#22470] Same scope gate as the presigned door — before the file row, // the backend multipart and the session row. if (!requireAcceptedUploadScope(scope, res)) return; // [#22283] The declared total against the saved limit — before the file diff --git a/packages/services/service-storage/src/upload-scope-vocabulary.test.ts b/packages/services/service-storage/src/upload-scope-vocabulary.test.ts new file mode 100644 index 00000000000..05adaa5adbf --- /dev/null +++ b/packages/services/service-storage/src/upload-scope-vocabulary.test.ts @@ -0,0 +1,199 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #22470 — one upload-scope list. `@objectstack/spec` declares it once +// (`UploadScopeSchema`, api/storage.zod.ts); the upload requests' `scope`, the +// `sys_file.scope` select and the two upload-starting doors all read it. +// +// Before this, the request declared `scope` an open string, the doors passed a +// caller's value straight to the `sys_file` insert, and the data engine refused +// anything outside the select as an invalid option — which the door relayed as +// `500 INTERNAL` telling the operator to restore the data engine. The door pins +// below run on a REAL ObjectQL over a REAL SqlDriver on sqlite `:memory:` with +// the real `SystemFile` / `SystemUploadSession` objects, because the defect +// was the engine's select check meeting an open request: a double would only +// restate what this file has to measure. +// +// What is pinned: +// +// - the `sys_file.scope` select's values ARE the enum's values, in order, +// with their labels; +// - `scope: 'avatars'` on each upload-starting door → 400 INVALID_REQUEST +// naming the allowed values, and no `sys_file` row, no session row, no URL +// and no backend call; +// - control: each allowed scope, and an omitted one, still starts an upload +// that the real engine stores; +// - control: a real engine fault on an allowed scope still answers 500. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { UploadScopeSchema } from '@objectstack/spec/api'; +import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { SystemFile } from './objects/system-file.object.js'; +import { SystemUploadSession } from './objects/system-upload-session.object.js'; +import { LocalStorageAdapter } from './local-storage-adapter.js'; +import { StorageMetadataStore } from './metadata-store.js'; +import { registerStorageRoutes } from './storage-routes.js'; + +const PRESIGNED = '/api/v1/storage/upload/presigned'; +const CHUNKED = '/api/v1/storage/upload/chunked'; +const DOORS = [PRESIGNED, CHUNKED] as const; + +// Literal on purpose — the list the select, the request and the doors share. +const UPLOAD_SCOPES = ['user', 'tenant', 'private', 'temp', 'attachments'] as const; + +const silentLogger = () => ({ + info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn(), + trace: vi.fn(), fatal: vi.fn(), child() { return this; }, +}); + +function createMockHttpServer() { + const routes = new Map(); + return { + get: vi.fn((path: string, handler: RouteHandler) => { routes.set(`GET:${path}`, handler); }), + post: vi.fn((path: string, handler: RouteHandler) => { routes.set(`POST:${path}`, handler); }), + put: vi.fn((path: string, handler: RouteHandler) => { routes.set(`PUT:${path}`, handler); }), + delete: vi.fn(), + patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + handler(method: string, path: string): RouteHandler { + const h = routes.get(`${method}:${path}`); + if (!h) throw new Error(`no route ${method} ${path}`); + return h; + }, + }; +} + +function createMockRes(): IHttpResponse & { _status: number; _json: any } { + const res: any = { + _status: 200, + _json: null, + json(data: any) { res._json = data; }, + send(data: any) { res._sent = data; }, + status(code: number) { res._status = code; return res; }, + header() { return res; }, + }; + return res; +} + +const bodyFor = (door: string, scope?: unknown): Record => ({ + filename: 'logo.png', + mimeType: 'image/png', + ...(door === PRESIGNED ? { size: 3 } : { totalSize: 3 }), + ...(scope === undefined ? {} : { scope }), +}); + +describe('the sys_file scope select reads the spec upload-scope enum (#22470)', () => { + const scopeField = (SystemFile as any).fields.scope; + + it('lists exactly the enum values, in the enum order', () => { + expect(scopeField.type).toBe('select'); + expect(scopeField.options.map((o: { value: string }) => o.value)).toEqual([...UploadScopeSchema.options]); + expect([...UploadScopeSchema.options]).toEqual([...UPLOAD_SCOPES]); + }); + + it('keeps one label per scope', () => { + expect(scopeField.options).toEqual([ + { label: 'User', value: 'user' }, + { label: 'Tenant', value: 'tenant' }, + { label: 'Private', value: 'private' }, + { label: 'Temp', value: 'temp' }, + { label: 'Attachments', value: 'attachments' }, + ]); + }); +}); + +describe('the upload-starting doors refuse a scope outside the vocabulary (#22470) — a real ObjectQL over SqlDriver (sqlite :memory:)', () => { + let rootDir: string; + let sql: SqlDriver; + let engine: ObjectQL; + let adapter: LocalStorageAdapter; + let store: StorageMetadataStore; + let server: ReturnType; + + beforeEach(async () => { + rootDir = join(tmpdir(), `os-upload-scope-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await fs.mkdir(rootDir, { recursive: true }); + sql = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + engine = new ObjectQL({ logger: silentLogger() } as any); + engine.registerDriver(sql as any, true); + await engine.init(); + engine.registry.registerObject(SystemFile as any, 'com.objectstack.storage'); + engine.registry.registerObject(SystemUploadSession as any, 'com.objectstack.storage'); + await engine.syncSchemas(); + + adapter = new LocalStorageAdapter({ rootDir, signingSecret: 'test-secret' }); + store = new StorageMetadataStore(engine as any); + server = createMockHttpServer(); + registerStorageRoutes(server as any, adapter, store, { basePath: '/api/v1/storage' }); + }); + + afterEach(async () => { + try { await engine?.destroy(); } catch { /* already torn down */ } + if (rootDir) await fs.rm(rootDir, { recursive: true, force: true }); + }); + + const post = async (door: string, body: Record) => { + const res = createMockRes(); + await server.handler('POST', door)({ params: {}, query: {}, headers: {}, method: 'POST', path: door, body } as IHttpRequest, res); + return res; + }; + const storedFiles = () => engine.find('sys_file', { context: { isSystem: true } } as any); + const storedSessions = () => engine.find('sys_upload_session', { context: { isSystem: true } } as any); + + it("refuses scope 'avatars' on both doors with 400 INVALID_REQUEST naming the allowed values, before any row, URL or backend call", async () => { + const presign = vi.spyOn(adapter, 'getPresignedUpload'); + const initiate = vi.spyOn(adapter, 'initiateChunkedUpload'); + for (const door of DOORS) { + const res = await post(door, bodyFor(door, 'avatars')); + expect(res._status, door).toBe(400); + expect(res._json?.success, door).toBe(false); + expect(res._json?.error?.code, door).toBe('INVALID_REQUEST'); + expect(res._json?.error?.message, door).toMatch(/^scope 'avatars' is not accepted/); + expect(res._json?.error?.message, door).toContain(`The upload scopes are ${UPLOAD_SCOPES.join(', ')}`); + } + expect(await storedFiles()).toHaveLength(0); + expect(await storedSessions()).toHaveLength(0); + expect(presign).not.toHaveBeenCalled(); + expect(initiate).not.toHaveBeenCalled(); + }); + + it('refuses a value that is not a string, and a case variant, the same way', async () => { + for (const door of DOORS) { + for (const scope of [null, 7, 'User']) { + const res = await post(door, bodyFor(door, scope)); + expect(res._status, `${door} ${String(scope)}`).toBe(400); + expect(res._json?.error?.code, `${door} ${String(scope)}`).toBe('INVALID_REQUEST'); + } + } + expect(await storedFiles()).toHaveLength(0); + }); + + it('starts an upload for every allowed scope, and an omitted one, and the engine stores it (control)', async () => { + for (const door of DOORS) { + for (const scope of [...UPLOAD_SCOPES, undefined]) { + const res = await post(door, bodyFor(door, scope)); + expect(res._status, `${door} ${scope}`).toBe(200); + const row = await store.getFile(res._json.data.fileId); + expect(row?.scope, `${door} ${scope}`).toBe(scope ?? 'user'); + } + } + expect(await storedFiles()).toHaveLength(2 * (UPLOAD_SCOPES.length + 1)); + }); + + it('still answers a real engine fault on an allowed scope with 500 INTERNAL (control)', async () => { + const realInsert = engine.insert.bind(engine); + vi.spyOn(engine, 'insert').mockImplementation((object: string, data: any, options?: any) => + object === 'sys_file' ? Promise.reject(new Error('sqlite: disk I/O error')) : realInsert(object, data, options)); + for (const door of DOORS) { + const res = await post(door, bodyFor(door, 'user')); + expect(res._status, door).toBe(500); + expect(res._json?.error?.code, door).toBe('INTERNAL'); + } + }); +}); diff --git a/packages/spec/src/api/storage.test.ts b/packages/spec/src/api/storage.test.ts index 87cd836bdcd..dee12d69dbd 100644 --- a/packages/spec/src/api/storage.test.ts +++ b/packages/spec/src/api/storage.test.ts @@ -15,6 +15,7 @@ import { FileDownloadUrlResponseSchema, RawUploadResponseSchema, StorageApiContracts, + UploadScopeSchema, } from './storage.zod'; describe('GetPresignedUrlRequestSchema', () => { @@ -664,6 +665,54 @@ describe('RawUploadResponseSchema', () => { }); }); +// ========================================== +// Upload Scope Vocabulary (#22470) +// ========================================== + +// The one list the two upload requests, the `sys_file.scope` select and the +// upload doors share. Literal on purpose: a member added or dropped is a +// published contract change, and this pin is where it shows. +const UPLOAD_SCOPES = ['user', 'tenant', 'private', 'temp', 'attachments'] as const; + +describe('UploadScopeSchema', () => { + it('declares exactly the upload scopes, in order', () => { + expect(UploadScopeSchema.options).toEqual([...UPLOAD_SCOPES]); + }); + + it('does not list the retired public scope', () => { + expect(UploadScopeSchema.safeParse('public').success).toBe(false); + }); +}); + +describe('upload requests close scope to UploadScopeSchema', () => { + const REQUESTS = [ + ['GetPresignedUrlRequestSchema', GetPresignedUrlRequestSchema, { filename: 'a.png', mimeType: 'image/png', size: 3 }], + ['InitiateChunkedUploadRequestSchema', InitiateChunkedUploadRequestSchema, { filename: 'a.bin', mimeType: 'application/octet-stream', totalSize: 3 }], + ] as const; + + for (const [name, schema, base] of REQUESTS) { + it(`${name} accepts every upload scope`, () => { + for (const scope of UPLOAD_SCOPES) { + expect(schema.parse({ ...base, scope }).scope, scope).toBe(scope); + } + }); + + it(`${name} keeps the default user`, () => { + expect(schema.parse({ ...base }).scope).toBe('user'); + }); + + it(`${name} refuses a scope outside the vocabulary at parse, on the scope key`, () => { + for (const scope of ['avatars', 'public', 'User', '']) { + const result = schema.safeParse({ ...base, scope }); + expect(result.success, scope).toBe(false); + const issue = result.error!.issues[0]; + expect(issue.path, scope).toEqual(['scope']); + expect(issue.code, scope).toBe('invalid_value'); + } + }); + } +}); + // ========================================== // Storage API Contracts // ========================================== diff --git a/packages/spec/src/api/storage.zod.ts b/packages/spec/src/api/storage.zod.ts index 3ea5a2f89ed..0f103448026 100644 --- a/packages/spec/src/api/storage.zod.ts +++ b/packages/spec/src/api/storage.zod.ts @@ -18,8 +18,37 @@ import { FileMetadataSchema } from '../system/object-storage.zod'; import { lazySchema } from '../shared/lazy-schema'; +/** + * Upload Scope Enum + * + * The storage scope a new uploaded file is filed under — the ONE list the two + * upload requests below, the `scope` select of the `sys_file` object + * (`@objectstack/service-storage`, which builds its options from this enum) + * and the two upload-starting doors share (#22470). The doors refuse any other + * value with `400 INVALID_REQUEST`, naming these values, before a file record, + * an upload URL or a backend upload exists. + * + * `attachments` files the upload under the record attachments surface, whose + * join rows are its only referrers. `public` is not a member: it never made a + * file publicly readable and was retired (#22443) — anonymous download is a + * stored file record's `acl: 'public_read'` (ADR-0104). + * + * Distinct from `StorageScopeSchema` (`system/object-storage.zod.ts`), which + * classifies a storage configuration's area and is not read by any upload. + */ +export const UploadScopeSchema = lazySchema(() => z.enum([ + 'user', + 'tenant', + 'private', + 'temp', + 'attachments', +]).describe('Storage scope an uploaded file is filed under')); + +export type UploadScope = z.input; + // The upload requests' `scope` — the scope a new `sys_file` row is filed under -// (`service-storage` stores it and prefixes the storage key with it). +// (`service-storage` stores it and prefixes the storage key with it), closed to +// `UploadScopeSchema` (#22470). // // Not an access setting, and it never was (#22443): the download doors judge a // file by its `acl`, the `attachments` scope and field ownership alone, so the @@ -38,7 +67,7 @@ export const GetPresignedUrlRequestSchema = lazySchema(() => z.object({ filename: z.string().describe('Original filename'), mimeType: z.string().describe('File MIME type'), size: z.number().describe('File size in bytes'), - scope: z.string().default('user').describe(UPLOAD_SCOPE_DESCRIPTION), + scope: UploadScopeSchema.default('user').describe(UPLOAD_SCOPE_DESCRIPTION), bucket: z.string().optional().describe('Specific bucket override (admin only)'), })); @@ -165,7 +194,7 @@ export const InitiateChunkedUploadRequestSchema = lazySchema(() => z.object({ 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)'), - scope: z.string().default('user').describe(UPLOAD_SCOPE_DESCRIPTION), + scope: UploadScopeSchema.default('user').describe(UPLOAD_SCOPE_DESCRIPTION), bucket: z.string().optional().describe('Specific bucket override (admin only)'), metadata: z.record(z.string(), z.string()).optional().describe('Custom metadata key-value pairs'), })); diff --git a/packages/spec/src/migrations/entries/semantic/18.upload-request-scope-closed.ts b/packages/spec/src/migrations/entries/semantic/18.upload-request-scope-closed.ts new file mode 100644 index 00000000000..4c2b5b3b168 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.upload-request-scope-closed.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #22470 — the upload requests' `scope` (presigned and chunked, +// api/storage.zod.ts) closed from an open string to the new `UploadScope` enum, +// the one list the `sys_file.scope` select is now built from, and the +// `service-storage` upload doors refuse any other value with +// `400 INVALID_REQUEST` naming the allowed values. Semantic only: no metadata +// type carries the upload request, so there is no authored source for a D2 +// conversion to rewrite — what moves is caller code, and which scope a file +// belongs under is a judgement only the caller can make. Nothing stored moves: +// no upload naming another scope ever reached a stored file record. +export const entry: SemanticMigration = { + id: 'upload-request-scope-closed', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span AND a table cell. + surface: + 'the scope of an upload request, presigned or chunked (GetPresignedUrlRequest.scope and ' + + 'InitiateChunkedUploadRequest.scope) — an open string, now the UploadScope enum', + replacement: + 'one of the upload scopes `user`, `tenant`, `private`, `temp` or `attachments` (`UploadScope`, ' + + 'exported from `@objectstack/spec/api`), or no scope for the default `user`', + reason: + 'The upload requests declared scope an open string, while the stored file record only ever took ' + + 'the five values of the scope select of sys_file: any other value was refused by the data engine ' + + 'and answered 500 INTERNAL, with a message that sent the operator to restore the data engine. The ' + + 'request, the select and the upload doors now read one list. A literal outside it fails tsc, a ' + + 'parse of either request refuses it on the scope key, and both upload doors answer it with 400 ' + + 'INVALID_REQUEST naming the allowed values, before a file record, an upload URL or a backend ' + + 'upload exists. A scope is the lifecycle a file is filed under, not a free folder name, so a ' + + 'caller that sent one as a key prefix (avatars, logos, a record path) owes a choice no rewrite ' + + 'can make: `attachments` only for a file whose referrers are record attachment rows, since that ' + + 'scope is what orphan tombstoning reads; `temp` for a scratch file; otherwise `user` or `tenant`. ' + + 'No stored file record moves: no upload naming another scope ever succeeded.', + acceptanceCriteria: + 'Every upload call names one of the five upload scopes or none, and is answered 200; a caller ' + + 'that passes a scope through types it as UploadScope and compiles. An upload naming any other ' + + 'scope is answered 400 INVALID_REQUEST naming the five, and creates no sys_file record.', +}; diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 5081815d0c7..decb3c88b1b 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -274,7 +274,7 @@ import type * as M187 from './shared/duration.zod.js'; import type * as M188 from './ai/build-progress.zod.js'; // --------------------------------------------------------------------------- -// 773 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 774 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -670,6 +670,7 @@ export type Iso_api_storage__CompleteChunkedUploadRequestSchema = Assert, z.infer< typeof M34.CompleteUploadRequestSchema > >>; export type Iso_api_storage__FileTypeValidationSchema = Assert, z.infer< typeof M34.FileTypeValidationSchema > >>; export type Iso_api_storage__UploadChunkRequestSchema = Assert, z.infer< typeof M34.UploadChunkRequestSchema > >>; +export type Iso_api_storage__UploadScopeSchema = Assert, z.infer< typeof M34.UploadScopeSchema > >>; // api/versioning.zod.ts export type Iso_api_versioning__VersionDefinitionSchema = Assert, z.infer< typeof M35.VersionDefinitionSchema > >>; @@ -1671,7 +1672,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 773 isomorphic pins', () => { + it('still declares all 774 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2433,7 +2434,12 @@ describe('ADR-0122 type-alias convention', () => { // in the untouched `Iso_ui_page__PageTypeSchema = Assert<` line, which this // case's `= Assert<` pattern then no longer matched — one pin added, one // pin silently uncounted. Restoring the space is what made the count honest. - expect(pins).toHaveLength(773); + // + // 773 -> 774 is #22470: the upload requests' `scope` closed to the new + // exported `UploadScopeSchema`, a bare enum with no default and no + // transform, so it is pinned here (Iso_api_storage__UploadScopeSchema) + // rather than given an `UploadScopeParsed` synonym. +1 added. + expect(pins).toHaveLength(774); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either From 8ae37c88f6863e5644b7020de805f828ec68152b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 06:06:25 +0000 Subject: [PATCH 2/5] chore(spec): regenerate the migration registry and the spec artifacts the UploadScope export reaches; add the changeset Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .changeset/22470-upload-scope-vocabulary.md | 37 +++++++++++++++++++++ content/docs/references/api/storage.mdx | 23 ++++++++++--- content/docs/references/index.mdx | 10 +++--- packages/spec/api-surface/api.json | 2 ++ packages/spec/declaration-map/api.json | 2 ++ packages/spec/export-origins/api.json | 2 ++ packages/spec/json-schema.manifest/api.json | 1 + packages/spec/src/migrations/registry.ts | 36 ++++++++++++++++++++ 8 files changed, 104 insertions(+), 9 deletions(-) create mode 100644 .changeset/22470-upload-scope-vocabulary.md diff --git a/.changeset/22470-upload-scope-vocabulary.md b/.changeset/22470-upload-scope-vocabulary.md new file mode 100644 index 00000000000..679dda88873 --- /dev/null +++ b/.changeset/22470-upload-scope-vocabulary.md @@ -0,0 +1,37 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-storage': patch +--- + +feat(spec,service-storage)!: one upload-scope list — the upload requests' `scope` closes to the new `UploadScope` enum, the `sys_file` scope select is built from it, and the upload doors answer any other scope with `400` naming the allowed values instead of `500 INTERNAL` (#22470) + +Clause-②: yes (narrowing) + + + +**BREAKING** — an accept-set narrowing on a published request contract, shipped as `minor` under the launch-window convention for accept-set narrowings, plus one new export. + +The presigned and chunked upload requests declared `scope` an open string, while the stored file record (`sys_file.scope`, a closed select) only ever took `user`, `tenant`, `private`, `temp` and `attachments`. Any other value reached the `sys_file` insert, the data engine refused it as an invalid option, and the upload door answered that caller error as `500 INTERNAL`, with a message telling the operator to restore the data engine. + +### What changes + +- **`UploadScopeSchema` / `UploadScope`** (`@objectstack/spec`, new, exported from `@objectstack/spec/api`): the upload-scope vocabulary, declared once — `user`, `tenant`, `private`, `temp`, `attachments`. It is a different list from `StorageScopeSchema`, which classifies a storage configuration and is not read by any upload. +- **`GetPresignedUrlRequestSchema.scope` and `InitiateChunkedUploadRequestSchema.scope`** read it. The default stays `user`. A literal outside the list fails `tsc`, and a parse refuses it on the `scope` key. +- **The `sys_file` scope select** (`@objectstack/service-storage`) takes its options from the enum, in its order, with the same labels. The stored values do not change. +- **The presigned upload door and the chunked upload door** answer a scope outside the list — any string, a case variant, `null`, a number — with `400 INVALID_REQUEST`, naming the allowed values, before a file record, a session record, an upload URL or a backend upload exists. `public` is refused by the same gate and keeps its remedy (`acl: 'public_read'` on the stored file record). An omitted scope is the default `user`, as before. A real data-engine fault still answers `500`. + +### FROM → TO + +| before | what to write instead | +| --- | --- | +| an upload naming a scope outside the list (a key prefix such as `avatars`, a folder, a record path) | one of `user`, `tenant`, `private`, `temp`, `attachments`, or no `scope` for the default `user` | +| a caller passing a scope through typed `string` | type it `UploadScope` (`import type { UploadScope } from '@objectstack/spec/api'`) | + +**The one-line fix: send one of the five upload scopes, or none.** `attachments` is for a file whose referrers are record attachment rows (it is what orphan tombstoning reads); `temp` for a scratch file; otherwise `user` or `tenant`. + +**No stored file record moves**: no upload naming another scope ever succeeded, so no stored record carries one. + +### The kit + +- **D3 entry `upload-request-scope-closed`** carries the judgement no rewrite can make: which scope a file that was sent under a free name really belongs under. No D2 conversion: no metadata type carries the upload request, so `os migrate meta` has no authored source to rewrite. +- **Liveness.** No ledger row: the liveness ledger walks metadata types, and no metadata type carries the upload request. diff --git a/content/docs/references/api/storage.mdx b/content/docs/references/api/storage.mdx index dd1e3d133b5..ec162537944 100644 --- a/content/docs/references/api/storage.mdx +++ b/content/docs/references/api/storage.mdx @@ -19,8 +19,8 @@ rather than proxying bytes through the API server. ## TypeScript Usage ```typescript -import { CompleteChunkedUploadRequestSchema, CompleteChunkedUploadResponseSchema, CompleteUploadRequestSchema, FileDownloadUrlResponseSchema, FileTypeValidationSchema, FileUploadResponseSchema, GetPresignedUrlRequestSchema, InitiateChunkedUploadRequestSchema, InitiateChunkedUploadResponseSchema, PresignedUrlResponseSchema, RawUploadResponseSchema, UploadChunkRequestSchema, UploadChunkResponseSchema, UploadProgressSchema } from '@objectstack/spec/api'; -import type { CompleteChunkedUploadRequest, CompleteChunkedUploadResponse, CompleteUploadRequest, FileDownloadUrlResponse, FileTypeValidation, FileUploadResponse, GetPresignedUrlRequest, InitiateChunkedUploadRequest, InitiateChunkedUploadResponse, PresignedUrlResponse, RawUploadResponse, UploadChunkRequest, UploadChunkResponse, UploadProgress } from '@objectstack/spec/api'; +import { CompleteChunkedUploadRequestSchema, CompleteChunkedUploadResponseSchema, CompleteUploadRequestSchema, FileDownloadUrlResponseSchema, FileTypeValidationSchema, FileUploadResponseSchema, GetPresignedUrlRequestSchema, InitiateChunkedUploadRequestSchema, InitiateChunkedUploadResponseSchema, PresignedUrlResponseSchema, RawUploadResponseSchema, UploadChunkRequestSchema, UploadChunkResponseSchema, UploadProgressSchema, UploadScopeSchema } from '@objectstack/spec/api'; +import type { CompleteChunkedUploadRequest, CompleteChunkedUploadResponse, CompleteUploadRequest, FileDownloadUrlResponse, FileTypeValidation, FileUploadResponse, GetPresignedUrlRequest, InitiateChunkedUploadRequest, InitiateChunkedUploadResponse, PresignedUrlResponse, RawUploadResponse, UploadChunkRequest, UploadChunkResponse, UploadProgress, UploadScope } from '@objectstack/spec/api'; // Validate data const result = CompleteChunkedUploadRequestSchema.parse(data); @@ -224,7 +224,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | **filename** | `string` | ✅ | Original filename | | **mimeType** | `string` | ✅ | File MIME type | | **size** | `number` | ✅ | File size in bytes | -| **scope** | `string` | 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. | +| **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. | | **bucket** | `string` | optional | Specific bucket override (admin only) | @@ -240,7 +240,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | **mimeType** | `string` | ✅ | File MIME type | | **totalSize** | `integer` | ✅ | Total file size in bytes | | **chunkSize** | `integer` | optional (default: `5242880`) | Size of each chunk in bytes (minimum 5MB per S3 spec) | -| **scope** | `string` | 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. | +| **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. | | **bucket** | `string` | optional | Specific bucket override (admin only) | | **metadata** | `Record` | optional | Custom metadata key-value pairs | @@ -497,3 +497,18 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); --- +## UploadScope + +Storage scope an uploaded file is filed under + +### Allowed Values + +* `user` +* `tenant` +* `private` +* `temp` +* `attachments` + + +--- + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 0015e76fd65..ab534321077 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1514 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1515 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 32 | 431 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 32 | 432 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 70 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 29 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 166 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **195** | **1514** | 14 protocol modules | +| **Total** | **195** | **1515** | 14 protocol modules | --- @@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 431 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 432 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -98,7 +98,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`rest-server.zod.ts`](/docs/references/api/rest-server) | `BatchEndpointsConfig`, `CrudEndpointsConfig`, `CrudOperation`, `EndpointRegistry`, `GeneratedEndpoint`, `MetadataEndpointsConfig`, `RestApiConfig`, `RestServerConfig`, `RouteGenerationConfig` | | [`router.zod.ts`](/docs/references/api/router) | `ConflictResolutionStrategy`, `HttpMethod`, `RouteCategory`, `RouteDefinition`, `RouterConfig` | | [`sortability.zod.ts`](/docs/references/api/sortability) | `FieldSortability`, `ObjectSortability` | -| [`storage.zod.ts`](/docs/references/api/storage) | `CompleteChunkedUploadRequest`, `CompleteChunkedUploadResponse`, `CompleteUploadRequest`, `FileDownloadUrlResponse`, `FileTypeValidation`, `FileUploadResponse`, `GetPresignedUrlRequest`, `InitiateChunkedUploadRequest`, `InitiateChunkedUploadResponse`, `PresignedUrlResponse`, `RawUploadResponse`, `UploadChunkRequest`, `UploadChunkResponse`, `UploadProgress` | +| [`storage.zod.ts`](/docs/references/api/storage) | `CompleteChunkedUploadRequest`, `CompleteChunkedUploadResponse`, `CompleteUploadRequest`, `FileDownloadUrlResponse`, `FileTypeValidation`, `FileUploadResponse`, `GetPresignedUrlRequest`, `InitiateChunkedUploadRequest`, `InitiateChunkedUploadResponse`, `PresignedUrlResponse`, `RawUploadResponse`, `UploadChunkRequest`, `UploadChunkResponse`, `UploadProgress`, `UploadScope` | | [`versioning.zod.ts`](/docs/references/api/versioning) | `VersionDefinition`, `VersionNegotiationResponse`, `VersionStatus`, `VersioningConfig`, `VersioningStrategy` | | [`websocket.zod.ts`](/docs/references/api/websocket) | `AckMessage`, `CursorMessage`, `CursorPosition`, `DocumentState`, `EditMessage`, `EditOperation`, `EditOperationType`, `ErrorMessage`, `EventMessage`, `EventPattern`, `EventSubscription`, `PingMessage`, `PongMessage`, `PresenceMessage`, `PresenceState`, `PresenceUpdate`, `SimpleCursorPosition`, `SimplePresenceState`, `SubscribeMessage`, `UnsubscribeMessage`, `UnsubscribeRequest`, `WebSocketConfig`, `WebSocketEvent`, `WebSocketMessage`, `WebSocketMessageType`, `WebSocketPresenceStatus`, `WebSocketServerConfig` | diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 5e1c117823f..4ffeb5e6fc4 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -1035,6 +1035,8 @@ "UploadProgress (type)", "UploadProgressParsed (type)", "UploadProgressSchema (const)", + "UploadScope (type)", + "UploadScopeSchema (const)", "UserProfileResponse (type)", "UserProfileResponseParsed (type)", "UserProfileResponseSchema (const)", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index 2e4d0fd23a0..4478da5b68d 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -758,6 +758,8 @@ "UploadChunkResponseSchema": "api/UploadChunkResponse", "UploadProgress": "api/UploadProgress", "UploadProgressSchema": "api/UploadProgress", + "UploadScope": "api/UploadScope", + "UploadScopeSchema": "api/UploadScope", "UserProfileResponse": "api/UserProfileResponse", "UserProfileResponseSchema": "api/UserProfileResponse", "ValidateDataIssue": "api/ValidateDataIssue", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 40a19b403ce..b5038b9480e 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -993,6 +993,8 @@ "UploadProgress": "src/api/storage.zod.ts#UploadProgress (type)", "UploadProgressParsed": "src/api/storage.zod.ts#UploadProgressParsed (type)", "UploadProgressSchema": "src/api/storage.zod.ts#UploadProgressSchema (const)", + "UploadScope": "src/api/storage.zod.ts#UploadScope (type)", + "UploadScopeSchema": "src/api/storage.zod.ts#UploadScopeSchema (const)", "UserProfileResponse": "src/api/auth.zod.ts#UserProfileResponse (type)", "UserProfileResponseParsed": "src/api/auth.zod.ts#UserProfileResponseParsed (type)", "UserProfileResponseSchema": "src/api/auth.zod.ts#UserProfileResponseSchema (const)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index b3aa076b28e..0555256309f 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -416,6 +416,7 @@ "api/UploadChunkRequest", "api/UploadChunkResponse", "api/UploadProgress", + "api/UploadScope", "api/UserProfileResponse", "api/ValidateDataIssue", "api/ValidateDataRequest", diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 63d0b662bcf..cb89d91669a 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -23216,6 +23216,42 @@ const step18: MigrationStep = { + 'fixture and docs example at the pin above (13 occurrences); in the cloud repo none ' + 'exist.', }, + // #22470 — the upload requests' `scope` (presigned and chunked, + // api/storage.zod.ts) closed from an open string to the new `UploadScope` enum, + // the one list the `sys_file.scope` select is now built from, and the + // `service-storage` upload doors refuse any other value with + // `400 INVALID_REQUEST` naming the allowed values. Semantic only: no metadata + // type carries the upload request, so there is no authored source for a D2 + // conversion to rewrite — what moves is caller code, and which scope a file + // belongs under is a judgement only the caller can make. Nothing stored moves: + // no upload naming another scope ever reached a stored file record. + { + id: 'upload-request-scope-closed', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span AND a table cell. + surface: + 'the scope of an upload request, presigned or chunked (GetPresignedUrlRequest.scope and ' + + 'InitiateChunkedUploadRequest.scope) — an open string, now the UploadScope enum', + replacement: + 'one of the upload scopes `user`, `tenant`, `private`, `temp` or `attachments` (`UploadScope`, ' + + 'exported from `@objectstack/spec/api`), or no scope for the default `user`', + reason: + 'The upload requests declared scope an open string, while the stored file record only ever took ' + + 'the five values of the scope select of sys_file: any other value was refused by the data engine ' + + 'and answered 500 INTERNAL, with a message that sent the operator to restore the data engine. The ' + + 'request, the select and the upload doors now read one list. A literal outside it fails tsc, a ' + + 'parse of either request refuses it on the scope key, and both upload doors answer it with 400 ' + + 'INVALID_REQUEST naming the allowed values, before a file record, an upload URL or a backend ' + + 'upload exists. A scope is the lifecycle a file is filed under, not a free folder name, so a ' + + 'caller that sent one as a key prefix (avatars, logos, a record path) owes a choice no rewrite ' + + 'can make: `attachments` only for a file whose referrers are record attachment rows, since that ' + + 'scope is what orphan tombstoning reads; `temp` for a scratch file; otherwise `user` or `tenant`. ' + + 'No stored file record moves: no upload naming another scope ever succeeded.', + acceptanceCriteria: + 'Every upload call names one of the five upload scopes or none, and is answered 200; a caller ' + + 'that passes a scope through types it as UploadScope and compiles. An upload naming any other ' + + 'scope is answered 400 INVALID_REQUEST naming the five, and creates no sys_file record.', + }, // The D3 entry for the list-view chart-binding check (#22491): the enforce arm // of ADR-0049 enforce-or-remove, applied to the ADR-0021 single form the list // chart block already required. It narrows a list view's accept set; no key is From 9acc50cd2d0c22cb24300a31604126b2e0c657d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 06:45:48 +0000 Subject: [PATCH 3/5] test(service-storage): read the stored rows with typed query options in the upload-scope pins Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .../service-storage/src/upload-scope-vocabulary.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/services/service-storage/src/upload-scope-vocabulary.test.ts b/packages/services/service-storage/src/upload-scope-vocabulary.test.ts index 05adaa5adbf..5a3853ac31d 100644 --- a/packages/services/service-storage/src/upload-scope-vocabulary.test.ts +++ b/packages/services/service-storage/src/upload-scope-vocabulary.test.ts @@ -143,8 +143,8 @@ describe('the upload-starting doors refuse a scope outside the vocabulary (#2247 await server.handler('POST', door)({ params: {}, query: {}, headers: {}, method: 'POST', path: door, body } as IHttpRequest, res); return res; }; - const storedFiles = () => engine.find('sys_file', { context: { isSystem: true } } as any); - const storedSessions = () => engine.find('sys_upload_session', { context: { isSystem: true } } as any); + const storedFiles = () => engine.find('sys_file', {}); + const storedSessions = () => engine.find('sys_upload_session', {}); it("refuses scope 'avatars' on both doors with 400 INVALID_REQUEST naming the allowed values, before any row, URL or backend call", async () => { const presign = vi.spyOn(adapter, 'getPresignedUpload'); From 6b002281e7dd905720b73c065f12de9e8f2dda21 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 07:42:29 +0000 Subject: [PATCH 4/5] feat(client)!: type storage.upload's scope parameter UploadScope Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .changeset/22470-upload-scope-vocabulary.md | 9 ++++++--- packages/client/src/index.ts | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.changeset/22470-upload-scope-vocabulary.md b/.changeset/22470-upload-scope-vocabulary.md index 679dda88873..af4d3f2fda6 100644 --- a/.changeset/22470-upload-scope-vocabulary.md +++ b/.changeset/22470-upload-scope-vocabulary.md @@ -1,15 +1,16 @@ --- '@objectstack/spec': minor '@objectstack/service-storage': patch +'@objectstack/client': minor --- -feat(spec,service-storage)!: one upload-scope list — the upload requests' `scope` closes to the new `UploadScope` enum, the `sys_file` scope select is built from it, and the upload doors answer any other scope with `400` naming the allowed values instead of `500 INTERNAL` (#22470) +feat(spec,service-storage,client)!: one upload-scope list — the upload requests' `scope` and the SDK's `storage.upload` close to the new `UploadScope` enum, the `sys_file` scope select is built from it, and the upload doors answer any other scope with `400` naming the allowed values instead of `500 INTERNAL` (#22470) Clause-②: yes (narrowing) -**BREAKING** — an accept-set narrowing on a published request contract, shipped as `minor` under the launch-window convention for accept-set narrowings, plus one new export. +**BREAKING** — an accept-set narrowing on a published request contract and on the SDK method that sends it, shipped as `minor` under the launch-window convention for accept-set narrowings, plus one new export. The presigned and chunked upload requests declared `scope` an open string, while the stored file record (`sys_file.scope`, a closed select) only ever took `user`, `tenant`, `private`, `temp` and `attachments`. Any other value reached the `sys_file` insert, the data engine refused it as an invalid option, and the upload door answered that caller error as `500 INTERNAL`, with a message telling the operator to restore the data engine. @@ -17,6 +18,7 @@ The presigned and chunked upload requests declared `scope` an open string, while - **`UploadScopeSchema` / `UploadScope`** (`@objectstack/spec`, new, exported from `@objectstack/spec/api`): the upload-scope vocabulary, declared once — `user`, `tenant`, `private`, `temp`, `attachments`. It is a different list from `StorageScopeSchema`, which classifies a storage configuration and is not read by any upload. - **`GetPresignedUrlRequestSchema.scope` and `InitiateChunkedUploadRequestSchema.scope`** read it. The default stays `user`. A literal outside the list fails `tsc`, and a parse refuses it on the `scope` key. +- **`client.storage.upload(file, scope)`** (`@objectstack/client`): the `scope` parameter is typed `UploadScope` instead of `string`, default `user` unchanged, so a scope outside the list fails `tsc` at the SDK call. `client.storage.getPresignedUrl` and `client.storage.initChunkedUpload` take the request types above and narrow with them. - **The `sys_file` scope select** (`@objectstack/service-storage`) takes its options from the enum, in its order, with the same labels. The stored values do not change. - **The presigned upload door and the chunked upload door** answer a scope outside the list — any string, a case variant, `null`, a number — with `400 INVALID_REQUEST`, naming the allowed values, before a file record, a session record, an upload URL or a backend upload exists. `public` is refused by the same gate and keeps its remedy (`acl: 'public_read'` on the stored file record). An omitted scope is the default `user`, as before. A real data-engine fault still answers `500`. @@ -25,7 +27,8 @@ The presigned and chunked upload requests declared `scope` an open string, while | before | what to write instead | | --- | --- | | an upload naming a scope outside the list (a key prefix such as `avatars`, a folder, a record path) | one of `user`, `tenant`, `private`, `temp`, `attachments`, or no `scope` for the default `user` | -| a caller passing a scope through typed `string` | type it `UploadScope` (`import type { UploadScope } from '@objectstack/spec/api'`) | +| `client.storage.upload(file, scope)` called with a `scope` typed `string` (`@objectstack/client`) | pass one of the five, or type the value `UploadScope` (`import type { UploadScope } from '@objectstack/spec/api'`) | +| a caller passing a scope typed `string` into either upload request | type it `UploadScope` | **The one-line fix: send one of the five upload scopes, or none.** `attachments` is for a file whose referrers are record attachment rows (it is what orphan tombstoning reads); `temp` for a scratch file; otherwise `user` or `tenant`. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index bacab6619dd..f42774841ab 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -37,6 +37,7 @@ import { CompleteChunkedUploadRequest, CompleteChunkedUploadResponse, UploadProgress, + UploadScope, ListNotificationsResponse, MarkNotificationsReadResponse, MarkAllNotificationsReadResponse, @@ -5189,7 +5190,7 @@ export class ObjectStackClient { * Storage Services */ storage = { - upload: async (file: any, scope: string = 'user'): Promise => { + upload: async (file: any, scope: UploadScope = 'user'): Promise => { // 1. Get Presigned URL const presignedReq: GetPresignedUrlRequest = { filename: file.name, From 1b715cc92b90733e70a9215e27fc35aae66200f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:10:20 +0000 Subject: [PATCH 5/5] chore(spec): regenerate the reference index over the merged tree The os-regen driver kept the branch's side of content/docs/references/index.mdx; main's side is restored and the page regenerated, so it counts both main's new data schema and this branch's UploadScope (1516 schemas). Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- content/docs/references/index.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index ab534321077..a1db2132191 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1515 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1516 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -23,7 +23,7 @@ counts are sums of the rows they head. Regenerate with | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | | [API Protocol](/docs/references/api) | 32 | 432 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 70 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | -| [Data Protocol](/docs/references/data) | 29 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | +| [Data Protocol](/docs/references/data) | 29 | 175 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 12 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 30 | 157 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 166 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **195** | **1515** | 14 protocol modules | +| **Total** | **195** | **1516** | 14 protocol modules | --- @@ -130,7 +130,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu ## Data Protocol -**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **29 pages, 174 schemas** +**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **29 pages, 175 schemas** Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. @@ -154,7 +154,7 @@ Objects, fields, queries, filters, datasources and drivers — the ObjectQL laye | [`external-catalog.zod.ts`](/docs/references/data/external-catalog) | `ExternalCatalog`, `ExternalColumn`, `ExternalTable` | | [`feed.zod.ts`](/docs/references/data/feed) | `FeedFilterMode`, `FeedItemType` | | [`field.zod.ts`](/docs/references/data/field) | `CurrencyConfig`, `CurrencyValue`, `Field`, `FieldMaskingKeep`, `FieldMaskingRule`, `FieldType`, `InlineGridColumn`, `LocationCoordinates`, `SelectOption`, `UniqueScope` | -| [`field-value.zod.ts`](/docs/references/data/field-value) | `Address`, `AddressValue`, `CalendarDateValue`, `ClockTimeValue`, `FileLikeValue`, `FileReferenceIdValue`, `FileValue`, `InstantValue`, `LocationValue`, `ReferenceIdValue` | +| [`field-value.zod.ts`](/docs/references/data/field-value) | `Address`, `AddressValue`, `CalendarDateValue`, `ClockTimeValue`, `FileLikeValue`, `FileReferenceIdValue`, `FileRefusedValue`, `FileValue`, `InstantValue`, `LocationValue`, `ReferenceIdValue` | | [`filter.zod.ts`](/docs/references/data/filter) | `ComparisonOperator`, `EqualityOperator`, `FieldOperators`, `FieldReference`, `FilterArray`, `FilterCondition`, `NormalizedFilter`, `QueryFilter`, `RangeOperator`, `SetOperator`, `SpecialOperator`, `StringOperator` | | [`hook.zod.ts`](/docs/references/data/hook) | `Hook`, `HookContext`, `HookEvent` | | [`hook-body.zod.ts`](/docs/references/data/hook-body) | `ExpressionBody`, `HookBody`, `HookBodyCapability`, `ScriptBody` |