Skip to content
24 changes: 24 additions & 0 deletions .changeset/25572-public-form-submit-carries-no-file.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is how the anonymous public-form submit door handles a request body (a value in a declared file field is refused) and which files the file-reference claim path can reach for a write (those within the writing organization's tenant scope). The remedy is a change of how a visitor submits (register and sign in to provide a file), never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**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.
57 changes: 50 additions & 7 deletions packages/rest/src/form-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -595,10 +596,40 @@ 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`.
// 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)
&& !isMissing(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)
Expand All @@ -608,17 +639,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);
}
}

Expand Down
201 changes: 201 additions & 0 deletions packages/rest/src/public-form-file-field.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;
let post: (body: Record<string, unknown>) => Promise<Answer>;
const rows = async (): Promise<Array<Record<string, any>>> => (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<string, unknown>, 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);
});
Loading
Loading