From 4d8ba29526d502034b1dc3fad8b8c9512ff5b02b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:26:58 +0000 Subject: [PATCH 1/5] test(service-storage): pin the file-reference claim path's organization scope Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB Co-authored-by: Claude --- .../file-reference-organization-scope.test.ts | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 packages/services/service-storage/src/file-reference-organization-scope.test.ts diff --git a/packages/services/service-storage/src/file-reference-organization-scope.test.ts b/packages/services/service-storage/src/file-reference-organization-scope.test.ts new file mode 100644 index 00000000000..d5c54827e72 --- /dev/null +++ b/packages/services/service-storage/src/file-reference-organization-scope.test.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The file-reference claim path resolves a referenced file within the + * organization the write acts in. + * + * `installFileReferenceHooks` looks every file id a file-class field carries up + * in `sys_file`, then either claims the file for the written record or copies + * it into a fresh `sys_file` the record owns (ADR-0104 D3 exclusive ownership). + * The copy has always been stamped for the acting organization + * (`systemWriteContext`). The two LOOKUPS now carry the same organization, so + * the driver's own tenant term (ADR-0131 D8, the wall's verdict) decides which + * files the write can reach — the rule is the platform's, never restated here. + * + * Through a real engine — `ObjectQL` → `SqlDriver` (sqlite in memory) under the + * `isolated` posture — with the hooks installed exactly as + * `StorageServicePlugin.start()` installs them, over a real local storage + * adapter, so a copy really reads and writes bytes. + * + * Pinned, by invariant: + * - a write in one organization neither claims nor copies a file another + * organization holds: the value is kept as written, as an id the platform + * does not manage is, and the other organization's row is untouched; + * - control: a file of the writing organization is still claimed, and one + * another record of that organization owns is still copied, the copy + * stamped for that organization; + * - a write that names no organization is judged exactly as before. + */ + +import { describe, it, expect, 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 { installFileReferenceHooks } from './file-reference-lifecycle.js'; +import { LocalStorageAdapter } from './local-storage-adapter.js'; +import { SystemFile } from './objects/system-file.object.js'; + +const OBJECT = 'xorg_case'; +const SYS = { context: { isSystem: true } }; + +const quietLogger = () => ({ info() {}, warn() {}, debug() {}, error() {} }); + +/** The execution context of a signed-in caller acting in `organization`. */ +const actingIn = (organization: string) => ({ context: { userId: `u_${organization}`, tenantId: organization } }); + +interface Harness { + ql: ObjectQL; + storage: LocalStorageAdapter; + rootDir: string; +} + +let harness: Harness | undefined; + +async function boot(posture: 'isolated' | 'single' = 'isolated'): Promise { + const ql = new ObjectQL({ logger: quietLogger() } as any); + ql.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as any, + true, + ); + await ql.init(); + ql.setTenancyPostureProvider(() => posture); + ql.registry.registerObject(SystemFile as any, 'test-file-reference-organization-scope'); + ql.registry.registerObject( + { name: OBJECT, fields: { name: { type: 'text' }, attachment: { type: 'file' } } } as any, + 'test-file-reference-organization-scope', + ); + await ql.syncSchemas(); + const rootDir = await fs.mkdtemp(join(tmpdir(), 'oss-file-ref-org-')); + const storage = new LocalStorageAdapter({ rootDir }); + installFileReferenceHooks(ql as any, () => storage, quietLogger()); + harness = { ql, storage, rootDir }; + return harness; +} + +afterEach(async () => { + const h = harness; + harness = undefined; + if (!h) return; + await h.ql.destroy().catch(() => {}); + await fs.rm(h.rootDir, { recursive: true, force: true }); +}); + +/** A committed `sys_file` row in `organization`, with its bytes in storage. */ +async function seedFile( + h: Harness, + organization: string, + id: string, + owner?: { recordId: string }, +): Promise { + const key = `user/${id}.txt`; + await h.storage.upload(key, Buffer.from(`bytes of ${id}`)); + await h.ql.insert( + 'sys_file', + { + id, + key, + name: `${id}.txt`, + mime_type: 'text/plain', + size: 11, + scope: 'user', + status: 'committed', + organization_id: organization, + ...(owner ? { ref_object: OBJECT, ref_id: owner.recordId, ref_field: 'attachment' } : {}), + }, + { context: { isSystem: true, tenantId: organization } }, + ); +} + +/** A record of {@link OBJECT} in `organization`, written directly (no file reference). */ +async function seedRecord(h: Harness, organization: string, id: string): Promise { + await h.ql.insert(OBJECT, { id, name: id }, actingIn(organization)); +} + +const fileRows = async (h: Harness) => + (await h.ql.find('sys_file', { ...SYS })) as Array>; +const fileRow = async (h: Harness, id: string) => (await fileRows(h)).find((r) => r.id === id); +const recordRow = async (h: Harness, id: string) => + ((await h.ql.find(OBJECT, { ...SYS })) as Array>).find((r) => r.id === id); + +describe('the file-reference claim path resolves a file within the writing organization', () => { + let h: Harness; + beforeEach(async () => { + h = await boot('isolated'); + }); + + it('an insert in one organization does not take ownership of an unowned file another organization holds', async () => { + await seedFile(h, 'org_b', 'file_b_free'); + + await h.ql.insert(OBJECT, { id: 'case_a1', name: 'a1', attachment: 'file_b_free' }, actingIn('org_a')); + + const file = await fileRow(h, 'file_b_free'); + expect(file?.ref_object ?? null).toBeNull(); + expect(file?.ref_id ?? null).toBeNull(); + expect(file?.organization_id).toBe('org_b'); + // The value is kept as written — an id this organization's platform does + // not manage, exactly as an unknown id is kept. + expect((await recordRow(h, 'case_a1'))?.attachment).toBe('file_b_free'); + }); + + it('an insert in one organization does not copy a file a record of another organization owns', async () => { + await seedRecord(h, 'org_b', 'case_b1'); + await seedFile(h, 'org_b', 'file_b_owned', { recordId: 'case_b1' }); + const before = await fileRows(h); + + await h.ql.insert(OBJECT, { id: 'case_a2', name: 'a2', attachment: 'file_b_owned' }, actingIn('org_a')); + + const after = await fileRows(h); + expect(after.map((r) => r.id).sort()).toEqual(before.map((r) => r.id).sort()); + expect(after.filter((r) => r.organization_id === 'org_a')).toEqual([]); + expect((await recordRow(h, 'case_a2'))?.attachment).toBe('file_b_owned'); + const original = await fileRow(h, 'file_b_owned'); + expect([original?.ref_object, original?.ref_id, original?.ref_field]).toEqual([OBJECT, 'case_b1', 'attachment']); + }); + + it('an update in one organization does not take ownership of an unowned file another organization holds', async () => { + await seedRecord(h, 'org_a', 'case_a3'); + await seedFile(h, 'org_b', 'file_b_free2'); + + await h.ql.update(OBJECT, { id: 'case_a3', attachment: 'file_b_free2' }, actingIn('org_a')); + + const file = await fileRow(h, 'file_b_free2'); + expect(file?.ref_id ?? null).toBeNull(); + expect((await recordRow(h, 'case_a3'))?.attachment).toBe('file_b_free2'); + }); + + it('CONTROL — an unowned file of the writing organization is claimed for the record', async () => { + await seedFile(h, 'org_a', 'file_a_free'); + + await h.ql.insert(OBJECT, { id: 'case_a4', name: 'a4', attachment: 'file_a_free' }, actingIn('org_a')); + + const file = await fileRow(h, 'file_a_free'); + expect([file?.ref_object, file?.ref_id, file?.ref_field]).toEqual([OBJECT, 'case_a4', 'attachment']); + expect((await recordRow(h, 'case_a4'))?.attachment).toBe('file_a_free'); + }); + + it('CONTROL — a file another record of the writing organization owns is copied, the copy stamped for it', async () => { + await seedRecord(h, 'org_a', 'case_a5'); + await seedFile(h, 'org_a', 'file_a_owned', { recordId: 'case_a5' }); + + await h.ql.insert(OBJECT, { id: 'case_a6', name: 'a6', attachment: 'file_a_owned' }, actingIn('org_a')); + + const held = (await recordRow(h, 'case_a6'))?.attachment; + expect(typeof held).toBe('string'); + expect(held).not.toBe('file_a_owned'); + const copy = await fileRow(h, held); + expect(copy?.organization_id).toBe('org_a'); + expect([copy?.ref_object, copy?.ref_id, copy?.ref_field]).toEqual([OBJECT, 'case_a6', 'attachment']); + expect((await h.storage.download(copy!.key)).toString()).toBe('bytes of file_a_owned'); + }); +}); + +describe('a write that carries no organization is judged as before', () => { + it('the single posture: one tenant, so the lookup is not narrowed', async () => { + const h = await boot('single'); + await seedFile(h, 'org_b', 'file_single'); + + await h.ql.insert(OBJECT, { id: 'case_s1', name: 's1', attachment: 'file_single' }, actingIn('org_a')); + + const file = await fileRow(h, 'file_single'); + expect([file?.ref_object, file?.ref_id]).toEqual([OBJECT, 'case_s1']); + }); +}); From c6d8f73e421e812bc428335b03c61d88df2ef084 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:29:56 +0000 Subject: [PATCH 2/5] fix(rest): an anonymous public-form submission carries no file The submit door refuses a value in a file field, naming the field, with the existing VALIDATION_FAILED envelope. The file-reference claim path looks a referenced file up within the writing organization. Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB Co-authored-by: Claude --- packages/rest/src/form-routes.ts | 53 ++++- .../rest/src/public-form-file-field.test.ts | 201 ++++++++++++++++++ .../src/file-reference-lifecycle.ts | 45 +++- 3 files changed, 286 insertions(+), 13 deletions(-) create mode 100644 packages/rest/src/public-form-file-field.test.ts diff --git a/packages/rest/src/form-routes.ts b/packages/rest/src/form-routes.ts index 9d6ebc8a81e..381e0ea3bdb 100644 --- a/packages/rest/src/form-routes.ts +++ b/packages/rest/src/form-routes.ts @@ -77,6 +77,7 @@ import type { CreateDataRequest, GetMetaItemsRequest } from '@objectstack/spec/a import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; import { renderValidationMessage } from '@objectstack/spec/system'; import { validationFailure } from '@objectstack/types'; +import { FILE_REFERENCE_TYPES } from '@objectstack/spec/data'; import type { ServerScopedDataRequest } from './data-action-routes.js'; import { isExpectedRouteError, mapDataError } from './error-response.js'; import { logError } from './log.js'; @@ -595,10 +596,36 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo const judged = requiredFields.filter((name) => !PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(name) && name !== '__proto__' && name !== 'constructor' && name !== 'prototype'); - if (judged.length > 0) { + // [#25572, ruling C (record 6384525042)] An anonymous public-form + // submission carries no file. A value in a field whose declared + // type is a file type (`FILE_REFERENCE_TYPES`) is refused, naming + // the field, before anything is written; an empty or absent file + // field submits as before. A file is provided by a signed-in + // caller, through the authenticated upload doors; this door has + // none, by that ruling, and a guest-facing form that declares a + // file field is refused at publish (the protocol half, #25650). + // Forms published before that refusal existed are why the door + // judges it too. + // + // - Judged on what this door WRITES (`filteredData`): only the + // fields the form declares, which `GET /forms/:slug` already + // publishes, so the refusal names nothing the form does not. + // - The same emptiness rule as `required` (`isEmptyForRequired`). + // - ONE envelope with the `required` judgement above: both are + // collected into one `validationFailure`, the flat + // `400 VALIDATION_FAILED` with `fields[]`, so a submission with + // both problems is told both at once. The field code is the + // closed ADR-0114 catalog's `invalid_value`. + const fileValued = [...allowedFields].filter((name) => + Object.prototype.hasOwnProperty.call(filteredData, name)); + if (judged.length > 0 || fileValued.length > 0) { const objectDef = (await this.readFormObjectDefinitions(p, environmentId)) .find((o: any) => o?.name === match.object); - const missing: Array<{ field: string; code: 'required'; message: string; label: string }> = []; + const labelOf = (def: any, name: string): string => { + const declared = typeof def?.label === 'string' ? def.label.trim() : ''; + return declared.length > 0 ? declared : name; + }; + const refused: Array<{ field: string; code: 'required' | 'invalid_value'; message: string; label: string }> = []; for (const name of judged) { const def = objectDef?.fields?.[name]; const value = Object.prototype.hasOwnProperty.call(filteredData, name) @@ -608,17 +635,29 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo ? isEmptyForRequired(def, value) : isMissing(value); if (!empty) continue; - const declared = typeof def?.label === 'string' ? def.label.trim() : ''; - const label = declared.length > 0 ? declared : name; - missing.push({ + const label = labelOf(def, name); + refused.push({ field: name, code: 'required', message: renderValidationMessage({ messageKey: 'required', label, field: name, params: {} }), label, }); } - if (missing.length > 0) { - throw validationFailure(missing.map((f) => f.message).join('; '), missing); + for (const name of fileValued) { + const def = objectDef?.fields?.[name]; + if (!def || typeof def.type !== 'string' || !FILE_REFERENCE_TYPES.has(def.type)) continue; + if (isEmptyForRequired(def, filteredData[name])) continue; + const label = labelOf(def, name); + refused.push({ + field: name, + code: 'invalid_value', + message: `${label} cannot take a file here: an anonymous public-form submission carries no file. ` + + 'Leave it empty, or sign in and provide the file from your account.', + label, + }); + } + if (refused.length > 0) { + throw validationFailure(refused.map((f) => f.message).join('; '), refused); } } diff --git a/packages/rest/src/public-form-file-field.test.ts b/packages/rest/src/public-form-file-field.test.ts new file mode 100644 index 00000000000..ca8102f74a3 --- /dev/null +++ b/packages/rest/src/public-form-file-field.test.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25572, ruling C] An anonymous public-form submission carries no file. + * + * `POST /forms/:slug/submit` refuses a value in a field whose declared type is + * a file type (`FILE_REFERENCE_TYPES`), naming the field, before anything is + * written. An empty or absent file field submits as before. A file is provided + * by a signed-in caller through the authenticated upload doors; a guest-facing + * form that declares a file field is refused at publish (the protocol half), + * and this door judges the forms published before that refusal existed. + * + * Through the real route — `RestServer` → `ObjectStackProtocolImplementation` + * → `ObjectQL` → `SqlDriver` (sqlite in memory). Only the metadata read is + * stubbed, to serve the form and the object the route resolves. + * + * Pinned, by class: + * - a value in a declared file field, in any shape, is refused: `400`, the + * flat `VALIDATION_FAILED` envelope naming the field with the ADR-0114 + * field code `invalid_value`, the write never attempted and no row stored; + * - a multi-valued file field, and two file fields at once (one refusal, the + * form's order); + * - one envelope with the form's `required` judgement: both are told at once; + * - control: an empty or absent file field submits, `201`, the row stored; + * - the boundary: a file field the form does not declare is judged by the + * door's allow-list as before (dropped, never refused), so the refusal + * names nothing the form does not publish. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +const OBJECT = 'pf_file_supplier_25572'; +const SLUG = 'pf-file-25572'; +const SYS = { context: { isSystem: true } }; + +const SUPPLIER = { + name: OBJECT, + label: 'Supplier', + fields: { + name: { name: 'name', type: 'text' as const, label: 'Supplier Name', required: true }, + email: { name: 'email', type: 'email' as const, label: 'Email' }, + licence: { name: 'licence', type: 'file' as const, label: 'Business Licence' }, + photos: { name: 'photos', type: 'image' as const, label: 'Site Photos', multiple: true }, + note: { name: 'note', type: 'text' as const, label: 'Note' }, + // Declared on the object, NOT on the form. + internal_scan: { name: 'internal_scan', type: 'file' as const, label: 'Internal Scan' }, + }, +}; + +const FORM = { + name: 'pf_file_supplier_form', + object: OBJECT, + viewKind: 'form', + config: { + type: 'simple', + data: { provider: 'object', object: OBJECT }, + sections: [ + { + name: 'supplier', + fields: [ + { field: 'name' }, + { field: 'email', required: true }, + { field: 'licence' }, + { field: 'photos' }, + { field: 'note' }, + ], + }, + ], + sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${SLUG}` }, + }, +}; + +const COMPLETE = { name: 'Acme Parts', email: 'ops@acme.test', note: 'first contact' }; + +function mockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + header: () => res, + end: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: unknown) => { res._json = body; return res; }, + }; + return res; +} + +interface Answer { status: number; body: any } + +describe('[#25572] POST /forms/:slug/submit: an anonymous submission carries no file', () => { + let engine: ObjectQL; + let createData: ReturnType; + let post: (body: Record) => Promise; + const rows = async (): Promise>> => (await engine.find(OBJECT, {}, SYS)) as any; + + beforeAll(async () => { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(SUPPLIER as any); + await engine.syncSchemas(); + await installDefaultOrganization(engine); + + const protocol: any = new ObjectStackProtocolImplementation(engine as any); + const realGetMetaItems = protocol.getMetaItems.bind(protocol); + protocol.getMetaItems = async (request: any) => { + if (request?.type === 'view') return [FORM]; + if (request?.type === 'object') return [SUPPLIER]; + return realGetMetaItems(request); + }; + const realCreateData = protocol.createData.bind(protocol); + createData = vi.fn(realCreateData); + protocol.createData = createData; + const rest = new RestServer(mockServer() as any, protocol, { api: { requireAuth: false } } as any); + rest.registerRoutes(); + const submit = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/forms/:slug/submit'); + expect(submit, 'the submit route is registered').toBeDefined(); + post = async (body) => { + const res = makeRes(); + await submit!.handler({ params: { slug: SLUG }, body: JSON.parse(JSON.stringify(body)) } as any, res); + return { status: res._status ?? 200, body: res._json }; + }; + }, 60_000); + + afterAll(async () => { + await engine?.destroy().catch(() => {}); + }); + + /** A refusal naming exactly `expected` (field, code) in order, the write never attempted, no row stored. */ + const expectRefused = async (body: Record, expected: Array<[string, string, string]>) => { + const before = (await rows()).length; + const calls = createData.mock.calls.length; + const answer = await post(body); + expect(answer.status, JSON.stringify(answer.body)).toBe(400); + expect(answer.body.code).toBe('VALIDATION_FAILED'); + expect(answer.body.fields.map((f: any) => [f.field, f.code, f.label])).toEqual(expected); + for (const f of answer.body.fields) expect(f.message).toContain(f.label); + expect(createData.mock.calls.length, 'the write is never attempted').toBe(calls); + expect((await rows()).length, 'a refused submit stores no row').toBe(before); + return answer; + }; + + it.each([ + ['a file id', '3f2a9c1e-7b4d-4e1a-9c2f-0d1e2f3a4b5c'], + ['an external URL', 'https://files.example.test/licence.pdf'], + ['an inline file value', { url: 'https://files.example.test/licence.pdf', name: 'licence.pdf' }], + ])('an anonymous submission refuses a value in a file field (%s), naming the field', async (_shape, value) => { + await expectRefused({ ...COMPLETE, licence: value }, [['licence', 'invalid_value', 'Business Licence']]); + }, 60_000); + + it('a multi-valued file field carrying a value is refused, naming it', async () => { + await expectRefused({ ...COMPLETE, photos: ['8d1c2b3a-4e5f-4a6b-8c7d-9e0f1a2b3c4d'] }, [['photos', 'invalid_value', 'Site Photos']]); + }, 60_000); + + it('two file fields carrying values → one refusal naming both, in the form\'s order', async () => { + await expectRefused( + { ...COMPLETE, photos: ['8d1c2b3a-4e5f-4a6b-8c7d-9e0f1a2b3c4d'], licence: '3f2a9c1e-7b4d-4e1a-9c2f-0d1e2f3a4b5c' }, + [['licence', 'invalid_value', 'Business Licence'], ['photos', 'invalid_value', 'Site Photos']], + ); + }, 60_000); + + it('one envelope with the form\'s `required`: a missing required field and a file value are told at once', async () => { + const { email: _omitted, ...withoutEmail } = COMPLETE; + await expectRefused( + { ...withoutEmail, licence: '3f2a9c1e-7b4d-4e1a-9c2f-0d1e2f3a4b5c' }, + [['email', 'required', 'Email'], ['licence', 'invalid_value', 'Business Licence']], + ); + }, 60_000); + + it.each([ + ['absent', {}], + ['null', { licence: null, photos: null }], + ["''", { licence: '', photos: '' }], + ['[] on the multi-valued field', { photos: [] }], + ])('CONTROL — an empty or absent file field submits (%s) → 201, the row stored', async (_cell, extra) => { + const before = (await rows()).length; + const answer = await post({ ...COMPLETE, ...extra }); + expect(answer.status, JSON.stringify(answer.body)).toBe(201); + expect(typeof answer.body.id).toBe('string'); + const all = await rows(); + expect(all.length).toBe(before + 1); + const stored = all.find((r) => r.id === answer.body.id); + expect(stored?.name).toBe(COMPLETE.name); + expect([null, undefined, '']).toContain(stored?.licence); + }, 60_000); + + it('BOUNDARY — a file field the form does not declare is dropped by the allow-list as before, never refused', async () => { + const answer = await post({ ...COMPLETE, internal_scan: '3f2a9c1e-7b4d-4e1a-9c2f-0d1e2f3a4b5c' }); + expect(answer.status, JSON.stringify(answer.body)).toBe(201); + const stored = (await rows()).find((r) => r.id === answer.body.id); + expect(stored?.internal_scan ?? null).toBeNull(); + }, 60_000); +}); diff --git a/packages/services/service-storage/src/file-reference-lifecycle.ts b/packages/services/service-storage/src/file-reference-lifecycle.ts index 632a803aa35..319ad0b2f3f 100644 --- a/packages/services/service-storage/src/file-reference-lifecycle.ts +++ b/packages/services/service-storage/src/file-reference-lifecycle.ts @@ -126,6 +126,31 @@ function systemWriteContext(organizationId?: string | null): Record { + return typeof organizationId === 'string' && organizationId.length > 0 + ? { ...SYSTEM_CTX, tenantId: organizationId } + : { ...SYSTEM_CTX }; +} + /** * The organization the write that triggered this hook is acting in, or `null`. * @@ -510,9 +535,10 @@ async function applyCopyOnClaim( for (const token of [...new Set(tokens)]) { const row = await engine.findOne('sys_file', { where: { id: token }, - context: { ...SYSTEM_CTX }, + context: systemReadContext(organizationId), }); - // Unknown id: not a file this platform manages (external/legacy value). + // Unknown id: not a file this platform manages (external/legacy value), + // or not one the acting organization can reach (`systemReadContext`). // Left verbatim — the read resolver ignores it for the same reason. if (!row) continue; @@ -565,6 +591,10 @@ async function applyCopyOnClaim( * service), and the safe outcome is that the original owner keeps the file — * a second record pointing at bytes it does not own is visible to * `verify-references`, whereas a silently transferred owner would not be. + * + * The file is looked up as the acting organization reaches it + * ({@link systemReadContext}), the same lookup copy-on-claim made before the + * write: a file outside that reach is not claimed. */ async function claimFile( engine: FileReferenceEngine, @@ -573,9 +603,10 @@ async function claimFile( object: string, recordId: string, field: string, + organizationId: string | null, ): Promise { - const row = await engine.findOne('sys_file', { where: { id: fileId }, context: { ...SYSTEM_CTX } }); - if (!row) return; // not a platform-managed file — nothing to own + const row = await engine.findOne('sys_file', { where: { id: fileId }, context: systemReadContext(organizationId) }); + if (!row) return; // not a platform-managed file the acting organization reaches — nothing to own if (row.ref_id != null && !isSameSlot(row, object, recordId, field)) { logger.warn( `[storage] file reference: ${fileId} is already owned by ` + @@ -783,10 +814,11 @@ export function installFileReferenceHooks( if (fileFields.length === 0) return; const recordId = row.id; if (recordId == null) return; + const organizationId = actingOrganizationOf(ctx); for (const field of fileFields) { for (const token of [...new Set(idTokensIn(row[field]))]) { try { - await claimFile(engine, logger, token, object, String(recordId), field); + await claimFile(engine, logger, token, object, String(recordId), field, organizationId); } catch (err) { logger.warn( `[storage] file reference: failed to claim ${token} for ${object}/${String(recordId)}.${field} ` + @@ -832,6 +864,7 @@ export function installFileReferenceHooks( // permits is now unreachable on the predicate path. if (!ids || ids.length !== 1) return; const recordId = String(ids[0]); + const organizationId = actingOrganizationOf(ctx); for (const field of fileFields) { // Only a field the write actually TOUCHED can have changed ownership; @@ -845,7 +878,7 @@ export function installFileReferenceHooks( const held = new Set(current.map((r) => String(r.id))); for (const token of incoming) { if (held.has(token)) continue; // already ours - await claimFile(engine, logger, token, object, recordId, field); + await claimFile(engine, logger, token, object, recordId, field, organizationId); } } catch (err) { logger.warn( From 6255860f6924054e2b4a1ea2b7f2655861bd2bc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:32:51 +0000 Subject: [PATCH 3/5] test(service-storage): read the record's stored file value in the organization-scope pins Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB Co-authored-by: Claude --- .../src/file-reference-organization-scope.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/services/service-storage/src/file-reference-organization-scope.test.ts b/packages/services/service-storage/src/file-reference-organization-scope.test.ts index d5c54827e72..88733790b71 100644 --- a/packages/services/service-storage/src/file-reference-organization-scope.test.ts +++ b/packages/services/service-storage/src/file-reference-organization-scope.test.ts @@ -24,7 +24,8 @@ * - control: a file of the writing organization is still claimed, and one * another record of that organization owns is still copied, the copy * stamped for that organization; - * - a write that names no organization is judged exactly as before. + * - the posture decides the reach: under `single` (one tenant) the lookup is + * not narrowed, so no organization comparison lives in this module. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; @@ -33,12 +34,15 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; +import { RAW_FILE_VALUES_CONTEXT_KEY } from '@objectstack/spec/data'; import { installFileReferenceHooks } from './file-reference-lifecycle.js'; import { LocalStorageAdapter } from './local-storage-adapter.js'; import { SystemFile } from './objects/system-file.object.js'; const OBJECT = 'xorg_case'; const SYS = { context: { isSystem: true } }; +/** A system read that answers file fields in their STORED form (the id), never the expanded value. */ +const SYS_RAW = { context: { isSystem: true, [RAW_FILE_VALUES_CONTEXT_KEY]: true } }; const quietLogger = () => ({ info() {}, warn() {}, debug() {}, error() {} }); @@ -117,7 +121,7 @@ const fileRows = async (h: Harness) => (await h.ql.find('sys_file', { ...SYS })) as Array>; const fileRow = async (h: Harness, id: string) => (await fileRows(h)).find((r) => r.id === id); const recordRow = async (h: Harness, id: string) => - ((await h.ql.find(OBJECT, { ...SYS })) as Array>).find((r) => r.id === id); + ((await h.ql.find(OBJECT, { ...SYS_RAW })) as Array>).find((r) => r.id === id); describe('the file-reference claim path resolves a file within the writing organization', () => { let h: Harness; @@ -191,8 +195,8 @@ describe('the file-reference claim path resolves a file within the writing organ }); }); -describe('a write that carries no organization is judged as before', () => { - it('the single posture: one tenant, so the lookup is not narrowed', async () => { +describe('the posture decides the reach, not this module', () => { + it('the single posture is one tenant: the lookup is not narrowed', async () => { const h = await boot('single'); await seedFile(h, 'org_b', 'file_single'); From 9a103efb4b283d7ec7dd8dd478b059a85243a382 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:36:41 +0000 Subject: [PATCH 4/5] chore(changeset): an anonymous public-form submission carries no file Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB Co-authored-by: Claude --- ...5572-public-form-submit-carries-no-file.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .changeset/25572-public-form-submit-carries-no-file.md diff --git a/.changeset/25572-public-form-submit-carries-no-file.md b/.changeset/25572-public-form-submit-carries-no-file.md new file mode 100644 index 00000000000..e1cfebe5890 --- /dev/null +++ b/.changeset/25572-public-form-submit-carries-no-file.md @@ -0,0 +1,24 @@ +--- +'@objectstack/rest': patch +'@objectstack/service-storage': patch +--- + +fix(rest)!: an anonymous public-form submission carries no file + +`POST /api/v1/forms/:slug/submit` (and its environment-scoped twin) now refuses a submission that carries a value in a field whose declared type is a file type (`file`, `image`, `avatar`, `video` or `audio`, the spec's `FILE_REFERENCE_TYPES`). The refusal comes before anything is written. It is the door's existing envelope, `400 VALIDATION_FAILED`, with one `fields[]` entry per file field, coded `invalid_value` (the ADR-0114 field catalog) and naming the field by its label. A missing `required` field in the same submission is reported in the same envelope. An empty or absent file field submits exactly as before. Only the fields the form declares are judged, so the refusal names nothing the form does not already publish. + +This is the server half of the rule "an anonymous public-form submission carries no file". A visitor who needs to provide a file registers and signs in, then submits through a signed-in surface that uploads through the authenticated upload doors. The anonymous door has no upload door of its own, and none is added. The protocol half, refusing at publish a guest-facing form that declares a file field, is separate. This door also judges forms published before that refusal exists. + +`@objectstack/service-storage`: the file-reference claim path now looks a referenced file up within the organization the write acts in. It uses the same channel that already stamps a copied file for that organization, so the driver's tenant scope for the posture in force decides what the lookup sees. A referenced file id outside that scope is kept as written and is neither claimed nor copied, exactly as an id the platform does not manage. A write that carries no organization, and the `single` posture, look files up as before. + +Clause-②: no (narrowing) — the anonymous public-form submit door refuses a request shape it used to accept (a value in a declared file field), and the file-reference claim path reaches only files within the writing organization's tenant scope. Nothing is widened. + +Metadata-protocol: none (a request's handling at an anonymous door and a write path's file bookkeeping; nothing a metadata author writes changes. The publish-time refusal of a guest-facing form with a file field is the protocol half, and ships separately) + + + +**BREAKING** for a public form that collected files anonymously, shipped as `patch` because the metadata protocol does not move. + +- **Before.** A value in a file field the form declares was written with the anonymous submission. +- **Now.** That submission is refused with `400 VALIDATION_FAILED`, naming each file field that carries a value. A submission that leaves every file field empty is accepted as before. +- **Fix.** A public form that collected files: have the submitter register and sign in, then provide the file from their account. Remove the file field from the guest-facing form, or leave it for the visitor to leave empty. From fc95818c95cd298705cdb2bdd8245b4e211eef2a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:47:56 +0000 Subject: [PATCH 5/5] fix(rest): read the object's declarations only when a declared field carries a value The intake-availability pin now asserts its own subject: one object read, the submit's field judgement, and none for the predicate. Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB Co-authored-by: Claude --- packages/rest/src/form-routes.ts | 6 +++++- .../rest/src/public-form-intake-availability.test.ts | 10 +++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/rest/src/form-routes.ts b/packages/rest/src/form-routes.ts index 381e0ea3bdb..e6855e87be1 100644 --- a/packages/rest/src/form-routes.ts +++ b/packages/rest/src/form-routes.ts @@ -616,8 +616,12 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo // `400 VALIDATION_FAILED` with `fields[]`, so a submission with // both problems is told both at once. The field code is the // closed ADR-0114 catalog's `invalid_value`. + // The declared fields this submission carries a value in: the + // only ones the file judgement can refuse, so a submission that + // carries none reads no object declarations for it. const fileValued = [...allowedFields].filter((name) => - Object.prototype.hasOwnProperty.call(filteredData, name)); + Object.prototype.hasOwnProperty.call(filteredData, name) + && !isMissing(filteredData[name])); if (judged.length > 0 || fileValued.length > 0) { const objectDef = (await this.readFormObjectDefinitions(p, environmentId)) .find((o: any) => o?.name === match.object); diff --git a/packages/rest/src/public-form-intake-availability.test.ts b/packages/rest/src/public-form-intake-availability.test.ts index c6e2b04cc25..489496bcd59 100644 --- a/packages/rest/src/public-form-intake-availability.test.ts +++ b/packages/rest/src/public-form-intake-availability.test.ts @@ -171,9 +171,13 @@ describe('[#21476] a public form that cannot take intake on this posture is not it('CONTROL: single posture, walled object — accepted, and the object is not even read for the predicate', async () => { const s = build({ tenancy: 'single' }); expect((await s.post()).statusCode).toBe(201); - // The env-wide view read alone (ADR-0131 C7: the organization layer's - // read is deleted); no object read. - expect(s.getMetaItems.mock.calls.map(([r]) => r.type)).toEqual(['view']); + // The env-wide view read (ADR-0131 C7: the organization layer's read is + // deleted), then exactly ONE object read: the submit's own judgement of + // the fields it writes against their declarations (#25572: a declared + // file field carrying a value is refused), which reads it whenever a + // declared field carries a value. None for the predicate: a predicate + // that read the object on this posture would be a second object read. + expect(s.getMetaItems.mock.calls.map(([r]) => r.type)).toEqual(['view', 'object']); }); it('CONTROL: a degraded walled request reads the posture IN FORCE (single) — accepted', async () => {