Skip to content

Commit 6ad226a

Browse files
feat(lint)!: refuse a form open to anonymous intake that declares a file field, at validate, compile and the publish gate (#25737)
Fixes #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` The protocol half of ruling C: 「访客表单不收附件。供应商先注册账号、登录后再填表上传…发布时响亮拒绝「公开表单声明了文件字段」」. The server half (the submit door refusing a file value) is #25572 / PR #25682 and is not touched here. ## What changes - **New gating authoring rule** `validatePublicFormFileFields` (`packages/lint/src/validate-public-form-file-fields.ts`, rule id `public-form-file-field`, exported as `PUBLIC_FORM_FILE_FIELD`). For every form a view opens to anonymous intake, it reads the fields its sections declare and refuses any whose type on the bound object is in `FILE_REFERENCE_TYPES` (`image`, `file`, `avatar`, `video`, `audio`). It names the field, the object and the entry path. The prescription is the ruled route: the visitor registers and signs in, then fills a form that is not public. - **Which forms are open** comes from the one rule both anonymous doors serve from: `anonymousFormIntakeCandidates` (`@objectstack/spec/ui`). That means `enabled: true`, `allowAnonymous: true` and a `publicLink`. It covers all three form shapes: a view's default `form`, each `formViews` entry, and the flattened `config` of a `viewKind: 'form'` item. Both section entry spellings are read (bare name and `{ field }`), as both doors read them, in `sections` and in the legacy `groups` bucket (`os lint` hands the rule the unparsed stack). - **The door that sees both halves.** A field's type lives on the object and `sharing` lives on the view, so a view-level `superRefine` cannot judge this. The rule is a stack-level entry in `AUTHORING_RULES` (`packages/lint/src/authoring-rules.ts`), the registry `os validate`, `os build` and `os lint` run. It also runs at the runtime publish gate with `runtimeTypes: ['view']`: a view write's snapshot carries `objects` as resolution context (`buildRuntimeWriteSnapshotSet`, `runtime-gate.ts`). So an active `view` save, or a draft promotion, answers `422 INVALID_METADATA` naming the entry. No door code moves. - Docs: `content/docs/ui/forms.mdx` (a new "File fields" subsection with the register-and-sign-in route), `content/docs/ui/public-data-collection.mdx` (the whitelist cannot hold a file field), `content/docs/deployment/validating-metadata.mdx` (the rule's row in the door table), and the four CLI transcripts whose printed author-time rule count moves from 52 to 53 (`check:docs-transcript-drift`). - Changeset `.changeset/25650-anonymous-form-file-refusal.md`: `@objectstack/lint` minor, BREAKING, with its ADR-0087 disposition (not-required, no-migration-prescription). ## Lead re-measurements (dispatch's mechanism assumptions) - **Open-to-anonymous is three switches, not one.** `anonymousFormIntakeSlug` requires `enabled === true`, `allowAnonymous === true` and a non-empty `publicLink`. A form with `allowAnonymous: true` alone is served by no door and is not refused. A pin covers each switch. - **`anonymousFormIntakeUnavailability` lives in `@objectstack/metadata-core`, not in the spec module.** The spec module holds the candidates half. Not read here: posture is a deployment question, and the publish-time refusal is a metadata question. - **"A field directly in the form" does not exist.** `FormViewSchema` is strict and has no `fields` key. Both anonymous doors read `form.sections[].fields[]` only (`form-routes.ts`: the GET schema narrowing and the submit `allowedFields`). The pins cover a direct entry in a section, a later section, the legacy `groups` bucket, and both entry spellings. - **Producers: 0.** None is broken. `examples/app-crm` was measured through the built `@objectstack/lint`: 0 findings, and 1 for a file field planted on its public form. `app-todo` and `app-multi-package` have no public forms. `app-showcase`'s one public form, read in source (`inquiry.view.ts`), uses `name`, `email`, `company`, `message`: text and email. `platform-objects` has no public forms. HotCRM at `a4b3560` has two public forms (`src/sales/views/lead.view.ts:941`, `src/service/views/case.view.ts:527`) whose fields are text, email, url, select, currency, number and markdown. objectui at `a3c0da9` authors no public form; its hits are renderer code and tests. ## Verification (head `f6099df44`) - `pnpm --filter @objectstack/lint test`: 162 files / 7293 tests passed. This includes the new `validate-public-form-file-fields.test.ts` (13) and the view-rule roster pins updated in `authoring-rule-wiring.test.ts`, `runtime-gate.test.ts` and `runtime-gate.page-visibility-predicate-writes.test.ts`. - `pnpm --filter @objectstack/metadata-protocol test`: 263 files / 21489 tests passed. This includes 4 new door pins in `protocol.runtime-authoring-gate.test.ts`: the active save is refused with `422 INVALID_METADATA` and nothing is stored; the draft to active promotion is refused; the door and `os build` give the same message and hint; the form without the file field saves. This run was on `e0aa3cb98`, before the docs-only and merge commits. - `pnpm --filter @objectstack/cli test` (unit + integration): 500 files / 6291 tests passed (on `d4c18b25`; later commits are docs and a `main` merge touching only `skills/` and release CI). - Consumer files that run the registry: objectql (3 files, 29), platform-objects (1, 2), plugin-approvals (2, 24), service-mailbox (1, 4), all passed. - `pnpm --filter @objectstack/lint --filter @objectstack/metadata-protocol run typecheck`: exit 0. - Full dogfood (the title carries `!`): `pnpm --filter @objectstack/dogfood test` passed, 295 files / 2520 tests (3 files and 18 tests skipped). Lock verdict `command-exit 0`. - Derived gates: `dispatch-gates.mjs --commands` gave 97 commands. All 97 exit 0, with `--ran` reconciling 97 derived / 97 run / 0 unrun. Two of them, `check:dts-closure` and `check:dual-build-cjs-loads`, first went red on `plugin-auth`'s `dist/` while the dogfood closure build was rewriting it concurrently, and were re-run green. The first-round red on `check:docs-transcript-drift` was real (rule count 52 to 53) and is fixed in this PR. - **Ablation** (`scripts/ablation-replace.mjs`, on the committed fix): the refusal guard was made always-skip (`|| fieldName !== 'ablation_25650_never'`), lint rebuilt, and the marker proved in `dist/` (4 files). Prediction: 8 of 13 lint pins red and 3 of 4 door pins red. Observed: 8 failed / 5 passed and 3 failed / 1 passed, exactly the predicted tests. Restored blob-identical (`3325e760befb`, `git diff HEAD` empty). On the restore leg, lint was rebuilt, the marker proved absent (`--absent`), and both suites ran green again (13/13, 4/4). ## Acceptance notes - An `object` write is not judged by this rule: its runtime snapshot carries no views. Changing a field's type to a file type under a published public form is caught by `objectstack validate` / `compile` and by that view's next publish, not by the object's save. `runtimeTypes` is `['view']` only, because declaring `object` would run the rule over nothing. - The bound object is resolved in the doors' rung order (`anonymousFormObjectName` in `@objectstack/metadata-core`: the form's own binding, then the view's list block, then its default form, then the view). Lint cannot import `metadata-core`, so the rule spells that order over lint's shared `viewObjectName` reader. Moving `anonymousFormObjectName` into `@objectstack/spec/ui`, beside the candidates half, would leave one copy. It is outside this card's declared file surface, so it is noted here and not done. - A section that names a field `group` instead of listing fields is not read for file fields. Neither anonymous door serves a group's members, so such a field is never on the public form. --- _Generated by [Claude Code](https://claude.ai/code/session_01UDTkSkW5Hks1YsBoNomhDR)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f9a1e93 commit 6ad226a

15 files changed

Lines changed: 517 additions & 4 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
feat(lint)!: `objectstack validate`, `compile` and the publish gate refuse a form open to anonymous intake that declares a file field (#25650)
6+
7+
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)
8+
Metadata-protocol: narrowed
9+
Ruling-ref: ruling C on #25572 (the maintainer, 「25619 C 25495 B」, recorded on PR #25619 with the seat record `6383110676`); triage `6385114404`
10+
11+
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.
12+
13+
**BREAKING — what moves for authors.**
14+
15+
- 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.
16+
17+
**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.
18+
19+
**Unchanged.**
20+
21+
- 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.
22+
- A form that is not open to anonymous intake (either switch off, or no `publicLink`) is not judged, whatever fields it declares.
23+
- 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.
24+
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
25+
- 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.
26+
27+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at `objectstack validate`, `compile` and the publish gate of a form open to anonymous intake that declares a file-typed field: no authorable key, spelling or stored shape moves, the parse is unchanged, and no stored row is read, rewritten or converted. A stored view carrying such a form keeps loading until it is next saved, and the repair is the author's choice of where the file is collected (off the public form, onto a signed-in form), which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a build and door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->

‎content/docs/deployment/cli.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -758,7 +758,7 @@ os compile --json # JSON output for CI pipelines
758758
→ Normalizing stack definition...
759759
→ Lowering inline handlers...
760760
→ Validating protocol compliance...
761-
→ Running author-time rules (52)...
761+
→ Running author-time rules (53)...
762762
→ Checking that every required capability has a provider installable in this edition...
763763
→ Collecting package docs (ADR-0046)... 0 collected
764764
→ Writing artifact...

‎content/docs/deployment/validating-metadata.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,7 @@ orthogonal to both, and no cell here can carry it; it is written out in
482482
| Membership key declarations — `userField` / `valueField` columns, one key on one object, a key the registered resolver also owns (save door only) (#25234) | ✓ | ✓ | ✓ | ✓ᵒ |
483483
| Platform-schedule `create_record` organization (#6285) | — | — | — | ✓ᶠ |
484484
| Public-form anonymous intake on this deployment's tenancy posture — advisory only (#21476) | — | — | — | ✓ᵛ |
485+
| Public-form file fields — a form open to anonymous intake whose sections declare a `file`, `image`, `avatar`, `video` or `audio` field (#25650) | ✓ | ✓ | ✓ | ✓ᵛ |
485486
| Autonumber `{field}` interpolation | ✓ | ✓ | ✓ | ✓ᵒ |
486487
| Email template HTML hole placements — a hole in a placement the mail renderer's encoding does not suit (#25189) | ✓ | ✓ | ✓ | ✓ᵉ |
487488
| View references — form targets, view-key collisions (#2554) | ✓ | ✓ | ✓ | — |
@@ -741,7 +742,7 @@ A clean run walks the registry and reports timing:
741742
Config: /path/to/support-desk/objectstack.config.ts
742743
Load time: 21ms
743744
→ Validating against ObjectStack Protocol...
744-
→ Running author-time rules (52)...
745+
→ Running author-time rules (53)...
745746
→ Checking that every required capability has a provider installable in this edition...
746747
→ Checking package docs (ADR-0046)...
747748

‎content/docs/getting-started/build-with-claude-code.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ visible: 'status != "resolved"'
305305
◆ Validate
306306
────────────────────────────────────────
307307
→ Validating against ObjectStack Protocol...
308-
→ Running author-time rules (52)...
308+
→ Running author-time rules (53)...
309309
310310
✗ Author-time rules failed (1 issue)
311311
• stack · action 'resolve_ticket' visible: bare reference `status` — a

‎content/docs/ui/forms.mdx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,19 @@ lookup route.
240240
- To let a visitor pick an existing record, put the form behind sign-in — an
241241
internal form (section 6) renders lookup fields with the signed-in user's access.
242242

243+
### File fields
244+
245+
A public form takes no attachment. There is no anonymous upload route, so a
246+
visitor has no way to fill a file field (`file`, `image`, `avatar`, `video` or
247+
`audio`). A form open to anonymous intake
248+
whose sections declare such a field is therefore refused where it is written:
249+
`os validate`, `os build` and `os lint` fail with `public-form-file-field`, and
250+
publishing the view through Studio, REST `/meta` or MCP answers
251+
`422 INVALID_METADATA` naming the field's entry.
252+
253+
To collect a file from someone outside the organization, have them register and
254+
sign in, then fill an internal form (section 6) that declares the file field.
255+
243256
### Auth model
244257

245258
- Neither route calls `enforceAuth`, so both work under the always-on anonymous-deny default (there is no `requireAuth` knob to configure since v17).

‎content/docs/ui/public-data-collection.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ POST /api/v1/forms/contact-us/submit → INSERT one showcase_inquiry
4646

4747
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.
4848

49+
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.
50+
4951
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.
5052

5153
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).

‎content/docs/ui/react-pages.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ objectstack validate
390390
────────────────────────────────────────
391391
→ Loading configuration...
392392
→ Validating against ObjectStack Protocol...
393-
→ Running author-time rules (52)...
393+
→ Running author-time rules (53)...
394394
→ Checking that every required capability has a provider installable in this edition...
395395
→ Checking package docs (ADR-0046)...
396396

‎packages/lint/src/authoring-rule-wiring.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,8 @@ describe('authoring-rule registry wiring (#4409)', () => {
511511
'validateStackExpressions',
512512
'validatePresetComparands',
513513
'validateReferenceIntegrity',
514+
// #25650 — a form open to anonymous intake declaring a file field.
515+
'validatePublicFormFileFields',
514516
...FAMILY,
515517
]);
516518
});

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ import { validateRecordTitle } from './validate-record-title.js';
128128
import { validateFieldConsumers } from './validate-field-consumers.js';
129129
import { validateSemanticRoles } from './validate-semantic-roles.js';
130130
import { validateFormLayout } from './validate-form-layout.js';
131+
import { validatePublicFormFileFields } from './validate-public-form-file-fields.js';
131132
import { validateSeedReplaySafety } from './validate-seed-replay-safety.js';
132133
import { validateSeedStateMachine } from './validate-seed-state-machine.js';
133134
import { validateVisibilityPredicates } from './validate-visibility-predicates.js';
@@ -1569,6 +1570,26 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
15691570
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
15701571
run: (stack) => validateFormLayout(stack),
15711572
},
1573+
// Ruling C on #25572 (#25650) — a form open to anonymous intake that
1574+
// declares a file field (a `FILE_REFERENCE_TYPES` type on the bound object)
1575+
// is refused: no visitor can upload a file, so the field can never be
1576+
// filled. The prescription is the ruled route: register and sign in, then
1577+
// fill a form that is not public. Gating, so all three commands run it.
1578+
//
1579+
// Runtime publish gate: a `view` write's snapshot carries `objects` as
1580+
// context, which is every input this rule reads, so the view's publish is
1581+
// refused at the key. `object` is deliberately not declared: an object
1582+
// write's snapshot carries no views, so the rule would judge nothing there.
1583+
{
1584+
name: 'validatePublicFormFileFields',
1585+
tier: 'gating',
1586+
input: 'parsed',
1587+
commands: ALL,
1588+
source: 'packages/lint/src/validate-public-form-file-fields.ts',
1589+
surfaces: CLI_AND_RUNTIME,
1590+
runtimeTypes: ['view'],
1591+
run: (stack) => validatePublicFormFileFields(stack),
1592+
},
15721593
// ADR-0078 Phase 3 (Tier-A `action-locations`) — an action that declares no
15731594
// `locations` and that no view places by name renders on no surface at all.
15741595
// objectui#3142 made that measurable: four renderers used to show an

‎packages/lint/src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,10 @@ export {
260260
} from './validate-form-layout.js';
261261
export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js';
262262

263+
// A form open to anonymous intake that declares a file field (ruling C on #25572).
264+
export { validatePublicFormFileFields, PUBLIC_FORM_FILE_FIELD } from './validate-public-form-file-fields.js';
265+
export type { PublicFormFileFieldFinding } from './validate-public-form-file-fields.js';
266+
263267
export {
264268
validateVisibilityPredicates,
265269
VISIBILITY_ROOT_MISLAYERED,

0 commit comments

Comments
 (0)