diff --git a/.changeset/25650-anonymous-form-file-refusal.md b/.changeset/25650-anonymous-form-file-refusal.md new file mode 100644 index 00000000000..02301ad385c --- /dev/null +++ b/.changeset/25650-anonymous-form-file-refusal.md @@ -0,0 +1,27 @@ +--- +'@objectstack/lint': minor +--- + +feat(lint)!: `objectstack validate`, `compile` and the publish gate refuse a form open to anonymous intake that declares a file field (#25650) + +Clause-②: no (narrowing: a form view open to anonymous intake whose sections declare a field of a `FILE_REFERENCE_TYPES` type is refused at `objectstack validate`, `compile` and the publish gate) +Metadata-protocol: narrowed +Ruling-ref: ruling C on #25572 (the maintainer, 「25619 C 25495 B」, recorded on PR #25619 with the seat record `6383110676`); triage `6385114404` + +A public form takes no attachment. A form view is open to anonymous intake when its `sharing` declares `enabled: true`, `allowAnonymous: true` and a `publicLink` (`anonymousFormIntakeCandidates`, `@objectstack/spec/ui`, the rule both anonymous doors serve from). Such a form collects the fields its sections declare. A visitor has no route to upload a file, so a declared `file`, `image`, `avatar`, `video` or `audio` field can never be filled. The new authoring rule `validatePublicFormFileFields` (rule id `public-form-file-field`, exported as `PUBLIC_FORM_FILE_FIELD`) refuses it, naming the field and the object. + +**BREAKING — what moves for authors.** + +- FROM: a public form whose `sections[].fields` (or legacy `groups[].fields`) name a file-typed field of the bound object published clean. TO: an error. `objectstack validate`, `compile` and `lint` fail with `public-form-file-field` at the field's entry (`views[0].formViews.apply.sections[1].fields[2]`), and an active `view` write answers `422 INVALID_METADATA` with that issue. The item is not stored. This covers `saveMetaItem` in publish mode (Studio, REST `/meta`, MCP) and the promotion of a draft (`publishMetaItem`). All three form shapes are judged: a view's default `form`, each `formViews` entry, and the flattened `config` of a `viewKind: 'form'` item. A bare field name and a `{ field }` entry are both read. + +**Fix (one line).** Take the file field off the public form. To collect a file from someone outside the organization, have them register and sign in, then fill a form that is not open to anonymous intake and declares the field. There is no guest upload. + +**Unchanged.** + +- The parse. No schema shape moves, and nothing is added to `@objectstack/spec`. A stored view carrying such a form keeps loading and serving until it is next saved, and that save is judged. Saving as a draft is still allowed; publishing the draft is judged. +- A form that is not open to anonymous intake (either switch off, or no `publicLink`) is not judged, whatever fields it declares. +- An `object` write is not judged by this rule: its snapshot carries no views. Changing a field's type to a file type under a published public form is caught by `objectstack validate` and `compile`, and by that view's next publish. +- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. +- Measured before crossing: 0 producers declare such a form. `examples/app-crm` (through the built `@objectstack/lint`: 0 findings, 1 for a planted file field on its public form), `app-todo` and `app-multi-package` (0 public forms); `app-showcase`'s one public form, read in source (`inquiry.view.ts`, fields `name`, `email`, `company`, `message`: text and email); `@objectstack/platform-objects` (0 public forms); HotCRM at `a4b3560`, whose two public forms (`src/sales/views/lead.view.ts`, `src/service/views/case.view.ts`) declare text, email, url, select, currency, number and markdown fields only. + + diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index f70fa93de47..e462c172f19 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -758,7 +758,7 @@ os compile --json # JSON output for CI pipelines → Normalizing stack definition... → Lowering inline handlers... → Validating protocol compliance... - → Running author-time rules (52)... + → Running author-time rules (53)... → Checking that every required capability has a provider installable in this edition... → Collecting package docs (ADR-0046)... 0 collected → Writing artifact... diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 4dc2043cf91..20c5abb4e95 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -482,6 +482,7 @@ orthogonal to both, and no cell here can carry it; it is written out in | Membership key declarations — `userField` / `valueField` columns, one key on one object, a key the registered resolver also owns (save door only) (#25234) | ✓ | ✓ | ✓ | ✓ᵒ | | Platform-schedule `create_record` organization (#6285) | — | — | — | ✓ᶠ | | Public-form anonymous intake on this deployment's tenancy posture — advisory only (#21476) | — | — | — | ✓ᵛ | +| Public-form file fields — a form open to anonymous intake whose sections declare a `file`, `image`, `avatar`, `video` or `audio` field (#25650) | ✓ | ✓ | ✓ | ✓ᵛ | | Autonumber `{field}` interpolation | ✓ | ✓ | ✓ | ✓ᵒ | | Email template HTML hole placements — a hole in a placement the mail renderer's encoding does not suit (#25189) | ✓ | ✓ | ✓ | ✓ᵉ | | View references — form targets, view-key collisions (#2554) | ✓ | ✓ | ✓ | — | @@ -741,7 +742,7 @@ A clean run walks the registry and reports timing: Config: /path/to/support-desk/objectstack.config.ts Load time: 21ms → Validating against ObjectStack Protocol... - → Running author-time rules (52)... + → Running author-time rules (53)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index 1d28f9f23b6..bf832a5d41e 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -305,7 +305,7 @@ visible: 'status != "resolved"' ◆ Validate ──────────────────────────────────────── → Validating against ObjectStack Protocol... - → Running author-time rules (52)... + → Running author-time rules (53)... ✗ Author-time rules failed (1 issue) • stack · action 'resolve_ticket' visible: bare reference `status` — a diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx index 68cbc3ccd0d..8fb4b4fb24f 100644 --- a/content/docs/ui/forms.mdx +++ b/content/docs/ui/forms.mdx @@ -240,6 +240,19 @@ lookup route. - To let a visitor pick an existing record, put the form behind sign-in — an internal form (section 6) renders lookup fields with the signed-in user's access. +### File fields + +A public form takes no attachment. There is no anonymous upload route, so a +visitor has no way to fill a file field (`file`, `image`, `avatar`, `video` or +`audio`). A form open to anonymous intake +whose sections declare such a field is therefore refused where it is written: +`os validate`, `os build` and `os lint` fail with `public-form-file-field`, and +publishing the view through Studio, REST `/meta` or MCP answers +`422 INVALID_METADATA` naming the field's entry. + +To collect a file from someone outside the organization, have them register and +sign in, then fill an internal form (section 6) that declares the file field. + ### Auth model - Neither route calls `enforceAuth`, so both work under the always-on anonymous-deny default (there is no `requireAuth` knob to configure since v17). diff --git a/content/docs/ui/public-data-collection.mdx b/content/docs/ui/public-data-collection.mdx index 6b04b0fb15d..12aafdf9fd1 100644 --- a/content/docs/ui/public-data-collection.mdx +++ b/content/docs/ui/public-data-collection.mdx @@ -46,6 +46,8 @@ POST /api/v1/forms/contact-us/submit → INSERT one showcase_inquiry Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side. The submit route also enforces each entry's `required: true` before anything is written — a submission that leaves such a field absent, `null`, blank, or an empty list on a multi-value field is refused with `400 VALIDATION_FAILED` naming the field — except on an entry the form hides (`hidden: true`) or shows only conditionally (`visibleWhen` on the entry or its section), which nothing server-side evaluates. Server-controlled fields (`status`, `source`, owner stamps) are set by a defaults hook, never by the submitter. The submit route derives a narrow `publicFormGrant: { object: 'showcase_inquiry' }` — create + read-back of the row it just made, and nothing else. +The whitelist cannot hold a file field (`file`, `image`, `avatar`, `video`, `audio`): a visitor has no upload route, so a public form declaring one is refused at `os validate` / `os build` and at publish (`public-form-file-field`). Collect files from registered, signed-in users on an internal form instead. + The submit's execution context also names a `guest_portal` permission set. That name authorizes nothing: the grant is decided before any permission set resolves, so the form works the same whether or not a set of that name exists, and such a set's object permissions (`allowCreate`, `allowRead`, …) are not read on this path. Where a deployment registers one, only its field permissions, and the capabilities it holds against a field's `requiredPermissions`, are read, to mask the created record the server hands back to the route. The visitor never sees that record: the submit answers with the new record's `id` only. System-managed anchors (`owner_id`, `organization_id`, audit columns, `id`) are **never** accepted from a public submission — not even when a section declares them, and not through the zero-sections all-fields fallback. The route filter and the data layer enforce the same set (`PUBLIC_FORM_SERVER_MANAGED_FIELDS` from `@objectstack/spec/security`), so an anonymous visitor cannot forge record ownership or tenant placement regardless of how the form is declared (#3022). diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index 7bf7de254a7..bf2ea9e754a 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -390,7 +390,7 @@ objectstack validate ──────────────────────────────────────── → Loading configuration... → Validating against ObjectStack Protocol... - → Running author-time rules (52)... + → Running author-time rules (53)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/packages/lint/src/authoring-rule-wiring.test.ts b/packages/lint/src/authoring-rule-wiring.test.ts index ea36936cf69..e315ea8acaf 100644 --- a/packages/lint/src/authoring-rule-wiring.test.ts +++ b/packages/lint/src/authoring-rule-wiring.test.ts @@ -511,6 +511,8 @@ describe('authoring-rule registry wiring (#4409)', () => { 'validateStackExpressions', 'validatePresetComparands', 'validateReferenceIntegrity', + // #25650 — a form open to anonymous intake declaring a file field. + 'validatePublicFormFileFields', ...FAMILY, ]); }); diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index c536ad88053..e42ab3f57cc 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -128,6 +128,7 @@ import { validateRecordTitle } from './validate-record-title.js'; import { validateFieldConsumers } from './validate-field-consumers.js'; import { validateSemanticRoles } from './validate-semantic-roles.js'; import { validateFormLayout } from './validate-form-layout.js'; +import { validatePublicFormFileFields } from './validate-public-form-file-fields.js'; import { validateSeedReplaySafety } from './validate-seed-replay-safety.js'; import { validateSeedStateMachine } from './validate-seed-state-machine.js'; import { validateVisibilityPredicates } from './validate-visibility-predicates.js'; @@ -1569,6 +1570,26 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT, run: (stack) => validateFormLayout(stack), }, + // Ruling C on #25572 (#25650) — a form open to anonymous intake that + // declares a file field (a `FILE_REFERENCE_TYPES` type on the bound object) + // is refused: no visitor can upload a file, so the field can never be + // filled. The prescription is the ruled route: register and sign in, then + // fill a form that is not public. Gating, so all three commands run it. + // + // Runtime publish gate: a `view` write's snapshot carries `objects` as + // context, which is every input this rule reads, so the view's publish is + // refused at the key. `object` is deliberately not declared: an object + // write's snapshot carries no views, so the rule would judge nothing there. + { + name: 'validatePublicFormFileFields', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-public-form-file-fields.ts', + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['view'], + run: (stack) => validatePublicFormFileFields(stack), + }, // ADR-0078 Phase 3 (Tier-A `action-locations`) — an action that declares no // `locations` and that no view places by name renders on no surface at all. // objectui#3142 made that measurable: four renderers used to show an diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index ed988ea6952..211e012a8a1 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -260,6 +260,10 @@ export { } from './validate-form-layout.js'; export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js'; +// A form open to anonymous intake that declares a file field (ruling C on #25572). +export { validatePublicFormFileFields, PUBLIC_FORM_FILE_FIELD } from './validate-public-form-file-fields.js'; +export type { PublicFormFileFieldFinding } from './validate-public-form-file-fields.js'; + export { validateVisibilityPredicates, VISIBILITY_ROOT_MISLAYERED, diff --git a/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts b/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts index 92c422fa313..64741ff644e 100644 --- a/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts +++ b/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts @@ -193,6 +193,7 @@ describe('#24491 — the page door gives the build\'s visibility-predicate verdi 'validateStackExpressions', 'validatePresetComparands', 'validateReferenceIntegrity', + 'validatePublicFormFileFields', ...FAMILY, ]); const view = (visibleWhen: string, data?: unknown) => ({ diff --git a/packages/lint/src/runtime-gate.test.ts b/packages/lint/src/runtime-gate.test.ts index c59ef8f3b38..9e84d6ab6e4 100644 --- a/packages/lint/src/runtime-gate.test.ts +++ b/packages/lint/src/runtime-gate.test.ts @@ -339,6 +339,9 @@ describe('the views[] visibility-predicate family at the runtime publish gate (# // (`validateSearchableFields` / `validateSortableFields`) on the // per-write snapshot — see `runtime-gate.view-writes.test.ts`. 'validateReferenceIntegrity', + // #25650 — a form open to anonymous intake declaring a file field; + // the snapshot's `objects` context carries the field types it reads. + 'validatePublicFormFileFields', 'validateVisibilityPredicates', 'validatePredicatePathRefs', ]); @@ -539,6 +542,7 @@ describe('the views[] visibility-predicate family at the runtime publish gate (# 'validateStackExpressions', // #24798 — its bulk `confirmText` pass alone, clean here 'validatePresetComparands', // #8793 — dispatched for `view`, clean here 'validateReferenceIntegrity', // #9313 — its two list-view field members, clean here + 'validatePublicFormFileFields', // #25650 — a public form's file fields, clean here 'validateVisibilityPredicates', 'validatePredicatePathRefs', ]); @@ -634,6 +638,7 @@ describe('the publish gate judges a schema-bound form at its own layer (#7815)', 'validateStackExpressions', // #24798 — its bulk `confirmText` pass alone, clean here 'validatePresetComparands', // #8793 — dispatched for `view`, clean here 'validateReferenceIntegrity', // #9313 — its two list-view field members, clean here + 'validatePublicFormFileFields', // #25650 — a public form's file fields, clean here 'validateVisibilityPredicates', 'validatePredicatePathRefs', ]); diff --git a/packages/lint/src/validate-public-form-file-fields.test.ts b/packages/lint/src/validate-public-form-file-fields.test.ts new file mode 100644 index 00000000000..443eff287cc --- /dev/null +++ b/packages/lint/src/validate-public-form-file-fields.test.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Ruling C on #25572 (#25650): a form open to anonymous intake that declares a +// file field is refused at validate, compile and the publish gate, naming the +// field. Pins both doors: the rule over a whole stack (what the three CLI +// commands run through `authoring-rules.ts`), and the runtime publish gate on a +// `view` write (`runRuntimeAuthoringRules`, the function the metadata write +// path calls) resolving the field's type against its `objects` context. + +import { describe, expect, it } from 'vitest'; + +import { FILE_REFERENCE_TYPES } from '@objectstack/spec/data'; + +import { AUTHORING_RULES } from './authoring-rules.js'; +import { runRuntimeAuthoringRules } from './runtime-gate.js'; +import { PUBLIC_FORM_FILE_FIELD, validatePublicFormFileFields } from './validate-public-form-file-fields.js'; + +const supplier = { + name: 'supplier_application', + label: 'Supplier application', + fields: { + company: { type: 'text', label: 'Company' }, + email: { type: 'email', label: 'Email' }, + certificate: { type: 'file', label: 'Certificate' }, + logo: { type: 'image', label: 'Logo' }, + }, +}; + +const OPEN = { enabled: true, allowAnonymous: true, publicLink: '/forms/apply' }; + +const formOf = (fields: unknown[], sharing: Record = OPEN, bucket = 'sections') => ({ + type: 'simple', + data: { provider: 'object', object: 'supplier_application' }, + [bucket]: [{ name: 'about', fields }], + sharing, +}); + +const stackOf = (view: Record, objects: unknown = [supplier]) => ({ objects, views: [view] }); + +describe('validatePublicFormFileFields — the stack rule the CLI commands run', () => { + it('refuses a `{ field }` entry naming a file field on a public formViews entry', () => { + const findings = validatePublicFormFileFields(stackOf({ + name: 'supplier_views', + formViews: { apply: formOf([{ field: 'company' }, { field: 'certificate', required: true }]) }, + })); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: PUBLIC_FORM_FILE_FIELD, + where: 'view "supplier_views" · formViews.apply', + path: 'views[0].formViews.apply.sections[0].fields[1]', + }); + expect(findings[0].message).toContain('"certificate"'); + }); + + it('refuses a bare field name, and every file field in a section, each at its own entry', () => { + const findings = validatePublicFormFileFields(stackOf({ + name: 'supplier_views', + form: formOf(['logo', 'company', 'certificate']), + })); + expect(findings.map((f) => f.path)).toEqual([ + 'views[0].form.sections[0].fields[0]', + 'views[0].form.sections[0].fields[2]', + ]); + }); + + it('refuses a file field in a later section and in the legacy `groups` bucket', () => { + const form = formOf(['company']); + (form.sections as unknown[]).push({ name: 'documents', fields: [{ field: 'certificate' }] }); + expect(validatePublicFormFileFields(stackOf({ name: 'v', form })).map((f) => f.path)) + .toEqual(['views[0].form.sections[1].fields[0]']); + expect(validatePublicFormFileFields(stackOf({ name: 'v', form: formOf(['certificate'], OPEN, 'groups') })) + .map((f) => f.path)).toEqual(['views[0].form.groups[0].fields[0]']); + }); + + it('refuses on the flattened `viewKind: form` item', () => { + const findings = validatePublicFormFileFields(stackOf({ + name: 'supplier_apply', + viewKind: 'form', + config: formOf(['certificate']), + })); + expect(findings.map((f) => f.path)).toEqual(['views[0].config.sections[0].fields[0]']); + }); + + it('refuses every FILE_REFERENCE_TYPES type, and only those', () => { + for (const type of FILE_REFERENCE_TYPES) { + const object = { name: 'supplier_application', fields: { attachment: { type } } }; + const findings = validatePublicFormFileFields(stackOf({ name: 'v', form: formOf(['attachment']) }, [object])); + expect(findings, type).toHaveLength(1); + } + for (const type of ['text', 'textarea', 'url', 'json', 'lookup']) { + const object = { name: 'supplier_application', fields: { attachment: { type } } }; + expect(validatePublicFormFileFields(stackOf({ name: 'v', form: formOf(['attachment']) }, [object])), type) + .toEqual([]); + } + }); + + it('reads an object whose fields are a list', () => { + const object = { name: 'supplier_application', fields: [{ name: 'certificate', type: 'file' }] }; + expect(validatePublicFormFileFields(stackOf({ name: 'v', form: formOf(['certificate']) }, [object]))) + .toHaveLength(1); + }); + + it('resolves the object from the view when the form names none', () => { + const form: Record = formOf(['certificate']); + delete form.data; + expect(validatePublicFormFileFields(stackOf({ name: 'v', object: 'supplier_application', formViews: { apply: form } }))) + .toHaveLength(1); + }); + + it('passes a form that is not open to anonymous intake, whichever switch is off', () => { + for (const sharing of [ + { ...OPEN, allowAnonymous: false }, + { ...OPEN, enabled: false }, + { enabled: true, allowAnonymous: true }, + { allowAnonymous: true, publicLink: '/forms/apply' }, + ]) { + expect(validatePublicFormFileFields(stackOf({ name: 'v', form: formOf(['certificate'], sharing) }))) + .toEqual([]); + } + }); + + it('passes a public form that declares no file field', () => { + expect(validatePublicFormFileFields(stackOf({ name: 'v', form: formOf(['company', { field: 'email' }]) }))) + .toEqual([]); + }); + + it('does not judge a form whose object or field does not resolve', () => { + expect(validatePublicFormFileFields(stackOf({ name: 'v', form: formOf(['certificate']) }, []))).toEqual([]); + expect(validatePublicFormFileFields(stackOf({ name: 'v', form: formOf(['not_a_field']) }))).toEqual([]); + }); + + it('is registered gating, on all three commands and on the publish gate for view writes', () => { + const entry = AUTHORING_RULES.find((r) => r.name === 'validatePublicFormFileFields'); + expect(entry).toMatchObject({ + tier: 'gating', + commands: ['validate', 'build', 'lint'], + surfaces: ['cli', 'runtime-publish'], + runtimeTypes: ['view'], + }); + }); +}); + +describe('the runtime publish gate on a view write', () => { + const gate = (item: unknown) => + runRuntimeAuthoringRules({ type: 'view', item, context: { objects: [supplier] } }); + + it('refuses the publish as an error at the field entry, resolving the type from the objects context', () => { + const { errors } = gate({ + name: 'supplier_views', + formViews: { apply: formOf([{ field: 'company' }, { field: 'certificate' }]) }, + }); + const finding = errors.find((e) => e.rule === PUBLIC_FORM_FILE_FIELD); + expect(finding, JSON.stringify(errors)).toBeDefined(); + expect(finding!.path).toContain('formViews.apply.sections[0].fields[1]'); + }); + + it('admits the same form once the file field is off it', () => { + const { errors } = gate({ + name: 'supplier_views', + formViews: { apply: formOf([{ field: 'company' }, { field: 'email' }]) }, + }); + expect(errors.filter((e) => e.rule === PUBLIC_FORM_FILE_FIELD)).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-public-form-file-fields.ts b/packages/lint/src/validate-public-form-file-fields.ts new file mode 100644 index 00000000000..3325e760bef --- /dev/null +++ b/packages/lint/src/validate-public-form-file-fields.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A form open to anonymous intake that declares a file field is refused + * (ruling C on #25572: a guest form takes no attachment, and there is no guest + * upload door). + * + * A form view is open to anonymous intake when its `sharing` declares + * `enabled: true`, `allowAnonymous: true` and a `publicLink`. Which form + * candidates of a view that opens is ONE rule, `anonymousFormIntakeCandidates` + * (`@objectstack/spec/ui`), the same function both anonymous doors + * (`GET /forms/:slug`, `POST /forms/:slug/submit`) serve from, so this rule + * judges exactly the forms a visitor can reach and no other. + * + * Such a form collects the fields its sections declare: a section field entry + * is a bare field name or `{ field }`, and both doors read the same two + * spellings. When one of them is a field whose type is in + * `FILE_REFERENCE_TYPES` (`@objectstack/spec/data`: `image`, `file`, `avatar`, + * `video`, `audio`), the form declares a field no visitor can fill: a file + * field's value is a `sys_file` id, an anonymous caller has no upload route to + * mint one, and the submit door refuses the value. Declared but impossible, so + * it is refused where it is written, naming the field. + * + * The prescription is the route the ruling chose: the visitor registers and + * signs in, then fills a form that collects the file. Never a guest upload. + * + * ## Where it runs + * + * A field's type lives on the object and the `sharing` on the view, so the + * rule needs both. It is a stack-level authoring rule (`authoring-rules.ts`): + * `os validate`, `os build` and `os lint` run it over the whole stack, and the + * runtime publish gate runs it on a `view` write, whose snapshot carries the + * `objects` collection as resolution context (`runtime-gate.ts`). An `object` + * write's snapshot carries no views, so changing a field's type under a + * published public form is caught by the CLI commands and by that view's next + * publish, not by the object's own write. + * + * Both section spellings are read, `sections` and the legacy `groups` alias + * (folded onto `sections` at parse), because `os lint` hands this rule the + * unparsed stack. A form whose bound object, or whose field, does not resolve + * in the stack is not judged here: the field-reference rules own that answer. + */ + +import { FILE_REFERENCE_TYPES } from '@objectstack/spec/data'; +import { anonymousFormIntakeCandidates, type AnonymousFormIntakeCandidate } from '@objectstack/spec/ui'; + +import { collectionEntries } from './collection-entries.js'; +import { recordsOf } from './object-graph.js'; +import { viewObjectName } from './view-walk.js'; + +export const PUBLIC_FORM_FILE_FIELD = 'public-form-file-field'; + +export interface PublicFormFileFieldFinding { + severity: 'error'; + /** Diagnostic rule id: always {@link PUBLIC_FORM_FILE_FIELD}. */ + rule: string; + /** Human-readable location, e.g. `view "supplier_intake" · formViews.apply`. */ + where: string; + /** Config path of the section field entry, e.g. `views[0].formViews.apply.sections[1].fields[2]`. */ + path: string; + message: string; + hint: string; +} + +type AnyRec = Record; + +function isRec(v: unknown): v is AnyRec { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** object name → field name → declared type. Reads the map and the list shapes of `fields`. */ +function indexFieldTypes(stack: AnyRec): Map> { + const idx = new Map>(); + for (const obj of recordsOf(stack.objects)) { + const name = strName(obj.name); + if (!name) continue; + const types = new Map(); + for (const field of recordsOf(obj.fields)) { + const fieldName = strName(field.name); + const type = strName(field.type); + if (fieldName && type) types.set(fieldName, type); + } + idx.set(name, types); + } + return idx; +} + +/** Where a candidate sits in its view: `form`, `formViews.KEY` or `config`. */ +function candidateSlot(view: AnyRec, candidate: AnonymousFormIntakeCandidate): string { + if (candidate.form === view.form) return 'form'; + if (candidate.key !== undefined && isRec(view.formViews) && view.formViews[candidate.key] === candidate.form) { + return `formViews.${candidate.key}`; + } + return 'config'; +} + +/** + * The object an open candidate submits into, in the order the anonymous doors + * resolve it (`anonymousFormObjectName`, `@objectstack/metadata-core`): the + * form's own binding, then the view's list block, its default form, and the + * view itself. + */ +function candidateObjectName(view: AnyRec, form: AnyRec): string | undefined { + return ( + viewObjectName(form) ?? + (isRec(view.list) ? viewObjectName(view.list) : undefined) ?? + (isRec(view.form) ? viewObjectName(view.form) : undefined) ?? + viewObjectName(view) + ); +} + +/** A section field entry is a bare field name or `{ field, … }`. */ +function fieldNameOf(entry: unknown): string | undefined { + if (typeof entry === 'string') return strName(entry); + return isRec(entry) ? strName(entry.field) : undefined; +} + +/** Refuse every file-typed field a form open to anonymous intake declares. Empty = clean. */ +export function validatePublicFormFileFields(stack: AnyRec): PublicFormFileFieldFinding[] { + const findings: PublicFormFileFieldFinding[] = []; + const fieldTypes = indexFieldTypes(stack); + + for (const { rec: view, path: viewPath } of collectionEntries(stack.views, 'views')) { + const viewName = strName(view.name) ?? strName(view.object) ?? viewPath; + for (const candidate of anonymousFormIntakeCandidates(view)) { + const objectName = candidateObjectName(view, candidate.form); + const types = objectName ? fieldTypes.get(objectName) : undefined; + if (!types) continue; + const slot = candidateSlot(view, candidate); + const where = `view "${viewName}" · ${slot}`; + + for (const bucket of ['sections', 'groups'] as const) { + const sections = Array.isArray(candidate.form[bucket]) ? (candidate.form[bucket] as unknown[]) : []; + for (let s = 0; s < sections.length; s++) { + const section = sections[s]; + const entries = isRec(section) && Array.isArray(section.fields) ? (section.fields as unknown[]) : []; + for (let f = 0; f < entries.length; f++) { + const fieldName = fieldNameOf(entries[f]); + const type = fieldName ? types.get(fieldName) : undefined; + if (!fieldName || !type || !FILE_REFERENCE_TYPES.has(type)) continue; + findings.push({ + severity: 'error', + rule: PUBLIC_FORM_FILE_FIELD, + where, + path: `${viewPath}.${slot}.${bucket}[${s}].fields[${f}]`, + message: + `${viewName}: the form at ${slot} is open to anonymous intake (its sharing publishes ` + + `/forms/${candidate.slug} with allowAnonymous: true) and declares "${fieldName}", a ` + + `${type} field on object "${objectName}". A public form takes no attachment: an ` + + `anonymous visitor has no upload door, so this field can never be filled.`, + hint: + `Take "${fieldName}" off this public form. To collect a file from a visitor, have ` + + `them register and sign in, then fill a form that is not open to anonymous intake ` + + `and declares "${fieldName}".`, + }); + } + } + } + } + } + + return findings; +} diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index 127244708c2..48dcadfd29e 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -4609,3 +4609,108 @@ describe('runtime authoring gate on PERMISSION writes — membership-key ownersh } }); }); + +// #25650 — ruling C on #25572: a form open to anonymous intake that declares a +// file field is refused at publish. `validatePublicFormFileFields` declares +// `runtimeTypes: ['view']`, and a view write's snapshot carries the live +// `objects`, so the field's type resolves at this door exactly as it does at +// `os build`. The lint-level pins (every shape, every file type, the switches) +// are `@objectstack/lint`'s `validate-public-form-file-fields.test.ts`. +// +// ⚠️ This package reaches `@objectstack/lint` through its built `dist/`, so a +// registry edit is invisible here until `pnpm --filter @objectstack/lint build` +// has run. +describe('runtime authoring gate on VIEW writes — a public form declaring a file field (#25650)', () => { + const NAME = 'supplier_application'; + const RULE = 'public-form-file-field'; + /** A container whose `formViews.apply` is open to anonymous intake. */ + const container = (fields: unknown[]) => ({ + list: { type: 'grid', data: { provider: 'object', object: NAME }, columns: [{ field: 'company' }] }, + formViews: { + apply: { + type: 'simple', + data: { provider: 'object', object: NAME }, + sections: [{ name: 'about', fields }], + sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/apply' }, + }, + }, + }); + const withCertificate = () => container([{ field: 'company' }, { field: 'email' }, { field: 'certificate' }]); + const withoutCertificate = () => container([{ field: 'company' }, { field: 'email' }]); + const supplier = { + name: NAME, + label: 'Supplier application', + tenancy: { enabled: false }, + fields: { + company: { type: 'text', label: 'Company' }, + email: { type: 'email', label: 'Email' }, + certificate: { type: 'file', label: 'Certificate' }, + }, + }; + + function viewProtocol() { + const { engine, rows } = makeStubEngine(); + engine.registry.listItems = (type: string) => (type === 'object' ? [supplier] : []); + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any; + return { protocol, rows }; + } + const saveView = (protocol: any, item: unknown, extra: Record = {}) => + protocol.saveMetaItem({ type: 'view', name: NAME, item, ...extra }); + const activeViewRows = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'view' && r.state === 'active'); + + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + afterEach(() => { + warn.mockRestore(); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + + it('(a) REFUSES an active save naming the file field at its entry; nothing stored', async () => { + const { protocol, rows } = viewProtocol(); + + const err = await saveView(protocol, withCertificate()).catch((e: any) => e); + + expect(err, 'the save resolved — the door still publishes the file field').toBeInstanceOf(Error); + expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const issues = (err.issues ?? []).filter((i: any) => i.rule === RULE); + expect(issues, `issues: ${JSON.stringify(err.issues)}`).toHaveLength(1); + expect(issues[0].severity).toBe('error'); + expect(issues[0].path).toContain('formViews.apply.sections[0].fields[2]'); + expect(issues[0].message).toContain('"certificate"'); + expect(activeViewRows(rows)).toEqual([]); + }); + + it('(a) gates the draft → active promotion, so the draft door is no bypass', async () => { + const { protocol } = viewProtocol(); + await expect(saveView(protocol, withCertificate(), { mode: 'draft' })).resolves.toMatchObject({ success: true }); + + const err = await protocol.publishMetaItem({ type: 'view', name: NAME }).catch((e: any) => e); + + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(err.issues.map((i: any) => i.rule)).toContain(RULE); + }); + + it('(b) the door and `os build` give the same finding', async () => { + const { protocol } = viewProtocol(); + const err = await saveView(protocol, withCertificate()).catch((e: any) => e); + const atDoor = (err.issues ?? []).filter((i: any) => i.rule === RULE); + + const stack = { objects: [supplier], views: [{ name: NAME, ...withCertificate() }] }; + const atBuild = runAuthoringRules('build', { normalized: stack, parsed: stack }).filter((f) => f.rule === RULE); + + expect(atBuild).toHaveLength(1); + expect(atDoor).toHaveLength(1); + expect(atDoor[0].message).toBe(atBuild[0]!.message); + expect(atDoor[0].hint).toBe(atBuild[0]!.hint); + }); + + it('(c) the same form without the file field saves', async () => { + const { protocol, rows } = viewProtocol(); + await expect(saveView(protocol, withoutCertificate())).resolves.toMatchObject({ success: true }); + expect(activeViewRows(rows)).toHaveLength(1); + }); +});